如何从提升命令继承批处理环境的批处理内部执行 Powershell 的 "start-process -Verb RunAs"?

How to execute Powershell's "start-process -Verb RunAs" from inside a Batch where the elevated command inherits the Batch's environment?

1。问题

我有一个复杂的批处理文件,其中某些部分需要 运行 具有 elevated/admin 权限(例如与 Windows 服务交互),我找到了一种 Powershell 方法来做到这一点:

powershell.exe -command "try {$proc = start-process -wait -Verb runas -filepath '%~nx0' -ArgumentList '<arguments>'; exit $proc.ExitCode} catch {write-host $Error; exit -10}"

但有一个巨大的警告!我的脚本 (%~nx0) 的提升实例从环境变量的新副本开始,我 set "var=content" 之前的所有内容都不可用。

2。到目前为止我尝试了什么

这个 Powershell 脚本也无济于事,因为 Verb = "RunAs" 需要 UseShellExecute = $true 而这又是互斥的 to/with StartInfo.EnvironmentVariables.Add()

$p = New-Object System.Diagnostics.Process
$p.StartInfo.FileName = "cmd.exe";
$p.StartInfo.Arguments = '/k set blasfg'
$p.StartInfo.UseShellExecute = $true;
$p.StartInfo.Verb = "RunAs";
$p.StartInfo.EnvironmentVariables.Add("blasfg", "C:\Temp")

$p.Start() | Out-Null
$p.WaitForExit()
exit $p.ExitCode

即使这样可行,我仍然需要传输 数十个变量...

3。没有吸引力的半解决方案

因为规避问题是没有妥善解决的。

  1. hstart 等辅助工具 - 因为我无法依赖外部工具。只有 CMD、Powershell 和 VBscript(但看起来 runas 加上 waiterrorlevel/ExitCode 处理是不可能的 with/in vbs)。
  2. 传递(只需要)变量作为参数 - 因为我需要几十个并且转义它们是一件丑陋的苦差事(无论是结果还是这样做)。
  3. 重新启动整个脚本 - 因为所有解析、检查处理和其他任务再次发生(又一次……)效率低下。我想将提升的部分保持在最低限度,并且一些操作以后可以 运行 作为普通用户(例如服务 start/stop)。
  4. 将环境写入文件并在提升的实例中重新读取它 - 因为这是一个丑陋的 hack,我希望那里有一个更干净的选择。并且将可能敏感的信息写入文件甚至比将其临时存储在环境变量中更糟糕。

这是一个使用以下方法的概念证明:

  • 使 powershell 调用调用 另一个 ,辅助。 powershell 实例作为提升的目标进程。

  • 这允许外部 powershell 实例“烘焙”Set-Item 语句,re-create 调用者的环境变量(外部实例继承,并且可以因此用 Get-ChilItem Env:) 枚举到传递给辅助的 -command 字符串中。实例,后跟原始批处理文件的 re-invocation。

警告:此解决方案盲目地重新创建 all 提升进程中调用者进程中定义的环境变量 - 考虑 pre-filtering ,可能通过名称模式,例如通过共享前缀;例如,要将变量 re-creation 限制为名称以 foo 开头的变量,请在下面的命令中将 Get-ChildItem Env: 替换为 Get-ChildItem Env:foo*

@echo off & setlocal

:: Test if elevated.
net session 1>NUL 2>NUL && goto :ELEVATED 

:: Set sample env. vars. to pass to the elevated re-invocation.
set foo1=bar
set "foo2=none      done"
set foo3=3" of snow
:: " dummy comment to fix syntax highlighting
:: Helper variable to facilitate re-invocation.
set "thisBatchFilePath=%~f0"

:: Re-invoke with elevation, synchronously, reporting the exit
:: code of the elevated run.
:: Two sample arguments, ... and "quoted argument" are passed on re-invocation.
powershell -noprofile -command ^
  trap { [Console]::Error.WriteLine($_); exit -667 } ^
  exit ( ^
    Start-Process -Wait -PassThru -Verb RunAs powershell ^
      "\" -noprofile -command `\" $(Get-ChildItem Env: | ForEach-Object { 'Set-Item \\"env:' + $_.Name + '\\" \\"' + $($_.Value -replace '\""', '`\\""') + '\\"; ' }) cmd /c '\`\"%thisBatchFilePath:'=''%\`\" ... \`\"quoted argument\`\" & exit'; exit `$LASTEXITCODE`\" \"" ^
  ).ExitCode 

echo -- Elevated re-invocation exited with %ERRORLEVEL%.

:: End of non-elevated part.
exit /b

:ELEVATED

echo Now running elevated...

echo -- Arguments received:
echo [%*]

echo -- Env. vars. whose names start with "foo":
set foo 

:: Determine the exit code to report.
set ec=5

echo -- Exiting with exit code %ec%...
:: Pause, so you can inspect the output before exiting.
pause
exit /b %ec%

注:

  • trap { [Console]::Error.WriteLine($_); exit -667 } 处理用户拒绝提升提示的情况,这会导致 statement-terminating 错误,即 Start-Process 周围的 trap statement catches (using a try / catch 语句call 也是一个选项,通常是更好的选择,但在这种情况下 trap 在语法上更容易)。

  • 指定 pass-through 参数(参数直接传递给(提升的)批处理文件的 re-invocation,在上面的 cmd /c '\`\"%thisBatchFilePath:'=''%\`\" 部分之后):

    • 如果参数包含',你必须加倍它们('')
    • 如果参数需要 double-quoting,您必须将它们括在 '\`\"...\`\"(原文如此)中,如上面的 \`\"quoted argument\`\" 所示。
  • 需要 cmd /c '<batch-file> & exit' re-invocation 技术来确保 稳健 exit-code 报告,不幸的是 - 请参阅 了解详情。

  • batch-file re-invocation 之后的显式 exit $LASTEXITCODE 语句需要使 PowerShell CLI 报告批处理文件报告的特定退出代码 - 没有那个, 任何非零 退出代码将映射到 1。有关 PowerShell 中退出代码的全面讨论,请参阅

滚烫的解决方案

源自 mklement0 的工作概念证明和 Jeroen Mostert 的 Base64 建议我已经用这种方法构建了一个解决方案:

  • 将批处理内部的数据通过管道传输到外部 Powershell。
  • 让它将管道数据转换为 Base64 字符串。
  • 传递到提升的 Powershell 的命令行
  • 然后将其转换回并将其通过管道传输到新批次的实例中。

它更灵活,因为您不受环境变量的限制(您基本上可以传递任何内容(基于文本))并且不需要编辑 Powershell 命令来选择通过管道传输的内容。但它有一些限制 mklement0 的实现不会受到影响:

  • 包含换行符的环境变量将无法正确传递并可能导致混乱(取决于 LF 之后的内容,请参见barz)。
  • 目前,通过管道传输的每一行(第一行除外)都会在其前面加上一个空格(到目前为止我还不知道如何解决这个问题)。这通常不是问题,可以解决(fooDoubleQouting 是一个反例)。
  • 提升的实例不再像往常一样对控制台输入做出反应(见注释)。

示例/测试批次:

@echo off & setlocal EnableDelayedExpansion

::# Test if elevated.
net session 1>NUL 2>NUL && goto :ELEVATED

(set LF=^
%=this line is empty=%
)
::# Set sample env. vars. to pass to the elevated instance.
set foo1=bar
set "foo2=none done"
set foo3=3" of snow
set "barz=  Line1!LF!  foo1=Surprise^! foo1 isn't '%foo1%' anymore. It was unintentionally overwritten."
set barts=1 " 3_consecutive_" "_before_EOL   
set "barl=' sdfs' ´´`` =43::523; \/{[]} 457wb457;; %%%^!2^!11^!^!"
::# ' dummy comment#1 to fix syntax highlighting.

::# Helper variable to facilitate re-invocation (in case %~f0 contains any single quotes).
set "_selfBat=%~f0"

::# DDE - so "!" don't get expanded anymore. Was only needed for "set barz=..."
setlocal DisableDelayedExpansion
::# print variables etc. to console before self invocation & elevation.
call :testPrint

::# Generate pipe input. Be aware of CMD's handicaps of whats allowed in a command block.
::# eg. "REM" is not allowed and neither is echoing an unescaped closing parenthesis: ")" -> "^^^)"
(
    echo[foo_Setting_one=extra-varval.
    set ^^"
    echo[bar_stuff=in between.^^^)^^^"
    set bar
    echo["fooDoubleQouting=testertest"
) | powershell.exe -nologo -noprofile -command ^
    trap { [Console]::Error.WriteLine($_); exit -667 } ^
    exit ( ^
        Start-Process -PassThru -Wait -WindowStyle Maximized -Verb RunAs 'powershell.exe' ^
            "\"-nol -nop -comm `\" $('Write-Output $([Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(\\"' + $([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($(foreach ($i in $input) {\"$i`n\"})))) + '\\")))') | cmd.exe '/D','/U','/T:4F','/S','/C',' \`\"%_selfBat:'=''%\`\" \`\"quoted argument\`\" nonQtdArg & exit'; exit `$LastExitCode `\" \"" ^
    ).exitCode

