Calyo Delphi
Calyo Delphi

Reputation: 349

Powershell: Type error on self-elevation to admin when passing switch parameter value

I've been writing a powershell script that needs to self-elevate to admin. Self-elevation is the very last function I've added to the script after debugging the rest of it, and I get a type error when passing the parameters into the script. What seems to be happening is that during the self-elevation process, the boolean [System.Management.Automation.SwitchParameter] type of the -Debug parameter's value is getting typecast to [string], and I can't figure out a way to have it re-cast as type [bool]. I get a similar error if the script somehow captures a whitespace string for the -NewIpdb parameter, except it throws a validation error against the [System.IO.FileInfo] type, even when the parameter hasn't been explicitly invoked by the user. I don't know how to make a powershell script not positionally capture arguments into named parameters if they are not explicitly invoked.

I'm using a solution found here to build a string of the original user-invoked parameters to pass into a modified version of this self-elevation solution but this comment on that answer only ambiguously advises that I would have to be "clever" about how I build the ArgumentList. I have attempted use of the -Command parameter for powershell.exe as this post suggests but I still get the type error even with a few different methods of formatting the string to have it interpreted as a command expression. You can also see that I have already attempted to explicitly capture the True|False values that switch parameters take and prefix them with a dollar sign to turn them into literal $true|$false to no avail.

EDIT 1

I also just tried this solution that I saw suggested in the sidebar after posting this question, in combination with the capture-true/false trick to send only the switch parameter name and not an assigned value. Instead of getting an error in the admin powershell instance, it just straight-up quits.

TIDE

I'm clearly not "clever" and I am at an impasse and I need help.

Invocation (in user-level powershell window):

PS C:\Users\myname\Documents> .\changeip.ps1 -Debug
C:\Users\myname\Documents\changeip.ps1 -Debug:$True
[Debug, True]
PS C:\Users\myname\Documents>
PS C:\Users\myname\Documents> $PSVersionTable

Name                           Value
----                           -----
PSVersion                      5.1.19041.1682
PSEdition                      Desktop
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.19041.1682
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1

The error in the Admin-level powershell window:

Windows PowerShell
Copyright (C) Microsoft Corporation. All rights reserved.

Try the new cross-platform PowerShell https://aka.ms/pscore6

C:\Users\myname\Documents\changeip.ps1 : Cannot convert 'System.String' to the type
'System.Management.Automation.SwitchParameter' required by parameter 'Debug'.
    + CategoryInfo          : InvalidArgument: (:) [changeip.ps1], ParentContainsErrorRecordException
    + FullyQualifiedErrorId : CannotConvertArgument,changeip.ps1

PS C:\Windows\system32>

Relevant code, copy-pasted directly from the script:

#   Parameters for command line usage, because some users may prefer to use this script in a command line lol
Param(
    [Parameter(HelpMessage="Path to new IP database file for script to use")]
    #   https://4sysops.com/archives/validating-file-and-folder-paths-in-powershell-parameters/
    [ValidateScript({
        #   If the valid-formatted path does not exist at all, throw an error
        if( -Not ($_ | Test-Path) ){
            throw "File does not exist"
        }
        #   If the valid-formatted path does not point to a file, throw an error
        if( -Not ($_ | Test-Path -PathType Leaf) ){
            throw "Argument must point to a file"
        }
        #   Finally, if the valid-formatted path does not point to a JSON file, specifically, throw an error
        if($_ -notmatch "\.json"){
            throw "Argument must point to a JSON file"
        }
        return $true
    })] #   Gotta catch 'em all! (The bracket types, that is)
    #   Data type that rejects invalid Windows file paths with illegal characters
    [System.IO.FileInfo]$NewIpdb,
    
    [Parameter(HelpMessage="A custom IP configuration string in the format IP,Netmask[,Gateway]")]
    [ValidateScript({
        #   https://stackoverflow.com/questions/5284147/validating-ipv4-addresses-with-regexp
        #   Shortest validation regex used and modified slightly to validate a CSV list of 2-3 IPs
        #   This regex is reused in a helper function down below, but I can't use the function here in the Param() block for ease of input validation
        if($_ -notmatch "^(((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4},?){2,3}$"){
            throw "A comma-separated string of valid IP addresses must be provided in the order: IP,Netmask[,Gateway]"
        }
        return $true
    })]
    [string]$SetIP,
    
    #   A simple true/false flag that can reset the IP configuration
    [Parameter(HelpMessage="Reset the network interface configured for this script to automatic DHCP configuration. Does not take an argument.")]
    [switch]$Reset,
    #   A true/false flag that can restart the network interface
    [Parameter(HelpMessage="Restart the network interface configured for this script. Does not take an argument.")]
    [switch]$Restart,
    #   Used for elevation to admin privileges if script invoked without
    #   DO NOT INVOKE THIS FLAG YOURSELF. THIS FLAG GETS INVOKED INTERNALLY BY THIS SCRIPT.
    [Parameter(HelpMessage="Used internally by script. Script MUST run with admin privileges, and attempts to self-elevate if necessary. This flag indicates success.")]
    [switch]$Elevated
    
    #   Add parameters: -ListConfigs -SetConfig
)
#   https://stackoverflow.com/questions/9895163/in-a-cmdlet-how-can-i-detect-if-the-debug-flag-is-set
#   The -Debug common parameter doesn't set the value of a $Debug variable unlike user-defined parameters
#   So this manual hack is here to fix that :/
$Debug = $PsBoundParameters.Debug.IsPresent

#   https://stackoverflow.com/questions/21559724/getting-all-named-parameters-from-powershell-including-empty-and-set-ones
$parameters = ""
foreach($key in $MyInvocation.BoundParameters.keys) {
    $parameters += "-" + $key + ":" + ("","$")[$MyInvocation.BoundParameters[$key] -imatch "true|false"] + $MyInvocation.BoundParameters[$key] + " "
}
#if($Debug) {
    Write-Host $MyInvocation.MyCommand.Definition $parameters
    Write-Host $MyInvocation.BoundParameters
#}

#   Next two blocks are almost verbatim copypasta'd from:
#   https://superuser.com/a/532109
#   Modified slightly to add user-invoked parameters to the argument list

#   Function to test if the current security context is Administrator
function Test-Admin {
    $currentUser = New-Object Security.Principal.WindowsPrincipal $([Security.Principal.WindowsIdentity]::GetCurrent())
    $currentUser.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}

#   If the script is not running as Administrator...
if ((Test-Admin) -eq $false)  {
    #   Check if elevation attempt has been made
    if ($elevated) {
        #   tried to elevate, did not work, aborting
        throw "Unable to elevate to Administrator privileges. This application cannot perform its designed function. Aborting."
    }
    else {  #   Try to elevate script
        Start-Process powershell.exe -Verb RunAs -ArgumentList ('-noprofile -noexit -file "{0}" {1} -elevated' -f ($myinvocation.MyCommand.Definition), $parameters)
    }
    exit
}

#   1260 more lines of code past this point, most of it building Windows Forms...

Upvotes: 0

Views: 207

Answers (1)

Calyo Delphi
Calyo Delphi

Reputation: 349

After several months of distraction from this bug, I have solved it!

The solution I implemented is manually re-building the $MyInvocation.BoundParameters dictionary post-elevation to admin:

First, a catch-all parameter is required that can be used internally on self-elevation to admin, and then immediately following the parameters block, a conditional block that rebuilds the BoundParameters dictionary:

Param(
    #   Generic catch-all parameter
    [Parameter()]
    [string]$ElevatedParams,
    
    #   All other parameters follow...
)

#   Check if $ElevatedParams even has a length to begin with (empty strings are falsy in PowerShell)
#   Alternatively, this can check if $elevated == $true
if($ElevatedParams) {
    #   The string of parameters carried over through self-elevation has to be converted into a hash for easy iteration
    #   The resulting hash must be stored in a new variable
    #   It doesn't work if one attempts to overwrite $ElevatedParams with a completely different data type
    $ElevatedHash = ConvertFrom-StringData -StringData $ElevatedParams
    
    #   Loop through all carried-over parameters
    foreach($key in $ElevatedHash.Keys) {
        try {       #   Try to parse the keyed value as a boolean... this captures switch parameters
            $value = [bool]::Parse($ElevatedHash[$key])
        }
        catch {     #   If an error is thrown in the try block, the keyed value is not a boolean
            $value = $ElevatedHash[$key]
        }
        finally {   #   Finally, push the key:value pair into the BoundParameters dictionary
            $MyInvocation.BoundParameters.Add($key, $value)
        }
    }
}

