1

I'm trying to update an elevated PowerShell script that's using StartProcess on a BAT file that runs RunAs on PowerShell.exe to run another PowerShell script without elevation in order to clone a git repository so that the directory is created in a way that a normal non-elevated user will be able to use.

  • Elevated PS1: Start-Process
    • => Elevated .BAT: RunAs /trustlevel:0x20000
      • => Non-elevated PS1

This is failing in some environments and I can't figure out why so I'm trying to figure out how to capture stdout and stderr from all levels of this process, but I'm not seeing the error or any output. I can capture it down to the BAT file level, but I can't seem to see anything that's happening within the inner-most Powershell script.

This seems like an awful lot of work just to programmatically clone a Git repository from an elevated process. Is there a way to make this work or is there an easier way?

EDIT: Just learned that this solution was broken as of Windows 11 Update 22H2: https://superuser.com/questions/1749696/parameter-is-incorrect-when-using-runas-with-trustlevel-after-windows-11-22h2 but the workaround is to use the /machine switch when running RunAs.

BlueMonkMN
  • 25,079
  • 9
  • 80
  • 146
  • How about using a very different, simple and stable approach? Do you have any software deployment, patchmanagement... At least active directory might install the script and update outdated files via gpo. Otherwise try using a scheduled tasks for updating - maybe upon creation with a script that makes self deletion of the task after success.... – An-dir Nov 21 '22 at 23:40
  • 1
    What about using Named Pipes? For example create named pipe on child process (**System.IO.Pipes.NamedPipeServerStream**), redirect StdOut (or put any data) to this named pipe. From parent (privileged) process connect to child's pipe for reading stream. – Daemon-5 Nov 28 '22 at 09:55
  • @Daemon-5 I see the pipe can skip over the bat and just talk between the outermost PowerShell and the innermost PowerShell. I will add an answer with details. Thanks. – BlueMonkMN Nov 29 '22 at 22:10
  • 1
    You said "talk between the outermost PowerShell and the innermost PowerShell." And batch process can use the created pipe too: **echo DATA > \\.\pipe\SAMPipe** – Daemon-5 Nov 30 '22 at 01:25
  • @Daemon-5 I tried updating runas to direct output with >\\.\pipe\SAMPipe instead of capturing output in test2.ps1 but that just seemed to cause the parent process to hang unable to connect the pipe. Did I miss something? – BlueMonkMN Nov 30 '22 at 14:51
  • I suppose in this case: receiving pipe should have apropriate direction **[System.IO.Pipes.PipeDirection]::In** (or InOut) – Daemon-5 Nov 30 '22 at 15:42
  • @Daemon-5 That is what I have in test.ps1 where the receiving pipe is created. – BlueMonkMN Nov 30 '22 at 17:20

3 Answers3

1

This can be solved with a named pipe.

Elevated PowerShell Script (test.ps1)

function IsAdmin{
    $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
    $Is64 = [Environment]::Is64BitOperatingSystem
    if ($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        Write-Output "Running with elevated privileges. (64-bit=$Is64)"
    } else {
        Write-Output "Running without elevated privileges. (64-bit=$Is64)"
    }
}

IsAdmin
Write-Output "Running $PSScriptRoot\test.bat"
Start-Process -FilePath "$PSScriptRoot\test.bat" -ArgumentList "C:\" -NoNewWindow
$np = new-object System.IO.Pipes.NamedPipeClientStream('.','SAMPipe', [System.IO.Pipes.PipeDirection]::In,[System.IO.Pipes.PipeOptions]::None,[System.Security.Principal.TokenImpersonationLevel]::Impersonation)
$np.Connect()
$sr = new-object System.IO.StreamReader($np)
while ($l=$sr.ReadLine()) {
   Write-Output $l
}
$sr.Close()
$np.Close()

BAT file in the middle to de-elevate (test.bat)

runas /machine:amd64 /trustlevel:0x20000 "powershell -command %~dp0test2.ps1 -drive %1 >dummy.txt"

Non-Elevated PowerShell Script (test2.ps1)

param([string]$drive)

function IsAdmin{
    $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
    $Is64 = [Environment]::Is64BitOperatingSystem
    if ($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        Write-Output "Running with elevated privileges. (64-bit=$Is64)"
    } else {
        Write-Output "Running without elevated privileges. (64-bit=$Is64)"
    }
}

function Setup-Test{
    Write-Output "Testing Powershell with Parameter Drive=$drive"
    git config --global user.name
    cd bob
    Write-Error "Error Line 1
Error Line 2"
    Write-Error "Error Line 3"
    $d = 3/0
    Write-Output "Done Testing Powershell"
}

$np = New-Object System.IO.Pipes.NamedPipeServerStream('SAMPipe',[System.IO.Pipes.PipeDirection]::Out)
$np.WaitForConnection()
$sw = New-Object System.IO.StreamWriter($np)
$sw.WriteLine('Begin Non-Elevated Process Pipe')
Invoke-Command -ScriptBlock {
    try {
        IsAdmin
        Setup-Test
    } catch {
        Write-Error $_
    } 
} -ErrorVariable errVar -OutVariable out
foreach ($line in $out){
    $sw.WriteLine($line)
}
foreach ($line in $errVar) {
    $sw.WriteLine($line)
}
$sw.WriteLine('End Non-Elevated Process Pipe')
$sw.Close()
$np.Close()

Output

Running with elevated privileges. (64-bit=True)
Running C:\Users\bmarty\source\PowerShellTest\test.bat

C:\Users\bmarty\source\PowerShellTest>runas /machine:amd64 /trustlevel:0x20000 "powershell -command C:\Users\bmarty\source\PowerShellTest\test2.ps1 -drive C:\ >dummy.txt"
Begin Non-Elevated Process Pipe
Running without elevated privileges. (64-bit=True)
Testing Powershell with Parameter Drive=C:\
Ben Marty
Cannot find path 'C:\Users\bmarty\source\PowerShellTest\bob' because it does not exist.
Error Line 1
Error Line 2
Error Line 3
Attempted to divide by zero.
System.Management.Automation.RuntimeException: Attempted to divide by zero. ---> System.DivideByZeroException: Attempted to divide by zero.
   --- End of inner exception stack trace ---
   at System.Management.Automation.ExceptionHandlingOps.CheckActionPreference(FunctionContext funcContext, Exception exception)
   at System.Management.Automation.Interpreter.ActionCallInstruction`2.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
Attempted to divide by zero.
Attempted to divide by zero.
End Non-Elevated Process Pipe
Done running

I don't understand why the output of git config only appears in the output if I include >dummy.txt in the BAT file.

BlueMonkMN
  • 25,079
  • 9
  • 80
  • 146
1

I suggest simplifying your approach as follows:

  • Use synchronous invocation of runas.exe, via Start-Process -Wait, which obviates the need for an intermediate batch file, and the need for a named pipe (System.IO.Pipes.NamedPipeClientStream)

  • Let the runas.exe-launched PowerShell child process that runs test2.ps1 capture that script's output in a temporary file, which you can read after the Start-Process -Wait call returns.

  • test2.ps1 can then just produce output normally - no need for System.IO.Pipes.NamedPipeClientStream

Elevated PowerShell Script (test.ps1):

function IsAdmin{
  $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
  $Is64 = [Environment]::Is64BitOperatingSystem
  if ($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
      Write-Output "Running with elevated privileges. (64-bit=$Is64)"
  } else {
      Write-Output "Running without elevated privileges. (64-bit=$Is64)"
  }
}

IsAdmin

# Create a temporary file in which to capture the output from the 
# PowerShell child process launched by runas.exe.
$outFile = New-TemporaryFile

# Use Start-Process -Wait to directly invoke runas.exe,
# which doesn't just wait for runas.exe ITSELF to exit, but also
# waits for its CHILD processes.
# This ensures that execution is blocked until the other PowerShell script exits too. 
Start-Process -Wait runas.exe @"
/machine:amd64 /trustlevel:0x20000 "powershell -c & \"$PSScriptRoot\test2.ps1\" -drive C:\ *> \"$outFile\""
"@

# Now $outFile contains all output produced by the other PowerShell script.
Write-Verbose -Verbose "Output from the runas.exe-launched PowerShell script:"
Get-Content -LiteralPath $outFile

$outFile | Remove-Item # Clean up.

Non-Elevated PowerShell Script (test2.ps1):

param([string]$drive)

function IsAdmin{
    $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
    $Is64 = [Environment]::Is64BitOperatingSystem
    if ($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        Write-Output "Running with elevated privileges. (64-bit=$Is64)"
    } else {
        Write-Output "Running without elevated privileges. (64-bit=$Is64)"
    }
}

function Setup-Test{
    Write-Output "Testing Powershell with Parameter Drive=$drive"
    git config --global user.name
    cd bob
    Write-Error "Error Line 1
Error Line 2"
    Write-Error "Error Line 3"
    $d = 3/0
    Write-Output "Done Testing Powershell"
}

IsAdmin
Setup-Test
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • @BlueMonkMN, good point: you need *> I’ve updated the answer. – mklement0 Dec 01 '22 at 00:09
  • @BlueMonkMN, yes, on Windows process command lines (as interpreted by those executables built on the Microsoft C/C++/.NET compilers and those that use the `CommandLineToArgv` WinAPI function) ``\`` only acts as the escape character before `"` (and ``\`` itself, if part of a run followed by `"`) - all other instances are taken literally. – mklement0 Dec 01 '22 at 15:57
  • Wow, I like this solution so much better, and it captured much more of the output too. Two things I don't understand: 1. Why does Get-Content work without the -Path switch? 2. I didn't use the Remove-Item line, but why would you pipe the file name into it instead of passing it as the argument to -Path? – BlueMonkMN Dec 01 '22 at 16:16
  • 1
    Glad to hear it, @BlueMonkMN. PowerShell cmdlets (selectively) support _positional_ (unnamed) arguments. The (first) such argument passed to `Get-Content` binds to the `-Path` parameter - see [this answer](https://stackoverflow.com/a/65617895/45375). Piping to `Remove-Item` is more robust, because it binds to the `-LiteralPath` parameter (rather than `-Path`), which prevents inadvertent interpretation of its argument as a _wildcard_ expression; the alternative is to use `Remove-Item -LiteralPath $outFile` - see [this answer](https://stackoverflow.com/a/60177977/45375). – mklement0 Dec 01 '22 at 16:24
  • 1
    P.S., @BlueMonkMN, it follows from the previous comment that `Get-Content -LiteralPath $outFile` should be used for full robustness (I've updated the answer). In practice (at least on Windows), the difference between `-LiteralPath` and `-Path` only matters for paths that contain `[` and `]`, which are metacharacters in PowerShell's [wildcard expressions](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Wildcards). – mklement0 Dec 01 '22 at 17:07
  • What does the ampersand on the runas line accomplish? – BlueMonkMN Dec 02 '22 at 15:27
  • 1
    @BlueMonkMN, it is the call operator, which is required for invocation of the script syntactic reasons, namely because the script-file path is quoted - see [this answer](https://stackoverflow.com/a/57678081/45375). – mklement0 Dec 02 '22 at 15:30
0

Try working with ACLs instead. You can set that up on the parent directory so you don't even need to run the script in an elevated context.

Just set up a "gitclone" account that can write into the repository parent directory and then add the rest of the users as read+execute.

The rest will come automagically through inheritance.

Then run script as that "gitclone" user.

BlueMonkMN
  • 25,079
  • 9
  • 80
  • 146
Ralph Sch
  • 77
  • 6