echo[
echo[ ---- Returned errorlevel is: %ERRORLEVEL%
pause
endlocal & endlocal & exit /b %ERRORLEVEL%


:testPrint
    echo[
    echo[ ---- WhiteSpaceTest: "%barts%"
    set foo
    set bar
    echo[
    set ^"
exit /B
::# " dummy comment#2 to fix syntax highlighting again.

:ELEVATED
setlocal DisableDelayedExpansion
    ::# Read and parse piped in data.
    ::# (with "delims" & "eol" truly defined as empty so every line is read as-is, even empty lines)
    for /F delims^=^ eol^= %%A in ('findstr.exe "^"') do (
        echo[ Parsing %%~A
        for /F "tokens=1,* delims=="eol^= %%B in ("%%~A") do (
            echo[   into "%%~B"
            echo[     equals "%%~C"
            ::# Convert the piped in data back into environment variables+values.
            set "%%~B=%%~C" 2>NUL
        )
        echo[
    )
    echo[-------- END PIPEREADING --------
    echo[-- Arguments received:
    echo[ [%*]

    call :testPrint

    set "ERR=42"
    echo[
    ::# to actually pause and/or wait for / react to user input(!) one needs to pipe in CON (console).
    <con set /P ERR=Enter arbitrary exitcode / errorlevel: 
endlocal & exit /B %ERR%

备注:

  • 查看mklement0的注释。
  • CMD /C '<batch-file_withEscaped'> & exit' re-invocation 技术 不是必需的 如果您始终在批处理文件中 exit /b X。然后 &\`\"%_selfBat%\`\" 而不是 CMD /C ... & exit 就足够了(使用单独分隔的参数:'arg1','arg2')。
  • '/D','/T:4F', - 忽略 CMD 的注册表自动运行命令并将 fore-/background 颜色设置为深红色上的白色。
  • echo[ 而不是 echo 更安全、更快捷(cmd 不需要搜索名为 echo.* 的实际可执行文件)。
  • <con 在提升的实例中需要用户交互的任何东西(例如 pauseset /P ...)。没有 <con 现在空的 (?) 在 标准输入 (pipe#0) 中仍然传送 nul(?) 给任何需要它的东西(我的假设) .我确定有一种方法可以从管道中拯救 stdin 并将其重新连接到 con(也许是 breakthrou from in here 的某种形式) .
  • barl 的反引号被破坏了。

逃离地狱

这是动态的中间和内部 命令行 以显示正在发生的事情并消除一些逃避魔法:

powershell.exe -nol -nop -comm "Write-Output $([Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(\"<<BASE64_BLOB>>\"))) | cmd.exe '/D','/U','/T:4F','/S','/C',' \"<<path\this.cmd_withEscaped'>>\" \"quoted argument\" nonQtdArg & exit'; exit $LastExitCode"

即使只有我的默认环境,命令行也大约有 5kB 大(!),这要归功于 <<BASE64_BLOB>>

cmd.exe /D /U /T:4F /S /C " "<<path\this.cmd_withNormal'>>" "quoted argument" nonQtdArg & exit"

所以,是的,就像已经说过的那样,由于安全隐患,环境并不是设计为从一个用户传递给另一个用户的。这并不意味着它不能完成,即使这不是你“应该”做的事情。虽然我确实认为你应该研究你真正想要实现的目标,但我绝对讨厌人们告诉你你实际上“应该”做什么而不回答实际问题的答案根本。所以我在这里给你两种选择。

“通过”环境

这里有几个选项

  1. 从提升的子进程中,使用 NtQueryInformationProcess and ReadProcessMemory APIs 从未提升的调用者父进程的内存中读取环境变量。

    然后用 WriteProcessMemory 覆盖目标进程(在您的情况下为当前进程)上的变量,或者像往常一样设置它们。您可以仅使用 Powershell 实现此目的,尽管您需要添加一些 C# 代码来调用所需的 API 函数。

    在我的 Enable-Privilege.ps1 示例中,您可以看到如何在 PowerShell 中实现 NtQueryInformationProcess 并操作父进程(我编写它的目的是为了修改 self/parent/any 进程中的特权令牌)。您实际上想要使用它的一部分,因为您需要启用 SeDebugPrivilege 才能操作其他进程的内存。

    这个选项可能是最“干净”和最强大的解决方案,没有任何立即明显的警告。有关详细信息,请参阅此代码项目文章:Read Environment Strings of Remote Process

  2. 在未提升的父进程中,遍历所有环境变量并将它们作为字符串写入单个环境变量。然后在生成提升的子进程时将该单个环境值作为参数传递,您可以在其中解析该字符串并将这些环境值写回。不过,您可能会 运行 与此处的选项 3 相同的注意事项。

  3. 将变量从父级传递给子级,就像这里已经提出的那样。这里的问题是批处理器真的很挑剔,解析和转义的规则非常糟糕,所以很可能你会 运行 使用这个选项遇到特殊字符和其他类似警告的问题。

  4. 使用 kernel-mode 驱动程序,用提升的进程覆盖未提升进程的安全令牌,完成后写回原始令牌。从表面上看,这似乎是一个完美的解决方案,因为您实际上可以留在 previously-unelevated 进程中并保留其环境而不更改上下文,只会替换安全上下文。与在 kernel-mode 中一样,您可以修改所有内容,安全令牌是内核内存中的简单内存结构,您可以更改。这种方法的问题在于它完全绕过了 windows 安全模型,因为它应该不可能更改现有进程的安全令牌。因为它应该是“不可能的”,所以它深入到未记录的领域和内核内部,如果您不知道自己在做什么,您很容易破坏东西,所以这绝对是“高级”选项(即使这个特殊的事情并不太复杂,它基本上只是写一些内存)。因为这是你不应该做的事情,所以它有可能破坏某些东西,因为 Windows 不希望进程突然具有不同的安全上下文。话虽如此,我过去使用这种方法没有任何问题。尽管安全设计发生任何变化,但将来可能会破坏它。您还需要启用 testsigning(又名禁用驱动程序签名强制),对您的驱动程序进行数字签名或使用其他方法绕过此要求(f.ex。通过 hypervisor 或漏洞),但这超出了本答案的范围。

最终版本

"因为规避问题是没有妥善解决办法的。"

在这种情况下,我会完全那样做。由于您的问题的性质如此,因此不存在简单的解决方案,因为它不受设计支持。很难提出具体的解决方案,因为缺乏关于您在这里实际尝试实现的目标的信息。

我将尝试从一般意义上介绍这一点。首先是考虑您实际上要在此处实现的目标。您需要执行哪些需要提升的操作?将有多种方法以受支持的方式实现任何目标。

示例:

  • 无论您需要 read/write/modify,您都可以更改 target 的安全设置(而不是 来源)。这意味着假设您需要访问特定的注册表项,serice、文件、文件夹等等,您可以简单地修改 target 的 ACL 以允许 source(即用户)执行任何操作你需要。例如,如果您需要修改单个服务,您可以只为该单个进程添加 start/stop/modify 权限。

  • 如果您需要的是特定于操作类型而不是特定目标,您可以将所需的 privileges 添加到“用户”组。或者创建一个具有所需权限的新组,然后将用户添加到该组。

  • 如果您想更精细地控制 can/can 不做的事情 and/or 操作是特定的,您可以编写一个简单的程序并 运行 它作为高级服务。然后您可以告诉该服务从未提升的批处理脚本执行所需的操作,因此不需要请求提升和生成新进程。您可以简单地从批处理中执行 my-service.exe do-the-thing,然后 my-service 将执行您需要的操作。

  • 您也可以始终在脚本开头请求提升权限,但很明显您不想以完全管理员权限执行此操作,您可以为此创建一个新用户您为其添加到具有所需权限的新组的目的。请注意,如果不求助于上述 kernel-mode“hacks”,您无法为用户 添加 新权限 on-the-fly,只能启用/禁用/删除现有的。你可以做的是根据你的需要预先添加它们,但这需要在流程开始之前发生。