如何在保留当前工作目录并维护传递给脚本的所有参数的同时提升 Powershell?

fmo*_*on1 3 powershell admin elevation

function Test-IsAdministrator
{
    $Identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
    $Principal = New-Object System.Security.Principal.WindowsPrincipal($Identity)
    $Principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Test-IsUacEnabled
{
    (Get-ItemProperty HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System).EnableLua -ne 0
}

if (!(Test-IsAdministrator))
{
    if (Test-IsUacEnabled)
    {
        [string[]]$argList = @('-NoProfile', '-NoExit', '-File', $MyInvocation.MyCommand.Path)
        $argList += $MyInvocation.BoundParameters.GetEnumerator() | ForEach-Object {"-$($_.Key)", "$($_.Value)"}
        $argList += $MyInvocation.UnboundArguments
        Start-Process PowerShell.exe -Verb Runas -WorkingDirectory $pwd -ArgumentList $argList 
        return
    }
    else
    {
        throw "You must be an administrator to run this script."
    }
}
Run Code Online (Sandbox Code Playgroud)

如果我运行上面的脚本,它会成功生成另一个具有提升权限的 PowerShell 实例,但当前工作目录会丢失并自动设置为C:\Windows\System32. 绑定参数也会丢失或错误解析。

在阅读类似的问题后,我了解到,当将 Start-Process 与 -Verb RunAs 一起使用时,当目标可执行文件是 .NET 可执行文件时,才会采用 -WorkingDirectory 参数。由于某种原因,PowerShell 5 不支持它:

截至撰写本文时 (.NET 6.0.0-preview.4.21253.7),该问题存在于 PowerShell 在后台使用的 .NET API 级别(请参阅 System.Diagnostics.ProcessStartInfo)。

引用这个相关问题

在实践中 - 并且文档没有提到 - 如果您启动一个提升的进程(具有管理权限,这就是 -Verb RunAs - 有点模糊 - 所做的),则不会尊重 -WorkingDirectory 参数:位置默认为 $env:SYSTEMROOT \system32(通常为 C:\Windows\System32)。

因此,我见过的最常见的解决方案是使用 -Command 而不是 -File。IE:

Start-Process -FilePath powershell.exe -Verb Runas -ArgumentList '-Command', 'cd C:\ws; & .\script.ps1'
Run Code Online (Sandbox Code Playgroud)

这看起来确实很黑客,但很有效。唯一的问题是我无法设法获得可以将绑定和未绑定参数传递给通过 -Command 调用的脚本的实现。

我正在尽最大努力找到最强大的自提升实现,以便我可以很好地将其包装到一个函数中(最终将其包装到我正在开发的模块中),例如Request-AdminRights可以在新脚本中立即干净地调用它需要管理员权限和/或升级。在每个需要管理员权限的脚本的开头粘贴相同的自动提升代码感觉真的很草率。

我还担心我可能想得太多,并且只将提升保留在脚本级别而不是将其包装到函数中。

任何意见都将受到高度赞赏。

mkl*_*nt0 7

注意:2021 年 11 月 15 日,以下代码中修复了一个错误,以便使其能够与高级脚本正常工作 - 有关详细信息,请参阅此答案

您可以获得最接近的强大的平台自提升脚本解决方案,该解决方案支持

  • 位置参数(未命名)和命名参数
    • 同时在 PowerShell 序列化的限制内保持类型保真度(请参阅此答案
  • 保留调用者的工作目录。
  • 仅在类 Unix 平台上:同步、同一窗口执行,带有退出代码报告(通过标准sudo实用程序)。

是以下怪物(我当然希望这更容易):

  • 笔记:

    • 底部部分提供了一种更简单但有限的仅限 Windows 的替代方案

    • 为了(相对)简洁,我省略了您的Test-IsUacEnabled测试,并简化了当前会话是否已提升为的测试[bool] (net.exe session 2>$null)

    • 您可以将任何脚本之间# --- BEGIN: Helper function for self-elevation.# --- END: Helper function for self-elevation.其中的所有内容放在参数声明之后(如果有),以使其自提升。由于使用了-NoExit,代码在重新调用的脚本退出后保持提升的窗口打开。

      • 如果您发现自己在不同的脚本中反复需要自我提升,您可以将代码复制到您的$PROFILE文件中,或者 - 更适合更广泛的分发 - 将下面使用的动态(内存中)模块(通过New-Module)转换为常规持久化模块您的脚本可以(自动)加载的模块。通过Ensure-Elevated自动加载模块提供可用的函数,您在给定脚本中只需调用Ensure-Elevated,不带参数(或使用-Verbose以获得详细输出)。
# Sample script parameter declarations.
# Note: Since there is no [CmdletBinding()] attribute and no [Parameter()] attributes,
#       the script also accepts *unbound* arguments.
param(
  [object] $First,
  [int] $Second,
  [array] $Third
)

# --- BEGIN: Helper function for self-elevation; reusable as-is.
# Define a dynamic (in-memory) module that exports a single function, Ensure-Elevated.
# Note: 
#  * In real life you would put this function in a regular, persisted module.
#  * Technically, 'Ensure' is not an approved verb, but it seems like the best fit.
$null = New-Module -Name "SelfElevation_$PID" -ScriptBlock {  
  function Ensure-Elevated {

    [CmdletBinding()]
    param()

    $isWin = $env:OS -eq 'Windows_NT'

    # Simply return, if already elevated.
    if (($isWin -and (net.exe session 2>$null)) -or (-not $isWin -and 0 -eq (id -u))) { 
      Write-Verbose "(Now) running as $(("superuser", "admin")[$isWin])."
      return 
    }

    # Get the relevant variable values from the calling script's scope.
    $scriptPath = $PSCmdlet.GetVariableValue('PSCommandPath')
    $scriptBoundParameters = $PSCmdlet.GetVariableValue('PSBoundParameters')
    $scriptArgs = $PSCmdlet.GetVariableValue('args')

    Write-Verbose ("This script, `"$scriptPath`", requires " + ("superuser privileges, ", "admin privileges, ")[$isWin] + ("re-invoking with sudo...", "re-invoking in a new window with elevation...")[$isWin])

    # Note: 
    #   * On Windows, the script invariably runs in a *new window*, and by design we let it run asynchronously, in a stay-open session.
    #   * On Unix, sudo runs in the *same window, synchronously*, and we return to the calling shell when the script exits.
    #   * -inputFormat xml -outputFormat xml are NOT used:
    #      * The use of -encodedArguments *implies* CLIXML serialization of the arguments; -inputFormat xml presumably only relates to *stdin* input.
    #      * On Unix, the CLIXML output created by -ouputFormat xml is not recognized by the calling PowerShell instance and passed through as text.
    #   * On Windows, the elevated session's working dir. is set to the same as the caller's (happens by default on Unix, and also in PS Core on Windows - but not in *WinPS*)
    
    # Determine the full path of the PowerShell executable running this session.
    # Note: The (obsolescent) ISE doesn't support the same CLI parameters as powershell.exe, so we use the latter.
    $psExe = (Get-Process -Id $PID).Path -replace '_ise(?=\.exe$)'

    if (0 -ne ($scriptBoundParameters.Count + $scriptArgs.Count)) {
      # ARGUMENTS WERE PASSED, so the CLI must be called with -encodedCommand and -encodedArguments, for robustness.

      # !! To work around a bug in the deserialization of [switch] instances, replace them with Boolean values.
      foreach ($key in @($scriptBoundParameters.Keys)) {
        if (($val = $scriptBoundParameters[$key]) -is [switch]) { $null = $scriptBoundParameters.Remove($key); $null = $scriptBoundParameters.Add($key, $val.IsPresent) }
      }
      # Note: If the enclosing script is non-advanced, *both*
      #       $scriptBoundParameters and $scriptArgs may be present.
      #       !! Be sure to pass @() when $args is $null (advanced script), otherwise a scalar $null will be passed on reinvocation.
      #       Use the same serialization depth as the remoting infrastructure (1).
      $serializedArgs = [System.Management.Automation.PSSerializer]::Serialize(($scriptBoundParameters, (@(), $scriptArgs)[$null -ne $scriptArgs]), 1)

      # The command that receives the (deserialized) arguments.
      # Note: Since the new window running the elevated session must remain open, we do *not* append `exit $LASTEXITCODE`, unlike on Unix.
      $cmd = 'param($bound, $positional) Set-Location "{0}"; & "{1}" @bound @positional' -f (Get-Location -PSProvider FileSystem).ProviderPath, $scriptPath
      if ($isWin) {
        Start-Process -Verb RunAs $psExe ('-NoExit -encodedCommand {0} -encodedArguments {1}' -f [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd)), [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serializedArgs)))
      }
      else {
        sudo $psExe -encodedCommand ([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))) -encodedArguments ([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serializedArgs)))
      }

    }
    else {
      # NO ARGUMENTS were passed - simple reinvocation of the script with -c (-Command) is sufficient.
      # Note: While -f (-File) would normally be sufficient, it leaves $args undefined, which could cause the calling script to break.
      # Also, on WinPS we must set the working dir.

      if ($isWin) {
        Start-Process -Verb RunAs $psExe ('-NoExit -c Set-Location "{0}"; & "{1}"' -f (Get-Location -PSProvider FileSystem).ProviderPath, $scriptPath)
      }
      else {
        # Note: On Unix, the working directory is always automatically inherited.
        sudo $psExe -c "& `"$scriptPath`"; exit $LASTEXITCODE"
      }

    }

    # EXIT after reinvocation, passing the exit code through, if possible:
    # On Windows, since Start-Process was invoked asynchronously, all we can report is whether *it* failed on invocation.
    exit ($LASTEXITCODE, (1, 0)[$?])[$isWin]

  }

}
# --- END: Helper function for self-elevation; reusable as-is.

# Call the self-elevation helper function:
#  * If this session is already elevated, the call is a no-op and execution continues,
#    in the current console window.
#  * Otherwise, the function exits the script and re-invokes it with elevation,
#    passing all arguments through and preserving the working directory.
#  * On Windows:
#     * UAC will prompt for confirmation / adming credentials every time.
#     * Of technical necessity, the elevated session runs in a *new* console window,
#       asynchronously, and the window running the elevated session remains open.
#       Note: The new window is a regular *console window*, irrespective of the 
#             environment you're calling from (including Windows Terminal, VSCode,
#             or the (obsolescent) ISE).
#     * Due to running asynchronously in a new window, the calling session won't know 
#       the elevated script call's exit code.
#  * On Unix:
#     * The `sudo` utility used for elevation will prompt for a password,
#       and by default remembers it for 5 minutes for repeat invocations. 
#     * The elevated script runs in the *current* window, *synchronously*,
#       and $LASTEXITCODE reflects the elevated script's exit code.
#       That is, the elevated script runs to completion and only then
#       returns control to the non-elevated caller.
#       Note that $LASTEXITCODE is only meaningful if the elevated script
#       sets its intentionally, via `exit $n`.
# Omit -Verbose to suppress verbose output.
Ensure-Elevated -Verbose

# Getting here means running with elevation.

# For illustration:
# Print the arguments received, in diagnostic form, as well as 
# the current location.
Write-Verbose -Verbose '== Arguments received:'
[PSCustomObject] @{
  PSBoundParameters = $PSBoundParameters.GetEnumerator() | Select-Object Key, Value, @{ n='Type'; e={ $_.Value.GetType().Name } } | Out-String
  # Only applies to non-advanced scripts
  Args = $args | ForEach-Object { [pscustomobject] @{ Value = $_; Type = $_.GetType().Name } } | Out-String
  CurrentLocation = $PWD.ProviderPath
} | Format-List

exit  # Report an exit code.
Run Code Online (Sandbox Code Playgroud)

调用示例

如果将上述代码保存到文件script.ps1并按如下方式调用它:

./script.ps1 -First (get-date) -Third  ('foo', 'bar') -Second 42  @{ unbound=1 } 'last unbound'
Run Code Online (Sandbox Code Playgroud)

你会看到以下内容:

  • 在非提升会话中,触发 UAC/sudo密码提示(Windows 示例):

     Current location: C:\Users\jdoe\sample
     VERBOSE: This script, "C:\Users\jdoe\sample\script.ps1", requires admin privileges, re-invoking in a new window with elevation...
    
    Run Code Online (Sandbox Code Playgroud)
  • 在提升的会话中(在 Unix 上,该会话在同一窗口中短暂运行):

     VERBOSE: (Now) running as admin.
     VERBOSE: == Arguments received:
    
     PSBoundParameters : 
                         Key    Value                  Type
                        ---    -----                  ----
                         First  10/30/2021 12:30:08 PM DateTime
                         Third  {foo, bar}             Object[]
                         Second 42                     Int32
    
    
     Args              : 
                         Value        Type
                         -----        ----
                         {unbound}    Hashtable
                         last unbound String
    
     CurrentLocation   : C:\Users\jdoe\sample
    
    Run Code Online (Sandbox Code Playgroud)

更简单有限的仅限Windows的替代方案:

在 Windows 上的许多情况下,以下内容可能就足够了,但具有以下限制

  • 适用于Windows 。
  • 保留调用者的工作目录。
  • 仅支持 .NET 原始数据类型作为参数。
  • 不支持数组参数。

# --- BEGIN:以和开头的行之间的代码# --- END:可以按原样重用。也就是说,您可以将其放入任何脚本中 - 就在参数声明之后(如果有的话) - 以使其自提升。由于使用了-NoExit,代码在重新调用的脚本退出后保持提升的窗口打开。

# Sample script parameter declarations.
# Note: Since there is no [CmdletBinding()] attribute and no [Parameter()] attributes,
#       the script also accepts *unbound* arguments.
param(
  [object] $First,
  [int] $Second,
  [switch] $Third
)

# --- BEGIN: Helper code for self-elevation; reusable as-is.
if (-not (net session 2>$null)) { # Test if already elevated.
  # Encode the arguments given for a call vie the PowerShell CLI with -File
  $quotedsArgList = foreach ($p in $PSBoundParameters.GetEnumerator()) { 
    $p.Value.GetType() | wva
    if ($p.Value -is [switch]) {
      if (-not $p.Value) { '-{0}:$false' -f $p.Key }
      else { '-{0}' -f $p.Key }
      }
    else {
      '-{0}' -f $p.Key
      if ($p.Value -match ' ') { "`"$($p.Value)`"" } else { "$($p.Value)" } }
   }
  $quotedsArgList += foreach ($arg in $args) { if ($arg -match ' ') { "`"$arg`"" } else { "$arg" } }
  # Reinvoke with elevation, wait for process termination and pass the exit code through.
  $ps = Start-Process -Wait -PassThru -Verb RunAs (Get-Process -Id $PID).Path ('-NoExit -File "{0}" {1}' -f $PSCommandPath, ($quotedsArgList -join ' '))
  exit $ps.ExitCode
}
# --- END: Helper code for self-elevation; reusable as-is.


# Getting here means running with elevation.

# For illustration:
# Print the arguments received, in diagnostic form, as
# well as the current location.
Write-Verbose -Verbose '== Arguments received:'
[PSCustomObject] @{
  PSBoundParameters = $PSBoundParameters.GetEnumerator() | Select-Object Key, Value, @{ n='Type'; e={ $_.Value.GetType().Name } } | Out-String
  # Only applies to non-advanced scripts
  Args = $args | ForEach-Object { [pscustomobject] @{ Value = $_; Type = $_.GetType().Name } } | Out-String
  CurrentLocation = $PWD.ProviderPath
} | Format-List

exit  # Report an exit code.
Run Code Online (Sandbox Code Playgroud)