Private/Invoke-IslProcess.ps1
|
function Invoke-IslProcess { <# .SYNOPSIS Runs an executable as the current user, as SYSTEM or as another account, capturing its result. .DESCRIPTION User: a direct child process with stdin closed and both output streams read through the OEM code page, as a console-less powershell.exe writes them. System: a one-shot scheduled task registered for NT AUTHORITY\SYSTEM (session 0, the same place the Intune agent runs scripts) whose action is cmd.exe redirecting the command's output to files in the work folder. Needs an elevated session. The task is removed afterwards; a timeout stops the task and kills anything still holding the work folder's id in its command line. Credential (with Context User): the same task registered for that account. When the account holds a logon session the task's logon type is Interactive and it runs inside that session, which is where the agent runs user-context scripts (the signed-in user's session, session 2, UserInteractive true: REM-PROBE-USER64). Without a session the task is registered with the password, "run whether user is logged on or not", and runs in session 0 with the account's profile loaded but no interactive session. -LogonType forces one or the other. Needs an elevated session either way, and the account needs write access to the work folder. #> [CmdletBinding()] [OutputType('IntuneScriptLab.ProcessResult')] param( [Parameter(Mandatory)] [string]$FilePath, [string]$Arguments = '', [Parameter(Mandatory)] [string]$WorkingDirectory, # Scratch folder for the task modes' output files; its leaf name doubles as the run id [Parameter(Mandatory)] [string]$WorkFolder, [ValidateSet('User', 'System')] [string]$Context = 'User', # Run as this account through a scheduled task (Context User only) [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $Credential, # How the credential's task logs on: Auto picks Interactive when the account has a session [ValidateSet('Auto', 'Interactive', 'Password')] [string]$LogonType = 'Auto', [ValidateRange(1, 86400)] [int]$TimeoutSeconds = 300, # Which cmd.exe hosts the task: 64-bit for explicit host paths, 32-bit to make bare # 'powershell.exe' resolve to SysWOW64 the way the agent's own x86 process does [ValidateSet('x64', 'x86')] [string]$LauncherBitness = 'x64' ) $oem = Get-IslOemEncoding $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $timedOut = $false $useTask = $Context -eq 'System' -or $null -ne $Credential $logon = 'Direct' $userName = if ($env:USERDOMAIN) { "$env:USERDOMAIN\$env:USERNAME" } else { [Environment]::UserName } if (-not $useTask) { $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $FilePath $startInfo.Arguments = $Arguments $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.StandardOutputEncoding = $oem $startInfo.StandardErrorEncoding = $oem $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo $null = $process.Start() $process.StandardInput.Close() $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { $timedOut = $true & taskkill.exe /PID $process.Id /T /F 2>&1 | Out-Null $process.WaitForExit() } $stopwatch.Stop() $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() $exitCode = $process.ExitCode $process.Dispose() } else { $runId = Split-Path -Path $WorkFolder -Leaf $outFile = Join-Path -Path $WorkFolder -ChildPath 'stdout.txt' $errFile = Join-Path -Path $WorkFolder -ChildPath 'stderr.txt' $exitFile = Join-Path -Path $WorkFolder -ChildPath 'exitcode.txt' $use32BitLauncher = $LauncherBitness -eq 'x86' -and [Environment]::Is64BitOperatingSystem $cmdFolder = if ($use32BitLauncher) { 'SysWOW64' } else { 'System32' } $cmd = Join-Path -Path (Join-Path -Path $env:WINDIR -ChildPath $cmdFolder) -ChildPath 'cmd.exe' # Outer quotes are stripped by cmd /C; !ERRORLEVEL! needs /V:ON to expand after the command runs $wrapper = ("/V:ON /C `"`"$FilePath`" $Arguments 1>`"$outFile`" 2>`"$errFile`" & echo !ERRORLEVEL! " + ">`"$exitFile`"`"") $taskName = "IntuneScriptLab-$runId" $action = New-ScheduledTaskAction -Execute $cmd -Argument $wrapper -WorkingDirectory $WorkingDirectory $taskSettingsSplat = @{ AllowStartIfOnBatteries = $true DontStopIfGoingOnBatteries = $true StartWhenAvailable = $true ExecutionTimeLimit = [TimeSpan]::FromSeconds($TimeoutSeconds + 60) } $registerTaskSplat = @{ TaskName = $taskName Action = $action Settings = New-ScheduledTaskSettingsSet @taskSettingsSplat Force = $true ErrorAction = 'Stop' } if ($Context -eq 'System') { $logon = 'ServiceAccount' $userName = 'NT AUTHORITY\SYSTEM' $principalSplat = @{ UserId = 'SYSTEM'; LogonType = 'ServiceAccount'; RunLevel = 'Highest' } $registerTaskSplat.Principal = New-ScheduledTaskPrincipal @principalSplat $refusal = ('Context System registers a scheduled task that runs as SYSTEM, which this session ' + 'cannot do (run elevated)') } else { $userName = $Credential.UserName $account = if ($userName -match '\\') { ($userName -split '\\')[-1] } elseif ($userName -match '@') { ($userName -split '@')[0] } else { $userName } $sessions = @(Get-IslLogonSession | Where-Object { $_.UserName -eq $account }) $logon = if ($LogonType -ne 'Auto') { $LogonType } elseif ($sessions.Count -gt 0) { 'Interactive' } else { 'Password' } Write-Verbose "Task for ${userName}: logon type $logon, $($sessions.Count) session(s) found" if ($logon -eq 'Interactive') { $principalSplat = @{ UserId = $userName; LogonType = 'Interactive'; RunLevel = 'Limited' } $registerTaskSplat.Principal = New-ScheduledTaskPrincipal @principalSplat } else { $registerTaskSplat.User = $userName $registerTaskSplat.Password = $Credential.GetNetworkCredential().Password $registerTaskSplat.RunLevel = 'Limited' } $refusal = ("Running as $userName registers a scheduled task for that account, which this " + 'session cannot do (run elevated; a stored-password logon also needs the right password)') } # Registering the task is the real test of rights: a role check misjudges accounts (build # agents, for one) that are not in Administrators yet can do this try { $null = Register-ScheduledTask @registerTaskSplat } catch { throw "${refusal}: $($_.Exception.Message)" } try { Start-ScheduledTask -TaskName $taskName $deadline = (Get-Date).AddSeconds($TimeoutSeconds) $neverStartedAfter = (Get-Date).AddSeconds(5) $launchFailure = $null while (-not (Test-Path -LiteralPath $exitFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 # A task the scheduler refuses to start (a logon type the account is not granted) # ends at once with a result code and never writes the exit file: read it rather # than waiting for the timeout. 267009 is "running", 267011 "has not run yet" $info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue if (-not $info) { continue } $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State if ($info.LastTaskResult -notin 0, 267009, 267011 -and $state -ne 'Running') { $launchFailure = [uint32]$info.LastTaskResult break } # The refusal does not always come with a code: on the lab device a stored-password # task for an account without the batch logon right sits Ready, "has not run yet", # with no error anywhere. Five seconds of that after Start-ScheduledTask is the same # failure $neverStarted = $info.LastTaskResult -eq 267011 -and $state -eq 'Ready' if ($neverStarted -and (Get-Date) -gt $neverStartedAfter) { $launchFailure = [uint32]267011 break } } if ($launchFailure) { $code = '0x{0:X8}' -f $launchFailure $never = if ($code -eq '0x00041303') { 'the scheduler never launched it: ' } else { '' } $hint = if ($code -eq '0x80070569' -or ($code -eq '0x00041303' -and $logon -eq 'Password')) { " ($($never)the account is not granted the ""Log on as a batch job"" right a " + 'stored-password task needs; grant it in the local security policy, or run while the ' + 'account holds a session)' } else { '' } throw "The scheduled task for $userName did not start: $code$hint" } if (-not (Test-Path -LiteralPath $exitFile)) { $timedOut = $true Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue # Anything the task started carries the run id in its command line $cimInstanceSplat = @{ ClassName = 'Win32_Process' Filter = "CommandLine LIKE '%$runId%'" ErrorAction = 'SilentlyContinue' } Get-CimInstance @cimInstanceSplat | Where-Object { $_.ProcessId -ne $PID } | ForEach-Object { & taskkill.exe /PID $_.ProcessId /T /F 2>&1 | Out-Null } } else { # cmd writes the exit file before its own redirections are flushed; give it a moment $settleUntil = (Get-Date).AddSeconds(5) do { Start-Sleep -Milliseconds 200 $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State } while ($state -eq 'Running' -and (Get-Date) -lt $settleUntil) } } finally { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue } $stopwatch.Stop() $stdout = if (Test-Path -LiteralPath $outFile) { $oem.GetString([System.IO.File]::ReadAllBytes($outFile)) } else { '' } $stderr = if (Test-Path -LiteralPath $errFile) { $oem.GetString([System.IO.File]::ReadAllBytes($errFile)) } else { '' } $exitText = if (Test-Path -LiteralPath $exitFile) { (Get-Content -LiteralPath $exitFile -Raw).Trim() } else { '' } $exitCode = if ($exitText -match '^-?\d+$') { [int]$exitText } else { $null } } [pscustomobject]@{ PSTypeName = 'IntuneScriptLab.ProcessResult' ExitCode = if ($timedOut) { $null } else { $exitCode } TimedOut = $timedOut StdOut = $stdout StdErr = $stderr Duration = $stopwatch.Elapsed Context = $Context UserName = $userName LogonType = $logon } } |