powershellbatch-filecmdadminelevation

How to execute Powershell's "start-process -Verb RunAs" from inside a Batch where the elevated command inherits the Batch's environment?


1. Problem

I have a complicated batch file where some parts need to run with elevated/admin rights (e.g. interacting with Windows services) and I found a Powershell way to do that:

powershell.exe -command "try {$proc = start-process -wait -Verb runas -filepath '%~nx0' -ArgumentList '<arguments>'; exit $proc.ExitCode} catch {write-host $Error; exit -10}"

But there's a huge caveat! The elevated instance of my script (%~nx0) starts with a fresh copy of environment variables and everything I set "var=content" before is unavailable.

2. What I've tried so far

This Powershell script doesn't help either because Verb = "RunAs" requires UseShellExecute = $true which in turn is mutually exclusive to/with StartInfo.EnvironmentVariables.Add()

$p = New-Object System.Diagnostics.Process
$p.StartInfo.FileName = "cmd.exe";
$p.StartInfo.Arguments = '/k set blasfg'
$p.StartInfo.UseShellExecute = $true;
$p.StartInfo.Verb = "RunAs";
$p.StartInfo.EnvironmentVariables.Add("blasfg", "C:\\Temp")

$p.Start() | Out-Null
$p.WaitForExit()
exit $p.ExitCode

And even if that would work I'd still need to transfer dozens of variables...

3. unappealing semi-solutions

because circumventing the problem is no proper solution.

  1. helper tools like hstart - because I can't relay on external tools. Only CMD, Powershell and maybe VBscript (but it looks like runas plus wait and errorlevel/ExitCode processing isn't possible with/in vbs).
  2. passing (only required) variables as arguments - because I need dozens and escaping them is an ugly chore (both the result and doing it).
  3. restarting the whole script - because it's inefficient with all the parsing, checking processing and other tasks happening again (and again and ...). I'd like to keep the elevated parts to a minimum and some actions can later be run as a normal user (e.g service start/stop).
  4. Writing the environment to a file and rereading it in the elevated instance - because it's an ugly hack and I'd hope there's a cleaner option out there. And writing possibly sensitive information to a file is even worse than storing it temporarily in an environment variable.

Solution

  • Here's a proof of concept that uses the following approach:

    Caveat: This solution blindly recreates all environment variables defined in the caller's process in the elevated process - consider pre-filtering, possibly by name patterns, such as by a shared prefix; e.g., to limit variable re-creation to those whose names start with foo, replace Get-ChildItem Env: with Get-ChildItem Env:foo* in the command below.

    @echo off & setlocal
    
    :: Test if elevated.
    net session 1>NUL 2>NUL && goto :ELEVATED 
    
    :: Set sample env. vars. to pass to the elevated re-invocation.
    set foo1=bar
    set "foo2=none      done"
    set foo3=3" of snow
    :: " dummy comment to fix syntax highlighting
    :: Helper variable to facilitate re-invocation.
    set "thisBatchFilePath=%~f0"
    
    :: Re-invoke with elevation, synchronously, reporting the exit
    :: code of the elevated run.
    :: Two sample arguments, ... and "quoted argument" are passed on re-invocation.
    powershell -noprofile -command ^
      trap { [Console]::Error.WriteLine($_); exit -667 } ^
      exit ( ^
        Start-Process -Wait -PassThru -Verb RunAs powershell ^
          "\" -noprofile -command `\" $(Get-ChildItem Env: | ForEach-Object { 'Set-Item \\\"env:' + $_.Name + '\\\" \\\"' + $($_.Value -replace '\""', '`\\\""') + '\\\"; ' }) cmd /c '\`\"%thisBatchFilePath:'=''%\`\" ... \`\"quoted argument\`\" & exit'; exit `$LASTEXITCODE`\" \"" ^
      ).ExitCode 
    
    echo -- Elevated re-invocation exited with %ERRORLEVEL%.
    
    :: End of non-elevated part.
    exit /b
    
    :ELEVATED
    
    echo Now running elevated...
    
    echo -- Arguments received:
    echo [%*]
    
    echo -- Env. vars. whose names start with "foo":
    set foo 
    
    :: Determine the exit code to report.
    set ec=5
    
    echo -- Exiting with exit code %ec%...
    :: Pause, so you can inspect the output before exiting.
    pause
    exit /b %ec%
    

    Note: