Today I Learned … PowerShell | Elevation | Credential

I’ve been working on this far longer than I’m proud of, but this is it:
IT IS POSSIBLE to execute a powershell scriptblock from a non-admin session that does elevation and also makes use of user credentials ๐Ÿ™‚

Elevation (the easy part)

param(
    [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
    [ScriptBlock]$scriptBlock
)

$scriptBlockString = $scriptBlock.ToString()
$captureElevatedScriptBlock = @"
try {
    `$ErrorActionPreference = 'Stop'
    & { $scriptBlockString }
} catch {
    Write-Output `$_
    if (-Not `$lastexitcode) {
        `$lastexitcode = 1
    }
} finally {
    if (-Not `$lastexitcode) {
        `$lastexitcode = 0
    }
    exit `$lastexitcode
}
"@

$myWd = $(Get-Location).Path
$startElevatedProcessArgs = @{
    FilePath     = "PowerShell.exe"
    PassThru     = $true
    WorkingDir   = "$myWd"
    Verb         = "runas"
    ArgumentList = @("-Command", "& { Set-Location '$myWd'; $captureElevatedScriptBlock }")
}

$p = Start-Process @startElevatedProcessArgs -Wait
$p.WaitForExit()
$res = @{
    ExitCode = $p.ExitCode
}

This snippet just runs arbitrary commands (in a script-block) with elevation (if the current user is eligible for elevation). But wait, there’s no ouput of the started process, also output redirect doesn’t seem to be working when coming from a non-elevated session…

Using ‘Tee-Object’ to obtain the output of an elevated process

...
$myRandom = $([System.Guid]::NewGuid())
$myTmpPath = $([System.IO.Path]::GetTempPath())
$elevatedTextOutputFile = $(Join-Path $myTmpPath "$myRandom.elevated.log")
 
$captureElevatedScriptBlock = @"
try {
    `$ErrorActionPreference = 'Stop'
    & { $scriptBlockString } | Tee-Object $elevatedTextOutputFile
}
...
$res = @{
    ExitCode = $p.ExitCode
    Output   = Get-Content $elevatedTextOutputFile
}
Remove-Item $elevatedTextOutputFile

This already looks promising ๐Ÿ™‚ – we now can run any command with elevation and also get it’s exit code (partially) and console output!
So what’s missing? – In my use case I’m always running certain scripts as a non-administrative user, and I need to to use [PSCredentials] (which are known) and then also do elevation.
You can’t use both “-verb runas” and “-Credential” in one call (or at least I didn’t manage to in a couple of hours!” – so I’ve come up with this solution:

We’ll make use of two wrappers:
1. already known – get output of elevated process
2. new: start the first wrapper from a new powershell.exe process that is started with “-Credentials”

Running a scriptblock with credentials

$startWithCredentialsArgs = @{
    FilePath               = "PowerShell.exe"
    PassThru               = $true
    WorkingDir             = $myWd
    ArgumentList           = @("-ExecutionPolicy bypass", "-noprofile", "-noninteractive", "-Command", "& { Set-Location '$myWd'; $someScriptBlock }")
    Credential             = $Credential
    RedirectStandardOutput = $wrapperScriptLog
}

$p = Start-Process @startWithCredentialsArgs -Wait
$p.WaitForExit()

Putting it all together

param(
    [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
    [ScriptBlock]$scriptBlock,
    
    [Parameter(Mandatory = $False)]
    [pscredential]$Credential
)

$myRandom = $([System.Guid]::NewGuid())
$myTmpPath = $([System.IO.Path]::GetTempPath())
$elevatedTextOutputFile = $(Join-Path $myTmpPath "$myRandom.elevated.log")
  
$scriptBlockString = $scriptBlock.ToString()
$captureElevatedScriptBlock = @"
try {
    `$ErrorActionPreference = 'Stop'
    & { $scriptBlockString } | Tee-Object $elevatedTextOutputFile
} catch {
    Write-Output `$_
    if (-Not `$lastexitcode) {
        `$lastexitcode = 1
    }
} finally {
    if (-Not `$lastexitcode) {
        `$lastexitcode = 0
    }
    exit `$lastexitcode
}
"@

$myWd = $(Get-Location).Path
$startElevatedProcessArgs = @{
    FilePath     = "PowerShell.exe"
    PassThru     = $true
    WorkingDir   = "$myWd"
    Verb         = "runas"
    ArgumentList = @("-Command", "& { Set-Location '$myWd'; $captureElevatedScriptBlock }")
}

if (-Not $Credential) {
    # easy: run with the current user context
    $p = Start-Process @startElevatedProcessArgs -Wait
    $p.WaitForExit()
    $res = @{
        ExitCode = $p.ExitCode
        Output   = Get-Content $elevatedTextOutputFile
    }
}
else {
    # tricky: need to switch to a new session with given credentials ...
    # this session then needs to run the above code actually calling the elevated session
    $wrapperScript = Join-Path $myTmpPath "$myRandom.magic.ps1"
    $wrapperScriptLog = "$wrapperScript.log"

    @"
    `$startElevatedProcessArgs = @{
        FilePath     = `"PowerShell.exe"
        PassThru     = `$true
        WorkingDir   = '$myWd'
        Verb         = "runas"
        ArgumentList = @(`"-noexit`",`"-Command`", `"& { Set-Location '$myWd'; $($captureElevatedScriptBlock.Replace("`$","```$")) }")
    }
    `$p = Start-Process `@startElevatedProcessArgs -Wait
    `$p.WaitForExit()
    exit `$p.ExitCode
"@ | Out-File $wrapperScript -Encoding default

    $startWithCredentialsArgs = @{
        FilePath               = "PowerShell.exe"
        PassThru               = $true
        WorkingDir             = $myWd
        ArgumentList           = @("-ExecutionPolicy bypass", "-noprofile", "-noninteractive", "-Command", "& { Set-Location '$myWd'; $wrapperScript }")
        Credential             = $Credential
        RedirectStandardOutput = $wrapperScriptLog
    }

    $p = Start-Process @startWithCredentialsArgs -Wait
    $p.WaitForExit()
    Remove-Item $wrapperScript

    $res = @{
        ExitCode      = $p.ExitCode
        Output        = Get-Content $elevatedTextOutputFile
        WrapperOutput = Get-Content $wrapperScriptLog
    }
    Remove-Item $wrapperScriptLog
}

Remove-Item $elevatedTextOutputFile
$res

Of course, this source code is also available on GitHub!

Leave a Reply

Your email address will not be published. Required fields are marked *