Last active
May 27, 2025 07:41
-
-
Save PanosGreg/2bd423ecb055df3db36b29c534cbae64 to your computer and use it in GitHub Desktop.
Run a scriptblock in a timer using a runspace
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Function Invoke-WithImpersonation { | |
<# | |
.SYNOPSIS | |
Invoke a scriptblock as another user. | |
.DESCRIPTION | |
Invoke a scriptblock and run it in the context of another user as supplied by -Credential. | |
This is the 2nd file in this gist, there is also another file called Start-RunspaceJob.ps1 which calls this function | |
.PARAMETER ScriptBlock | |
The PowerShell code to run. It is recommended to use '{}.GetNewClosure()' to ensure the scriptblock has access to | |
the same values where it was defined. Anything output by this scriptblock will also be outputted by | |
Invoke-WithImpersonation. | |
.PARAMETER Credential | |
The PSCredential that specifies the user to run the scriptblock as. This needs to be a valid local or domain user | |
except when using '-LogonType NewCredential'. The user specified must have been granted the 'logon as ...' right | |
for the -LogonType that was requested (except for -LogonType NewCredential). | |
.PARAMETER LogonType | |
The logon type to use for the impersonated token. By default it is set to 'Interactive' which is the logon type | |
used when a user has logged on interactively. Each logon type has their own unique characteristics as specified. | |
Batch: Replicates running as a scheduled task, will typically have the full rights of the user specified. | |
Interactive: Replicates running as a normal logged on user, may have limited rights depending on whether UAC | |
is enabled. | |
Network: Replicates running from a network logon like WinRM, will not be able to delegate it's credential to | |
further downstream servers. | |
NetworkCleartext: Like Network but will have access to its credentials for delegation, similar to using | |
CredSSP auth for WinRM. | |
NewCredential: Can be used to specify any credentials and any network auth attempts will use those credentials. | |
Any local actions are run as the existing users token. | |
Service: Replicates running as a Windows service. | |
.EXAMPLE | |
#Run as an interactive logon | |
$cred = Get-Credential | |
Invoke-WithImpersonation -Credential $cred -ScriptBlock { | |
[System.Security.Principal.WindowsIdentity]::GetCurrent().Name | |
}.GetNewClosure() | |
.EXAMPLE | |
#Access a network path with explicit credentials | |
$cred = Get-Credential # Can be any username/password, does not have to be a valid local or domain account. | |
$files = Invoke-WithImpersonation -Credential $cred -LogonType NewCredential -ScriptBlock { | |
Get-ChildItem -Path \\192.168.1.1\share\folder | |
}.GetNewClosure() | |
.NOTES | |
Starting a new process in the scriptblock will run as the original user and not the user supplied by -Credential. | |
Use 'Start-Process' with -Credential to create a new process as another user. | |
I need to thank Jordan Borean for his great work on this function. | |
The actual source for this is here: https://gist.github.com/jborean93/3c148df03545023c671ddefb2d2b5ffc | |
His C# mastery is quite remarkable. | |
#> | |
[CmdletBinding(DefaultParameterSetName='Block')] | |
param ( | |
[Parameter(Mandatory=$true,Position=0,ParameterSetName='Block')] | |
[Parameter(Mandatory=$true,Position=0,ParameterSetName='BlockWithParams')] | |
[Parameter(Mandatory=$true,Position=0,ParameterSetName='BlockWithArgs')] | |
[ScriptBlock]$ScriptBlock, | |
[Parameter(Mandatory=$true,Position=0,ParameterSetName='String')] | |
[Parameter(Mandatory=$true,Position=0,ParameterSetName='StringWithParams')] | |
[Parameter(Mandatory=$true,Position=0,ParameterSetName='StringWithArgs')] | |
[String]$ScriptString, | |
[Parameter(Mandatory=$true,Position=1)] | |
[PSCredential]$Credential, | |
[Parameter(Mandatory=$true,Position=2,ParameterSetName='BlockWithArgs')] | |
[Parameter(Mandatory=$true,Position=2,ParameterSetName='StringWithArgs')] | |
[object[]]$ArgumentList, | |
[Parameter(Mandatory=$true,Position=2,ParameterSetName='BlockWithParams')] | |
[Parameter(Mandatory=$true,Position=2,ParameterSetName='StringWithParams')] | |
[hashtable]$ParameterList, | |
[ValidateSet('Batch', 'Interactive', 'Network', 'NetworkCleartext', 'NewCredential', 'Service')] | |
[String]$LogonType = 'Interactive' | |
) | |
if ($PSCmdlet.ParameterSetName -match 'String') { | |
$ScriptBlock = [scriptblock]::Create($ScriptString) | |
} | |
$code = @' | |
[DllImport("Advapi32.dll", EntryPoint = "ImpersonateLoggedOnUser", SetLastError = true)] | |
private static extern bool NativeImpersonateLoggedOnUser( | |
SafeHandle hToken); | |
public static void ImpersonateLoggedOnUser(SafeHandle token) | |
{ | |
if (!NativeImpersonateLoggedOnUser(token)) | |
{ | |
throw new System.ComponentModel.Win32Exception(); | |
} | |
} | |
[DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] | |
private static extern bool LogonUserW( | |
string lpszUsername, | |
string lpszDomain, | |
IntPtr lpszPassword, | |
UInt32 dwLogonType, | |
UInt32 dwLogonProvider, | |
out Microsoft.Win32.SafeHandles.SafeWaitHandle phToken); | |
public static Microsoft.Win32.SafeHandles.SafeWaitHandle LogonUser(string username, string domain, | |
System.Security.SecureString password, uint logonType, uint logonProvider) | |
{ | |
IntPtr passPtr = Marshal.SecureStringToGlobalAllocUnicode(password); | |
try | |
{ | |
Microsoft.Win32.SafeHandles.SafeWaitHandle token; | |
if (!LogonUserW(username, domain, passPtr, logonType, logonProvider, out token)) | |
{ | |
throw new System.ComponentModel.Win32Exception(); | |
} | |
return token; | |
} | |
finally | |
{ | |
Marshal.ZeroFreeGlobalAllocUnicode(passPtr); | |
} | |
} | |
[DllImport("Advapi32.dll")] | |
public static extern bool RevertToSelf(); | |
'@ | |
Add-Type -Namespace PInvoke -Name NativeMethods -MemberDefinition $code | |
$OriginalUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name | |
if ($OriginalUser.IndexOf('\') -gt 1) {$OriginalUser = $OriginalUser.Split('\')[1]} | |
$logonTypeInt = switch($LogonType) { | |
Interactive { 2 } # LOGON32_LOGON_INTERACTIVE | |
Network { 3 } # LOGON32_LOGON_NETWORK | |
Batch { 4 } # LOGON32_LOGON_BATCH | |
Service { 5 } # LOGON32_LOGON_SERVICE | |
NetworkCleartext { 8 } # LOGON32_LOGON_NETWORK_CLEARTEXT | |
NewCredential { 9 } # LOGON32_LOGON_NEW_CREDENTIALS | |
} | |
$user = $Credential.UserName | |
$domain = $null | |
if ($user.Contains('\')) { | |
$domain, $user = $user -split '\\', 2 | |
} | |
try { | |
$token = [PInvoke.NativeMethods]::LogonUser( | |
$user, | |
$domain, | |
$Credential.Password, | |
$logonTypeInt, | |
0 # LOGON32_PROVIDER_DEFAULT | |
) | |
Write-Verbose "Impersonate the user $user with $LogonType logon" | |
[PInvoke.NativeMethods]::ImpersonateLoggedOnUser($token) | |
try { | |
if ($ArgumentList.Count -gt 0) {$ScriptBlock.Invoke($ArgumentList)} | |
elseif ($ParameterList.Keys.Count -gt 0) {& $ScriptBlock @ParameterList} | |
else {& $ScriptBlock} | |
} | |
finally { | |
Write-Verbose "Revert back to the original context of user $OriginalUser" | |
$null = [PInvoke.NativeMethods]::RevertToSelf() | |
} | |
} | |
catch { | |
$PSCmdlet.WriteError($_) | |
} | |
finally { | |
if ($token) { $token.Dispose() } | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function Start-RunspaceJob { | |
<# | |
.SYNOPSIS | |
It runs a command on a timer, so it can abort the command if the timeout expires. | |
It can also run the command as a different user. | |
It allows the end-user to pass input parameters to the command. | |
.EXAMPLE | |
Start-RunspaceJob -Scriptblock { | |
$p1 = ' ' * 9 ; $p2 = ' ' * 18 | |
Write-Verbose "$p1 1 [V] Will do A" -Verbose | |
Start-Sleep 1 | |
Write-Output "$p2 2 [O] This is A" | |
Write-Verbose "$p1 3 [V] Will do b" -Verbose | |
Start-Sleep 1 | |
Write-Warning "$p1 4 [W] Issue with B" | |
Write-Output "$p2 5 [O] This is B" | |
Write-Verbose "$p1 6 [V] Will do c" -Verbose | |
Start-Sleep 1 | |
Write-Error '7 [E] Error with C' | |
Write-Verbose "$p1 8 [V] Done" -Verbose | |
} | |
an example command, that has multiple streams (like verbose and warning) | |
and also returns output in pieces throughout the execution runtime (due to the 1sec waits) | |
.EXAMPLE | |
Start-RunspaceJob -Scriptblock { | |
Start-Sleep 1 | |
'AAA' | |
Start-Sleep 2 | |
'BBB' | |
Start-Sleep 2 | |
'CCC' | |
} -TimeoutSec 4 | |
an example command that returns partial output because there's not enough time to finish the entire command. | |
And shows a warning message for the timeout expiration. | |
.EXAMPLE | |
$result = Start-RunspaceJob -Scriptblock { | |
function Get-MyService {Get-Service 'does-not-exist'} | |
Get-MyService | |
} -DontRenewErrors | |
an example where we use the DontRenewErrors flag to not re-hydrate the error message | |
but rather pass it directly as it returns from the runspace invocation output. | |
This way we can better drill in on exactly where the error is coming from (inside the user's scriptblock). | |
.NOTES | |
Author: Panos Grigoriadis | |
Date: 26-May-2025 | |
Version: 2.0.0 | |
Notes: | |
About the .EndInvoke() method | |
The .EndInvoke() does actually output $null, which is caught by the caller of the function. | |
So you have to silence it via out-null or use [void]. | |
About the .ReadAll() method | |
The .ReadAll() method does 2 things actually. It returns the items of the collection. | |
And also it removes them from the collection. | |
About warning stream redirection | |
I'm redirecting the Warning stream (#3) to the Normal stream (#1) to show a message that the timeout expired | |
because the output of the command is doing the same, so i'm keeping the same pattern | |
so that the end-user can collect the entirety of the output from this function, without missing this warning. | |
About error messages coming from the user's scriptblock | |
I need to imporve on that, in order to remove any mentions of this function (Start-RunspaceJob) | |
So that the end-user sees only the error from his scriptblock as-if it was run directly from the console | |
and not through this function. | |
TODO: Re-factor the Get-ErrorRecord function for this use-case | |
#> | |
[cmdletbinding()] | |
[OutputType([object])] # <-- whatever the output of the user's command returns | |
param ( | |
[Parameter(Mandatory)] | |
[scriptblock]$Scriptblock, | |
[object[]]$ArgumentList, # <-- you can pass either unnamed arguments in a specific order | |
[hashtable]$ParameterList, # <-- or you can pass named parameters in any order | |
[int]$TimeoutSec = 60, # <-- default execution timeout is 1 minute | |
[pscredential]$RunAs, # <-- user/pass to run the command as a different user | |
[switch]$ReturnOnEnd, # <-- do not reutrn the output as it gets generated, but instead return it all at the end in one go. | |
[switch]$DontRenewErrors # <-- do not re-hydrate the error stream, instead return errors as-is in StdOut | |
) | |
. ([scriptblock]::Create('using namespace System.Management.Automation')) # PSDataCollection,PowerShell,Runspaces.* | |
$State = [Runspaces.InitialSessionState]::CreateDefault() | |
# add the required context (functions & vars) to run as a different user | |
if ($RunAs) { | |
$MyFunc = Get-Item Function:\Invoke-WithImpersonation -ErrorAction Stop | |
$FunEntry = [Runspaces.SessionStateFunctionEntry]::new($MyFunc.Name,$MyFunc.Definition) | |
[void]$State.Commands.Add($FunEntry) | |
$VarEntry1 = [Runspaces.SessionStateVariableEntry]::new('_Creds',$RunAs,$null) | |
$VarEntry2 = [Runspaces.SessionStateVariableEntry]::new('_UserBlock',$Scriptblock,$null) | |
[void]$State.Variables.Add($VarEntry1) | |
[void]$State.Variables.Add($VarEntry2) | |
# we need to remove the user's parameters from the Bound Parameters, to use the remaining ones in the state | |
[void]$PSBoundParameters.Remove('Scriptblock') | |
[void]$PSBoundParameters.Remove('RunAs') | |
[void]$PSBoundParameters.Remove('TimeoutSec') | |
[void]$PSBoundParameters.Remove('ReturnOnEnd') | |
[void]$PSBoundParameters.Remove('DontRenewErrors') | |
# so now the function's $PSBoundParameters is either empty or | |
# it's a hashtable that has a single Key which is either ArgumentList or ParameterList | |
$VarEntry3 = [Runspaces.SessionStateVariableEntry]::new('_UserArgs',$PSBoundParameters,$null) | |
[void]$State.Variables.Add($VarEntry3) | |
} | |
# create a new powershell runspace | |
$Cmd = [PowerShell]::Create($State) | |
# add the scriptblock & any arguments | |
if ($RunAs) { | |
[void]$Cmd.AddScript('Invoke-WithImpersonation -ScriptBlock $_UserBlock -Credential $_Creds @_UserArgs') | |
} | |
else { | |
[void]$Cmd.AddScript($Scriptblock.ToString()) | |
# add user's parameters (can add args/params only after you add a script first) | |
if ($ArgumentList.Count -gt 0) { | |
$ArgumentList | foreach {[void]$Cmd.AddArgument($_)} | |
} | |
elseif ($ParameterList.Keys.Count -gt 0) { | |
$ParameterList.GetEnumerator() | foreach {[void]$Cmd.AddParameter($_.Key,$_.Value)} | |
} | |
} | |
# get all streams as part of the normal output, not separately | |
$Cmd.Commands.Commands.MergeMyResults('All','Output') | |
# prepare the setup to re-hydrate the objects to their regular streams | |
$SMA = 'System.Management.Automation' | |
$Hash = @{ | |
"$SMA.VerboseRecord" = {Write-Verbose -Message $_.Message -Verbose} | |
"$SMA.WarningRecord" = {Write-Warning -Message $_.Message} | |
"$SMA.InformationRecord" = {Write-Host -Object $_.MessageData} | |
"$SMA.ErrorRecord" = { | |
if (-not $DontRenewErrors) {$PSCmdlet.WriteError($_)} | |
else {Write-Output $_} | |
} | |
} | |
# start the command | |
$InOut = [PSDataCollection[object]]::new() | |
$Async = $Cmd.BeginInvoke($InOut,$InOut) | |
# return the output from the command as it gets generated on the fly | |
# while keeping a timer to make sure we don't exceed the timeout | |
$Timer = [System.Diagnostics.Stopwatch]::StartNew() | |
$IsDone = $false ; $HasExpired = $false | |
while (-not $IsDone -and -not $HasExpired) { | |
$IsDone = $Async.IsCompleted | |
$HasExpired = $Timer.Elapsed.TotalSeconds -gt $TimeoutSec | |
# this is the output of this function | |
if (-not $ReturnOnEnd) { | |
$InOut.ReadAll() | foreach { | |
if ($_.pstypenames[0] -like "$SMA.*Record") {. $Hash[$_.pstypenames[0]]} # <-- write verbose/warning/info/error | |
else {Write-Output $_} | |
} | |
} | |
# if it's done then dont wait, if it's expired then again dont wait | |
if (-not $IsDone -or -not $HasExpired) { | |
Start-Sleep -Milliseconds 200 # <-- refresh 5 times per second | |
} | |
} | |
$Timer.Stop() | |
# stop the command if the timeout has expired | |
if (-not $Async.IsCompleted) { | |
Write-Warning "Execution timeout has expired ($TimeoutSec sec) but the script is still running" 3>&1 | |
Write-Warning 'Will only collect output till this point, if any.' 3>&1 | |
$Cmd.Stop() | |
} | |
try {[void]$Cmd.EndInvoke($Async)} # <-- this method blocks, so it will wait as long as needed for the script to finish | |
catch {$_.Exception.InnerException.ErrorRecord} | |
if ($ReturnOnEnd) { | |
Write-Output $InOut | foreach { | |
if ($_.pstypenames[0] -like "$SMA.*Record") {. $Hash[$_.pstypenames[0]]} # <-- write verbose/warning/info/error | |
else {Write-Output $_} | |
} | |
} | |
# clean up | |
$Cmd.Dispose() | |
$InOut.Dispose() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment