powershelladminelevation

How can I elevate Powershell while keeping the current working directory AND maintain all parameters passed to the script?


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."
    }
}

If I run the script above, it successfully spawns another PowerShell instance with elevated privileges but the current working directory is lost and automatically set to C:\Windows\System32. Bound Parameters are also lost or incorrectly parsed.

After reading similar questions I learned that when using Start-Process with -Verb RunAs, the -WorkingDirectory argument is only honored if the target executable is a .NET executable. For some reason PowerShell 5 doesn't honor it:

The problem exists at the level of the .NET API that PowerShell uses behind the scenes (see System.Diagnostics.ProcessStartInfo), as of this writing (.NET 6.0.0-preview.4.21253.7).

Quote from this related question:

In practice - and the docs do not mention that - the -WorkingDirectory parameter is not respected if you start a process elevated (with administrative privileges, which is what -Verb RunAs - somewhat obscurely - does): the location defaults to $env:SYSTEMROOT\system32 (typically, C:\Windows\System32).

So the most common solution I've seen involves using -Command instead of -File. I.E:

Start-Process -FilePath powershell.exe -Verb Runas -ArgumentList '-Command', 'cd C:\ws; & .\script.ps1'

This looks really hack-ish but works. The only problem is I can't manage to get an implementation that can pass both bound and unbound parameters to the script being called via -Command.

I am trying my hardest to find the most robust implementation of self-elevation possible so that I can nicely wrap it into a function (and eventually into a module I'm working on) such as Request-AdminRights which can then be cleanly called immediately in new scripts that require admin privileges and/or escalation. Pasting the same auto-elevation code at the beginning of every script that needs admin rights feels really sloppy.

I'm also concerned I might be overthinking things, and to just leave elevation to the script level instead of wrapping it into a function.

Any input at all is greatly appreciated.


Solution

  • Note: On 15 Nov 2021 a bug was fixed in the code below in order to make it work properly with advanced scripts - see this answer for details.

    The closest you can get to a robust, cross-platform self-elevating script solution that supports:

    is the following monstrosity (I certainly wish this were easier):

    # 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 0 # Indicate success via the exit code.
    

    Sample call:

    If you save the above code to file script.ps1 and invoke it as follows:

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

    you'll see the following:


    Simpler, but limited, Windows-only alternative:

    The following may be sufficient in many situations on Windows, but has the following limitations:

    The code between the lines that start with # --- BEGIN: and # --- END: is reusable as-is. That is, you can drop it into any script - right after the parameter declarations, if any - to make it self-elevating. Due to use of -NoExit, the code keeps the elevated window open after the reinvoked script exits.

    # 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()) { 
        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 0 # Indicate success via the exit code.