But now, we have to make sure that we actually have a data string of the original parameters and their values. This can be built with a foreach loop somewhere between the above statements and the self-elevation code:

$parameters = ""
foreach($key in $MyInvocation.BoundParameters.keys) {
    $parameters += ("`n","")[$parameters.Length -eq 0] + $key + "'" + $MyInvocation.BoundParameters[$key]
}

And then comes the guts of self-elevation:

#   Function to test if the current security context is Administrator
function Test-Admin {
    $currentUser = New-Object Security.Principal.WindowsPrincipal $([Security.Principal.WindowsIdentity]::GetCurrent())
    $currentUser.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}

#   If the script is not running as Administrator...
if ((Test-Admin) -eq $false) {
    #   Check if elevation attempt has been made
    if ($elevated) {
        #   tried to elevate, did not work, aborting
        throw "Unable to elevate to Administrator privileges. This application cannot perform its designed function. Aborting."
    }
    else {  #   Try to elevate script
        Start-Process powershell.exe -Verb RunAs -ArgumentList ('-noprofile -noexit -file "{0}" -ElevatedParams "{1}" -elevated' -f ($myinvocation.MyCommand.Definition), $parameters)
    }
    exit
}

If your script is not as dependent on $MyInvocation.BoundParameters having a particular parameter value set prior to the if ((Test-Admin) -eq $false) statement like my script currently is, all of the above can be refactored as follows:

#   Start of script
Param(
    #   Generic catch-all parameter
    [Parameter()]
    [string]$ElevatedParams,
    
    #   All other parameters follow...
)

function Rebuild-BoundParameters {
    Param([string]$ParameterDataString)
    
    #   The string of parameters carried over through self-elevation has to be converted into a hash for easy iteration
    #   The resulting hash must be stored in a new variable
    #   It doesn't work if one attempts to overwrite $ParameterDataString with a completely different data type
    $ParameterHash = ConvertFrom-StringData -StringData $ParameterDataString
    
    #   Loop through all carried-over parameters; does not execute if there are no parameters in the hash
    foreach($key in $ParameterHash.Keys) {
        try {       #   Try to parse the keyed value as a boolean... this captures switch parameters
            $value = [bool]::Parse($ParameterHash[$key])
        }
        catch {     #   If an error is thrown in the try block, the keyed value is not a boolean
            $value = $ParameterHash[$key]
        }
        finally {   #   Finally, push the key:value pair into the BoundParameters dictionary
            $MyInvocation.BoundParameters.Add($key, $value)
        }
    }
}

#   Function to test if the current security context is Administrator
function Test-Admin {
    $currentUser = New-Object Security.Principal.WindowsPrincipal $([Security.Principal.WindowsIdentity]::GetCurrent())
    $currentUser.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}

#   If the script is not running as Administrator...
if ((Test-Admin) -eq $false) {
    #   Check if elevation attempt has been made
    if ($elevated) {
        #   tried to elevate, did not work, aborting
        throw "Unable to elevate to Administrator privileges. This application cannot perform its designed function. Aborting."
    }
    else {  #   Try to elevate script
            #   Build the parameter data string first
        $parameters = ""
        foreach($key in $MyInvocation.BoundParameters.keys) {
            $parameters += ("`n","")[$parameters.Length -eq 0] + $key + "'" + $MyInvocation.BoundParameters[$key]
        }
        
        Start-Process powershell.exe -Verb RunAs -ArgumentList ('-noprofile -noexit -file "{0}" -ElevatedParams "{1}" -elevated' -f ($myinvocation.MyCommand.Definition), $parameters)
    }
    exit
}
else {  #   Script is running as Administrator
    Rebuild-BoundParameters $ElevatedParams
}

The above solution may require modification if passing a file path in $ElevatedParams to properly substitute the backslashes with escaped backslashes, and other such modifications, but the solution works so far.

Upvotes: 0

Related Questions