Capturing output/error in PowerShell of process running as other user
Asked Answered
S

1

2

This is a variation of a question that has been asked and answered before.

The variation lies in using UserName and Password to set up the System.Diagnostics.ProcessStartInfo object. In this case, we're not able to read the output and error streams of the process – which makes sense because the process does not belong to us!

But even so, we've spawned the process so it should be possible to capture the output.

I suspect that this is a duplicate but it seems to have been misunderstood in the answer section.

Sauceda answered 19/3, 2022 at 9:48 Comment(1)
I would look at launching PowerShell with the credentials required and save the output to file from the process or deal with the data/streams in the session with credentials and return as an object to the non-elevated session using -OutputFormat. Here's another answer that has been helpful in the past RE: Start-ProcessDoctrine
S
4

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.

Sollie answered 19/3, 2022 at 22:26 Comment(9)
I am unable to request elevation since neither user are administrator. The setup is that I'm providing a WinRM entrypoint via JEA to run a specialized program which can only run under a common user account (not for example, a gMSA or a virtual account). That's why I'm supplying "-Credential" to change the user context. But I still want to capture the command output.Sauceda
I see, @Sauceda - please see my update. The reason I thought you needed elevation too is because the last question linked to from your question seemingly uses Verb = "runas", but it is actually quietly ignored, due to UseShellExecute = false - I've left a comment there, to spare future readers the same confusion.Sollie
I have marked this as an answer because it helped me figure out another complication – which is that apparently, I can't use Start-Process or System.Diagnostics.Process under a non-interactive session (which I have because this is via WinRM). There are some other ways to start a process in that situation.Sauceda
I see, @Sauceda - I hadn't paid attention to the remoting angle; yeah, it seems like you're not allowed to impersonate another user in a remote session - I've updated the answer accordingly. I encourage you to ask a new question focused on the remoting angle and, given that it sounds like you may have a solution already, to self-answer it.Sollie
Thanks, @Doctrine - I wasn't sure if the root cause is the same - it may well be; my uncertainty comes from double-hop being about delegation, whereas the issue at hand is impersonation. However, I've added your link to the answer.Sollie
I just thought it may be worth trying CredSSP. I think the issue starting a process in a non-CredSSP remote session is it needs to go off and authenticate. I believe I have done something similar in the past for installing SQL Server on Windows Core Servers via remote sessions. I'll post some details if I find it.Doctrine
@Sollie I don't have a solution quite yet, but I read stackoverflow.com/a/30985036 which suggested that the CreateProcessAsUser Win32 API might be the way to go about it.Sauceda
@Sollie cool and thanks for a very extensive answer; I can add that I took a fresh look at the problem and realized that 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.Sauceda
Glad to hear it, @malthe. I've added your comment to the answer.Sollie

© 2022 - 2024 — McMap. All rights reserved.