以其他用户身份在进程 运行 的 PowerShell 中捕获 output/error

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

这是 asked and answered before 问题的变体。

不同之处在于使用UserNamePassword来设置System.Diagnostics.ProcessStartInfo对象。在这种情况下,我们无法读取进程的输出和错误流——这是有道理的,因为进程不属于我们!

但即便如此,我们已经生成了进程,因此应该可以捕获输出。

我怀疑但是好像在回答部分被误解了

可以从您启动的(总是-提升的)进程中捕获输出流不同的用户身份,如以下self-contained示例代码所示:

注:

  • 如果您通过 PowerShell remoting, such as via Invoke-Command -ComputerName, including JEA.

    执行命令, 是否有效
    • 请参阅底部部分 - 繁琐的解决方法

    • 但是,JEA不需要解决方法,正如您在评论中报告的那样:

      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.

    • 无论您是否冒充其他用户,您还可以 运行 进入臭名昭著的 double-hop problem when accessing network resources in the remote session, as Ash 笔记。

  • 代码提示输入目标用户的凭据。

  • 工作目录必须设置为允许目标用户访问的目录路径,默认为下面的本地配置文件文件夹 - 假设它存在;根据需要调整。

  • Stdout 和 stderr 输出被单独完整捕获为multi-line字符串。

    • 如果你想合并这两个流,通过shell调用你的目标程序并使用其重定向功能 (2>&1).

    • 下面的示例调用执行对 shell 的调用,即通过其 /c 参数调用 cmd.exe,将一行输出到 stdout,另一行输出到 stderr (>&2)。如果您按如下方式修改 Arguments = ... 行,stderr 流将合并到 stdout 流中:

      Arguments = '/c "(echo success & echo failure >&2) 2>&1"'
      
  • 该代码在 Windows PowerShell 和 PowerShell (Core) 7+ 中均有效,并通过 异步读取流 来防止潜在的死锁。 [1]

    • 中install-on-demand,cross-platformPowerShell (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.

    • 在旧版中,ships-with-WindowsWindowsPowerShell版本,定期轮询,穿插Start-Sleep调用,必须用于查看异步任务是否已完成。

    • 如果您只需要捕获 一个 流,例如,仅 stdout,如果您已将 stderr 合并到其中(如上所述),则实现可以简化为synchronous$stdout = $ps.StandardOutput.ReadToEnd()调用,如图.

# 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

输出:

ExitCode : 0
StdOut   : success
StdErr   : failure

如果需要运行宁作为给定用户WITH ELEVATION(作为管理员):

  • 根据设计,您不能同时请求提升 -Verb RunAs 运行 在 单个操作中具有不同的用户身份 (-Credential) - Start-Process nor with the underlying .NET API, System.Diagnostics.Process.

    都没有
    • 如果您请求提升并且您是管理员您自己,提升过程将运行 与您一样 - 假设您已确认显示的 UAC 对话框的是/否形式。
    • 否则,UAC 将显示一个 credentials 对话框,要求您提供管理员的凭据 - 并且无法 preset 这些凭据,甚至连用户名都没有。
  • 根据设计,您无法直接从已启动的提升进程中捕获输出 - 即使使用您自己的身份提升进程 运行。

    • 但是,如果您启动 -提升的进程作为不同的用户,您可以 捕获输出,如顶部所示。

要获得您正在寻找的内容,需要以下方法

  • 您需要两次 Start-Process次调用:

    • 第一个启动-必然-提升进程作为目标用户-Credential)

    • 第二个 从该进程 启动以请求提升,然后在目标用户的上下文中提升,假设他们是管理员。

  • 因为您只能从提升的进程本身内部捕获输出,您需要通过 启动目标程序 =375=] 并使用其重定向 (>) 功能来捕获文件 中的输出 .

不幸的是,这是一个非常重要的解决方案,需要考虑许多微妙之处。

这是一个 self-contained 示例:

  • 它执行命令 whoaminet session(仅在 elevated 会话中成功)并捕获它们的组合标准输出和指定工作目录中文件 out.txt 中的 stderr 输出。

  • 同步执行,即它等待 elev在继续之前退出目标进程;如果这不是必需的,请从嵌套的 Start-Process 调用中删除 -PassThru 和封闭的 (...).WaitForExit(),以及 -Wait

    • 注意:-Wait 不能在 outer Start-Process 调用中使用的原因是 bug,从 PowerShell 7.2.2 开始仍然存在。 - 见 GitHub issue #17033.
  • 按照 source-code 评论中的说明:

    • 当系统提示您输入目标用户的凭据时,请务必指定管理员的凭据,以确保使用该用户身份的提升成功。

    • $workingDir 中,指定目标用户 允许访问 的工作目录,即使来自 -提升会话。默认情况下使用目标用户的本地配置文件 - 假设它存在。

# 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()

在 PowerShell 远程处理 (WinRM) 的上下文中以另一个用户身份启动进程

您自己发现了 ,这解释了 CreateProcessWithLogon() Windows API 函数 - .NET(以及 PowerShell)在启动作为另一个用户处理 - 在 batch-logon 场景中不起作用,如 WinRM 等服务所使用的场景。相反,需要调用 CreateProcessAsUser(),它可以传递预先使用 LogonUser().

显式创建的 batch-logon 用户令牌

以下 self-contained 示例 建立在 this C#-based answer 的基础上,但有重要的 先决条件和限制

  • 调用 用户帐户必须被授予 SE_ASSIGNPRIMARYTOKEN_NAME 又名 "SeAssignPrimaryTokenPrivilege" 又名“替换进程级令牌”权限。

    • 交互方式,可以使用secpol.msc修改用户权限(Local Policy > User Rights Assignment);修改后需要注销/重启。
    • 如果调用者缺少此权限,您将收到一条错误消息 A required privilege is not held by the client.
  • 目标 用户帐户必须是 Administrators 组的成员。

    • 如果目标用户不在该组中,您将收到一条错误消息 The handle is invalid.
  • 没有尝试捕获目标进程的输出在内存中;相反,进程调用 cmd.exe 并使用其重定向运算符 (>) 将输出 发送到文件 .

# 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://docs.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] 进程的重定向标准流的输出被缓冲,当缓冲区填满时进程被阻止写入更多数据,在这种情况下它必须等待 reader 的流消耗缓冲区。因此,如果您尝试同步读取到 one 流的末尾,如果同时 other 流的缓冲区已满,您可能会卡住,因此阻止进程完成对第一个流的写入。