You can capture the output streams from an (invariably non-elevated) process you've launched with a different user identity, as the following self-contained example code shows:
Note:
Does not work if you execute the command via PowerShell remoting, such as via Invoke-Command -ComputerName
, including JEA.
See the bottom section for a - cumbersome - workaround.
However, this workaround is not needed with JEA, as you report in a comment:
JEA sessions actually support RunAsCredential
(in addition to virtual accounts and group-managed service accounts) such that we can simply run as the intended common user and thus obviate the need to change user context during the session.
Independently of whether you impersonate another user, you may also run into the infamous double-hop problem when accessing network resources in the remote session, as Ash notes.
The code prompts for the target user's credentials.
The working directory must be set to a directory path that the target user is permitted to access, and defaults to their local profile folder below - assuming it exists; adjust as needed.
Stdout and stderr output are captured separately, in full, as multi-line strings.
If you want to merge the two streams, call your target program via a shell and use its redirection features (2>&1
).
The example call below performs a call to a shell, namely cmd.exe
via its /c
parameter, outputting one line to stdout, the other to stderr (>&2
). If you modify the Arguments = ...
line as follows, the stderr stream would be merged into the stdout stream:
Arguments = '/c "(echo success & echo failure >&2) 2>&1"'
The code works in both Windows PowerShell and PowerShell (Core) 7+, and guards against potential deadlocks by reading the streams asynchronously.[1]
In the install-on-demand, cross-platform PowerShell (Core) 7+ edition, the implementation is more efficient, as it uses dedicated threads to wait for the asynchronous tasks to complete, via ForEach-Object
-Parallel
.
In the legacy, ships-with-Windows Windows PowerShell edition, periodic polling, interspersed with Start-Sleep
calls, must be used to see if the asynchronous tasks have completed.
If you only need to capture one stream, e.g., only stdout, if you've merged stderr into it (as described above), the implementation can be simplified to a synchronous $stdout = $ps.StandardOutput.ReadToEnd()
call, as shown in this answer.
# Prompt for the target user's credentials.
$cred = Get-Credential
# The working directory for the new process.
# IMPORTANT: This must be a directory that the target user is permitted to access.
# Here, the target user's local profile folder is used.
# Adjust as needed.
$workingDir = Join-Path (Split-Path -Parent $env:USERPROFILE) $cred.UserName
# Start the process.
$ps = [System.Diagnostics.Process]::Start(
[System.Diagnostics.ProcessStartInfo] @{
FileName = 'cmd'
Arguments = '/c "echo success & echo failure >&2"'
UseShellExecute = $false
WorkingDirectory = $workingDir
UserName = $cred.UserName
Password = $cred.Password
RedirectStandardOutput = $true
RedirectStandardError = $true
}
)
# Read the output streams asynchronously, to avoid a deadlock.
$tasks = $ps.StandardOutput.ReadToEndAsync(), $ps.StandardError.ReadToEndAsync()
if ($PSVersionTable.PSVersion.Major -ge 7) {
# PowerShell (Core) 7+: Wait for task completion in background threads.
$tasks | ForEach-Object -Parallel { $_.Wait() }
} else {
# Windows PowerShell: Poll periodically to see when both tasks have completed.
while ($tasks.IsComplete -contains $false) {
Start-Sleep -MilliSeconds 100
}
}
# Wait for the process to exit.
$ps.WaitForExit()
# Sample output: exit code and captured stream contents.
[pscustomobject] @{
ExitCode = $ps.ExitCode
StdOut = $tasks[0].Result.Trim()
StdErr = $tasks[1].Result.Trim()
} | Format-List
Output:
ExitCode : 0
StdOut : success
StdErr : failure
If running as a given user WITH ELEVATION (as admin) is required:
By design, you cannot both request elevation with -Verb RunAs
and run with a different user identity (-Credential
) in a single operation - neither with Start-Process
nor with the underlying .NET API, System.Diagnostics.Process
.
- If you request elevation and you're an administrator yourself, the elevated process will run as you - assuming you've confirmed the Yes / No form of the UAC dialog presented.
- Otherwise, UAC will present a credentials dialog, requiring you to provide an administrator's credentials - and there's no way to preset these credentials, not even the username.
By design, you cannot directly capture output from an elevated process you've launched - even if the elevated process runs with your own identity.
- However, if you launch a non-elevated process as a different user, you can capture the output, as shown in the top section.
To get what you're looking for requires the following approach:
You need two Start-Process
calls:
The first one to launch an - of necessity - non-elevated process as the target user (-Credential
)
A second one launched from that process to request elevation, which then elevates in the context of the target user, assuming they're an administrator.
Because you can only capture output from inside the elevated process itself, you'll need to launch your target program via a shell and use its redirection (>
) features to capture output in files.
Unfortunately, this makes for a nontrivial solution, with many subtleties to consider.
Here's a self-contained example:
It executes the commands whoami
and net session
(which only succeeds in an elevated session) and captures their combined stdout and stderr output in file out.txt
in the specified working directory.
It executes synchronously, i.e. it waits for the elevated target process to exit before continuing; if that isn't a requirement Remove -PassThru
and the enclosing (...).WaitForExit()
, as well as -Wait
from the nested Start-Process
call.
- Note: The reason that
-Wait
cannot also be used in the outer Start-Process
call is a bug, still present as of PowerShell 7.2.2. - see GitHub issue #17033.
As instructed in the source-code comments:
when you're prompted for the target user's credentials, be sure to specify an administrator's credentials, to ensure that elevation with that user's identity succeeds.
in $workingDir
, specify a working directory that the target user is permitted to access, even from a non-elevated session. The target user's local profile is used by default - assuming it exists.
# Prompt for the target user's credentials.
# IMPORTANT: Must be an *administrator*
$cred = Get-Credential
# The working directory for both the intermediate non-elevated
# and the ultimate elevated process.
# IMPORTANT: This must be a directory that the target user is permitted to access,
# even when non-elevated.
# Here, the target user's local profile folder is used.
# Adjust as needed.
$workingDir = Join-Path (Split-Path $env:USERPROFILE) $cred.UserName
(Start-Process -PassThru -WorkingDirectory $workingDir -Credential $cred -WindowStyle Hidden powershell.exe @'
-noprofile -command Start-Process -Wait -Verb RunAs powershell \"
-noexit -command `"Set-Location -LiteralPath \`\"$($PWD.ProviderPath)\`\"; & { whoami; net session } 2>&1 > out.txt`"
\"
'@).WaitForExit()
Launching a process as another user in the context of PowerShell remoting (WinRM)
You yourself discovered this answer, which explains that the CreateProcessWithLogon()
Windows API function - which .NET (and therefore PowerShell) uses under the hood when starting a process as another user - won't work in batch-logon scenarios, as used by services such as WinRM
. A call to CreateProcessAsUser()
is required instead, which can be passed a batch-logon user token explicitly created beforehand with LogonUser()
.
The following self-contained example builds on this C#-based answer, but there are important prerequisites and limitations:
The calling user account must be granted the SE_ASSIGNPRIMARYTOKEN_NAME
aka "SeAssignPrimaryTokenPrivilege"
aka "Replace a process level token" privilege.
- Interactively, you can use
secpol.msc
to modify user privileges (Local Policy > User Rights Assignment
); after modification, a logoff / reboot is required.
- If the caller is missing this privilege, you'll get an error stating
A required privilege is not held by the client.
The target user account must be a member of the Administrators
group.
- If the target user isn't in that group, you'll get an error stating
The handle is invalid.
No attempt is made to the capture the target process' output in memory; instead, the process calls cmd.exe
and uses its redirection operator (>
) to send the output to a file.
# Abort on all errors.
$ErrorActionPreference = 'Stop'
Write-Verbose -Verbose 'Compiling C# helper code...'
Add-Type @'
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security;
public class ProcessHelper
{
static ProcessHelper()
{
UserToken = IntPtr.Zero;
}
private static IntPtr UserToken { get; set; }
// Launch and return right away, with the process ID.
// CAVEAT: While you CAN get a process object with Get-Process -Id <pidReturned>, and
// waiting for the process to exit with .WaitForExit() does work,
// you WON'T BE ABLE TO QUERY THE EXIT CODE:
// In PowerShell, .ExitCode returns $null, suggesting that an exception occurs,
// which PowerShell swallows.
// https://learn.microsoft.com/en-US/dotnet/api/System.Diagnostics.Process.ExitCode lists only two
// exception-triggering conditions, *neither of which apply here*: the process not having exited yet, the process
// object referring to a process on a remote computer.
// Presumably, the issue is related to missing the PROCESS_QUERY_LIMITED_INFORMATION access right on the process
// handle due to the process belonging to a different user.
public int StartProcess(ProcessStartInfo processStartInfo)
{
LogInOtherUser(processStartInfo);
Native.STARTUPINFO startUpInfo = new Native.STARTUPINFO();
startUpInfo.cb = Marshal.SizeOf(startUpInfo);
startUpInfo.lpDesktop = string.Empty;
Native.PROCESS_INFORMATION processInfo = new Native.PROCESS_INFORMATION();
bool processStarted = Native.CreateProcessAsUser(UserToken, processStartInfo.FileName, processStartInfo.Arguments,
IntPtr.Zero, IntPtr.Zero, true, 0, IntPtr.Zero, null,
ref startUpInfo, out processInfo);
if (!processStarted)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
UInt32 processId = processInfo.dwProcessId;
Native.CloseHandle(processInfo.hProcess);
Native.CloseHandle(processInfo.hThread);
return (int) processId;
}
// Launch, wait for termination, return the process exit code.
public int RunProcess(ProcessStartInfo processStartInfo)
{
LogInOtherUser(processStartInfo);
Native.STARTUPINFO startUpInfo = new Native.STARTUPINFO();
startUpInfo.cb = Marshal.SizeOf(startUpInfo);
startUpInfo.lpDesktop = string.Empty;
Native.PROCESS_INFORMATION processInfo = new Native.PROCESS_INFORMATION();
bool processStarted = Native.CreateProcessAsUser(UserToken, processStartInfo.FileName, processStartInfo.Arguments,
IntPtr.Zero, IntPtr.Zero, true, 0, IntPtr.Zero, null,
ref startUpInfo, out processInfo);
if (!processStarted)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
UInt32 processId = processInfo.dwProcessId;
Native.CloseHandle(processInfo.hThread);
// Wait for termination.
if (Native.WAIT_OBJECT_0 != Native.WaitForSingleObject(processInfo.hProcess, Native.INFINITE)) {
throw new Win32Exception(Marshal.GetLastWin32Error());
}
// Get the exit code
UInt32 dwExitCode;
if (! Native.GetExitCodeProcess(processInfo.hProcess, out dwExitCode)) {
throw new Win32Exception(Marshal.GetLastWin32Error());
}
Native.CloseHandle(processInfo.hProcess);
return (int) dwExitCode;
}
// Log in as the target user and save the logon token in an instance variable.
private static void LogInOtherUser(ProcessStartInfo processStartInfo)
{
if (UserToken == IntPtr.Zero)
{
IntPtr tempUserToken = IntPtr.Zero;
string password = SecureStringToString(processStartInfo.Password);
bool loginResult = Native.LogonUser(processStartInfo.UserName, processStartInfo.Domain, password,
Native.LOGON32_LOGON_BATCH, Native.LOGON32_PROVIDER_DEFAULT,
ref tempUserToken);
if (loginResult)
{
UserToken = tempUserToken;
}
else
{
Native.CloseHandle(tempUserToken);
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
}
private static String SecureStringToString(SecureString value)
{
IntPtr stringPointer = Marshal.SecureStringToBSTR(value);
try
{
return Marshal.PtrToStringBSTR(stringPointer);
}
finally
{
Marshal.FreeBSTR(stringPointer);
}
}
public static void ReleaseUserToken()
{
Native.CloseHandle(UserToken);
}
}
internal class Native
{
internal const Int32 LOGON32_LOGON_BATCH = 4;
internal const Int32 LOGON32_PROVIDER_DEFAULT = 0;
internal const UInt32 INFINITE = 4294967295;
internal const UInt32 WAIT_OBJECT_0 = 0x00000000;
internal const UInt32 WAIT_TIMEOUT = 0x00000102;
[StructLayout(LayoutKind.Sequential)]
internal struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public UInt32 dwProcessId;
public UInt32 dwThreadId;
}
[StructLayout(LayoutKind.Sequential)]
internal struct STARTUPINFO
{
public int cb;
[MarshalAs(UnmanagedType.LPStr)]
public string lpReserved;
[MarshalAs(UnmanagedType.LPStr)]
public string lpDesktop;
[MarshalAs(UnmanagedType.LPStr)]
public string lpTitle;
public UInt32 dwX;
public UInt32 dwY;
public UInt32 dwXSize;
public UInt32 dwYSize;
public UInt32 dwXCountChars;
public UInt32 dwYCountChars;
public UInt32 dwFillAttribute;
public UInt32 dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public UInt32 nLength;
public IntPtr lpSecurityDescriptor;
public bool bInheritHandle;
}
[DllImport("advapi32.dll", SetLastError = true)]
internal extern static bool LogonUser(String lpszUsername, String lpszDomain, String lpszPassword, int dwLogonType, int dwLogonProvider, ref IntPtr phToken);
[DllImport("advapi32.dll", SetLastError = true)]
internal extern static bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName,
string lpCommandLine, IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes, bool bInheritHandle, uint dwCreationFlags, IntPtr lpEnvironment,
string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
internal extern static bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", SetLastError = true)]
internal extern static UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
internal extern static bool GetExitCodeProcess(IntPtr hProcess, out UInt32 lpExitCode);
}
'@
# Determine the path for the file in which process output will be captured.
$tmpFileOutput = 'C:\Users\Public\tmp.txt'
if (Test-Path -LiteralPath $tmpFileOutput) { Remove-Item -Force $tmpFileOutput }
$cred = Get-Credential -Message "Please specify the credentials for the user to run as:"
Write-Verbose -Verbose "Running process as user `"$($cred.UserName)`"..."
# !! If this fails with "The handle is invalid", there are two possible reasons:
# !! - The credentials are invalid.
# !! - The target user isn't in the Administrators groups.
$exitCode = [ProcessHelper]::new().RunProcess(
[System.Diagnostics.ProcessStartInfo] @{
# !! CAVEAT: *Full* path required.
FileName = 'C:\WINDOWS\system32\cmd.exe'
# !! CAVEAT: While whoami.exe correctly reflects the target user, the per-use *environment variables*
# !! %USERNAME% and %USERPROFILE% still reflect the *caller's* values.
Arguments = '/c "(echo Hi from & whoami & echo at %TIME%) > {0} 2>&1"' -f $tmpFileOutput
UserName = $cred.UserName
Password = $cred.Password
}
)
Write-Verbose -Verbose "Process exited with exit code $exitCode."
Write-Verbose -Verbose "Output from test file created by the process:"
Get-Content $tmpFileOutput
Remove-Item -Force -ErrorAction Ignore $tmpFileOutput # Clean up.
[1] The output from a process' redirected standard streams is buffered, and the process is blocked from writing more data when a buffer fills up, in which case it has to wait for the reader of the stream to consume the buffer. Thus, if you try to synchronously read to the end of one stream, you may get stuck if the other stream's buffer fills up in the meantime, and therefore blocks the process from finishing writing to the first stream.
-OutputFormat
. Here's another answer that has been helpful in the past RE: Start-Process – Doctrine