Run as Administrator using ShellExecute
Running scripts that require administrative privileges is a common challenge in Windows Scripting. The current post aims to detail the different implementations of process elevation using the ShellExecute
method of the Shell Automation Service COM object in WSH scripts, PowerShell and .NET.
1. Process Elevation with ShellExecute
The snippet below shows the RunAsAdministrator
subroutine (VBScript) and function (JScript). It takes two inputs: an absolute or relative file path and additional command line arguments joined in a string. Be aware that the file type is not always an executable. It may be any file type as long as there is a registered association with an executable on the system. The snippets and examples will be limited to .exe
files only.
/**
* @param {string} programExe - the path to the program to elevate
* @param {string} command - the arguments used to run the program
*/
function RunAsAdministrator(programExe, command) {
var Shell32 = new ActiveXObject('Shell.Application');
Shell32.ShellExecute(programExe, command, null, 'runas');
Shell32 = null;
}
''' <param name="strProgramExe">The path to the program to elevate.</param>
''' <param name="strArguments">The arguments used to run the program.</param>
Sub RunAsAdministrator(ByVal strProgramExe, ByVal strArguments)
Set Shell32 = CreateObject("Shell.Application")
Shell32.ShellExecute strProgramExe, strArguments,, "runas"
Set Shell32 = Nothing
End Sub
The RunAsAdministrator
function instantiates a Shell Automation Service COM object using its ProgId Shell.Application
. Then, we call its method ShellExecute
with the following arguments: the program path, the command arguments, and the runas
verb. Note that we omit the third argument, which is the working directory of the elevated process. The default value is then the working directory of the WSH process running the snippet scripts. Be aware that Windows Scripting programs (Command Prompt, PowerShell or WSH) ignore that value and set C:\Windows\System32
as the default location when acquiring administrative privileges. That is why scripts that depend on the current directory path string value may behave unexpectedly.
The ShellExecute
method is handy when the program to execute with administrative rights is not an administrator executable1. For example, the Windows PowerShell application fails to perform administrative tasks when its process runs in the user context. In this case, we can force the ShellExecute
method to trigger the User Access Control (UAC) prompt and request for elevation by setting the "runas"
verb.
1.1. RunAsAdministrator
with WSH Runtime
To run a small test, append to the scripts above the snippets below and name them respectively admin.js
and admin.vbs
.
/** @file admin.js */
RunAsAdministrator(WSH.Arguments(0), WSH.Arguments(1));
''' admin.vbs
RunAsAdministrator WScript.Arguments(0), WScript.Arguments(1)
Now open a Windows PowerShell console window and ensure that it is not an administrator's session that gets created. Then, execute the following commands. You can use the windowed (wscript
) or the console (cscript
) Windows Script Host (WSH) runtime for this use case. We assume the working directory is the admin.js
and admin.vbs
parent folder to avoid writing their paths.
PS> wscript admin.js "powershell" "-NoExit"
PS> wscript admin.vbs "powershell" "-NoExit"
These commands open the UAC window and request for elevation.
Click on Show more details
to view the command set to execute with administrative privileges. It is labeled Program location
. The Windows PowerShell console application is available from the PATH
environment variable, and the .EXE
file is on the PATHEXT
one. For this reason, powershell
is equivalent to C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
by default.
When you click Yes
, a Windows PowerShell console window opens with the title label Administrator
. If you execute one of the commands above from the open console, another Windows PowerShell console will open, but this time without prompting for administrative rights.
1.2. The Cons
The cons of the ShellExecute
method are that it does not return a value and is non-blocking because it behaves like right-clicking on the Run as Administrator
2 shortcut of the program context menu. Moreover, no automatic variable stores the identifier of the wscript
or cscript
process in WSH scripting. We have no helpful information for identifying the elevated process or for synchronizing it with its parent3 process. For these reasons, we do not have direct control over processes started with ShellExecute
from JScript and VBScript scripts. A workaround is to set a File-based Inter-Process Communication between the parent and the elevated process. The child process can write its status or any valuable information to a file the parent process has read permission. For instance, it is possible to get the identifier of the parent WSH process from the child process4 and write it to a file.
2. From WSH to PowerShell
The snippet below is an implementation of the Process Elevation solution in PowerShell. There are better and simpler solutions than this. That will be a matter of future posts. Here, we focus on a solution that is similar to the WSH scripts. The only fundamental difference between RunAsAdministrator
in WSH and Start-AdminProcess
is how they dereference COM objects for garbage collection.
using namespace System.Runtime.InteropServices
using namespace System.Reflection
function Start-AdminProcess {
[CmdletBinding()]
param (
# The path to the program to execute.
[string] $programExe,
# The arguments string used to run the program.
[string] $arguments
)
$Shell32 = New-Object -ComObject 'Shell.Application' # Late-binding
$Shell32.ShellExecute($programExe, $arguments, [Missing]::Value, 'runas')
[void][Marshal]::FinalReleaseComObject($Shell32)
$Shell32 = $null
}
2.1. Pause Script Execution
$PID
is the automatic variable that stores the Process Identifier of the PowerShell runtime process. The snippets below show how to pause the execution of the running script until the child process exits. We add the parameter $wait
for that specific use case.
The logic is to retrieve the process whose parent process is the current process identified by the $PID
process ID. We then call the method WaitForExit
.
using namespace System.Runtime.InteropServices
using namespace System.Reflection
using namespace Shell32 # COM Interop library namespace
using assembly Interop.Shell32.dll # Path to the COM Interop library
function Start-AdminProcess {
[CmdletBinding()]
param (
# The path to the program to execute.
[string] $programExe,
# The arguments string used to run the program.
[string] $arguments,
# Specifies that the script pauses til the process exits.
[switch] $wait
)
$Shell32 = [ShellClass]::new() # Early-binding
$Shell32.ShellExecute($programExe, $arguments, [Missing]::Value, 'runas')
[void][Marshal]::FinalReleaseComObject($Shell32)
$Shell32 = $null
if ($wait) {
Wait-ChildProcess
}
}
function Wait-ChildProcess {
[CmdletBinding()]
param ()
$childProcess =
Get-CimInstance Win32_Process -Filter "ParentProcessID='$PID'" |
Select-Object @{ Label = 'Id'; Expression = { $_.ProcessId } } |
Get-Process -ErrorAction SilentlyContinue
if ($null -ne $childProcess) { # In PowerShell Core,
$childProcess.WaitForExit() # we simplify the whole "if" statement to:
$childProcess.Dispose() # ${childProcess}?.WaitForExit()
} # ${childProcess}?.Dispose()
}
Note that using early-binding is purely optional. I just prefer it because it allows IntelliSense without the need to execute the COM object instantiation line of code.
Let us append to the above script the following snippet for testing.
Start-AdminProcess $args[0] $args[1] -wait
Start-AdminProcess $args[0] $args[1]
Start-AdminProcess $args[0] $args[1]
Be aware that PowerShell profile scripts may start child processes. So, we should execute the script using a clean PowerShell process to avoid waiting for a child process started by the profile scripts. It consists of starting the PowerShell program with the -NoProfile
argument.
Let us execute the script with one of the commands below, provided that we name it admin.ps1
. We assume that the console from which we run these commands is unelevated.
PS> powershell -NoProfile -File admin.ps1 "powershell" "-NoExit"
PS> pwsh -NoProfile admin.ps1 "powershell" "-NoExit"
Besides the elevation prompts, three console windows pop up. The second window appears only when we close the first one.
The third window appears while the second one is still open, and the script execution ends immediately after.
2.2. Limited Access to Elevated Process
Why can we not use the Wait-Process
cmdlet to wait for the elevated process or use the Parent
code property of Get-Process
objects to identify the processes involved? The reason is that the parent process runs in the user (as distinct from the administrator) context and has limited access to the elevated child process.
If we replace from the function Wait-ChildProcess
the line $childProcess.WaitForExit()
with the line below, the PowerShell runtime throws a denied access exception.
Wait-Process -Id $childProcess.Id
Let us add a breakpoint right before the line $childProcess.WaitForExit()
(which is the 33rd line in my code editor):
Set-PSBreakpoint -Script admin.ps1 -Line 33 -Action {
Wait-Process -Id $childProcess.Id 2>&1 | Out-Host
}
PS> .\admin.ps1 "powershell" "-NoExit"
Wait-Process : This command stopped operation of "powershell (16564)" because of the following error: Access is denied.
At line:2 char:3
+ Wait-Process -Id $childProcess.Id 2>&1 | Out-Host
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : CloseError: (System.Diagnostics.Process (powershell):Process) [Wait-Process], ProcessCommandException
+ FullyQualifiedErrorId : ProcessNotTerminated,Microsoft.PowerShell.Commands.WaitProcessCommand
In PowerShell Core, each object returned by Get-Process
keeps a reference to its Parent
process. We can use that property to filter down the processes returned by Get-Process
to the child process of the current PowerShell process identified by its $PID
as suggested by the snippet below.
#Requires -PSEdition Core
function Wait-ChildProcess {
[CmdletBinding()]
param ()
try {
Get-Process |
ForEach-Object {
if (($shouldExit = $_.Parent.Id -eq $PID)) {
$_.WaitForExit()
}
$_.Dispose()
if ($shouldExit) {
throw
}
}
}
catch { }
}
It works fine when the parent and child processes run in the same context. However, the Parent
property of an elevated process is always $null
in a user PowerShell session. Thus, $shouldExit
is always false and WaitForExit
is never executed.
3. From WSH to .NET
Everything that we can do in PowerShell, we can do in .NET (DotNet). Below are the translations of the PowerShell solution in the different .NET languages. As I did in the PowerShell solution, I will display the solution with late and early binding of the Shell Automation Service COM object, plus the implementation of the current process or script execution pause to show control over the pawned and elevated process. As in the PowerShell solution, it consists of retrieving the Process Identifier of the child process by using the current or parent Process ID.
3.1. C#
using System;
using System.Management;
using System.Reflection;
using System.Diagnostics;
using System.Runtime.InteropServices;
static class Program
{
/// <param name="programExe">The path to the program to elevate.</param>
/// <param name="arguments">The arguments used to run the program.</param>
/// <param name="wait">The execution should pause til the process exits.</param>
static void RunAsAdministrator(string programExe, string arguments, bool wait = false)
{
Type ShellType = Type.GetTypeFromProgID("Shell.Application");
dynamic Shell32 = Activator.CreateInstance(ShellType);
Shell32.ShellExecute(programExe, arguments, Missing.Value, "runas");
Marshal.FinalReleaseComObject(Shell32);
Shell32 = null;
if (wait) WaitChildProcessExit();
}
static void WaitChildProcessExit()
{
int currentProcessId; // (1)
using (Process currentProcess = Process.GetCurrentProcess()) // (2)
currentProcessId = currentProcess.Id; // (3)
string childProcessQuery =
"SELECT * FROM Win32_Process " +
"WHERE ParentProcessId=" + currentProcessId;
var searcher = new ManagementObjectSearcher(childProcessQuery);
var childEnum = searcher.Get().GetEnumerator();
if (!childEnum.MoveNext()) return;
int childProcessID = Convert.ToInt32(childEnum.Current["ProcessId"]);
try
{
using (Process childProcess = Process.GetProcessById(childProcessID))
childProcess.WaitForExit();
}
catch (ArgumentException)
{ /* Do nothing when the process has already exited */ }
}
}
Let us add a Main
method to the Program
class for testing.
static void Main(string[] args)
{
RunAsAdministrator(args[0], args[1], true); // Wait
RunAsAdministrator(args[0], args[1]); // Do not wait
RunAsAdministrator(args[0], args[1]);
}
Follow the next steps for execution.
PS> csc admin.cs
PS> .\admin.exe "powershell" "-NoExit"
3.2. F#
open System
open System.Reflection
open System.Management
open System.Diagnostics
open System.Runtime.InteropServices
open Shell32
let WaitChildProcessExit () =
use currentProcess = Process.GetCurrentProcess() // (1)
let currentProcessId = currentProcess.Id |> string // (2)
let childProcessQuery =
"SELECT * FROM Win32_Process " +
"WHERE ParentProcessId=" + currentProcessId
let searcher = new ManagementObjectSearcher(childProcessQuery)
let childEnum = searcher.Get().GetEnumerator()
if childEnum.MoveNext() then
try
use childProcess =
childEnum.Current.["ProcessId"]
|> Convert.ToInt32
|> Process.GetProcessById
childProcess.WaitForExit()
with
| :? ArgumentException -> ignore()
/// <param name="programExe">The path to the program to elevate.</param>
/// <param name="arguments">The arguments used to run the program.</param>
/// <param name="wait">The execution should pause til the process exits.</param>
let RunAsAdministrator (programExe: string) (arguments: string) (wait: bool) =
let mutable Shell32 = new ShellClass()
Shell32.ShellExecute(programExe, arguments, Missing.Value, "runas")
Marshal.FinalReleaseComObject Shell32 |> ignore
Shell32 <- null
if wait then WaitChildProcessExit()
We can append the snippet below to the source file above to run a small test.
let args = Array.sub fsi.CommandLineArgs 1 2
RunAsAdministrator args.[0] args.[1] true // Wait
RunAsAdministrator args.[0] args.[1] false // Do not wait
RunAsAdministrator args.[0] args.[1] false
Then, execute the command below provided that we name the script admin.fsx
. As in the PowerShell solution, the imported type library Interop.Shell32.dll should be referenced. I will show you in another post how to make a COM Interop library.
PS> fsi -r:Interop.Shell32 -r:System.Management admin.fsx "powershell" "-NoExit"
The reason I went straight to early-binding is because F# is strongly typed. Dealing with dynamic objects like COM objects involves a lot of coding. Too much code.
3.3. VB.NET
Imports System.Management
Imports System.Diagnostics
Imports System.Runtime.InteropServices
Imports Shell32
Module Program
''' <param name="programExe">The path to the program to elevate.</param>
''' <param name="arguments">The arguments used to run the program.</param>
''' <param name="wait">The execution should pause til the process exits.</param>
Sub RunAsAdministrator(programExe As String, arguments As String, Optional wait As Boolean = False)
Dim Shell32 As New Shell ' ShellClass
Shell32.ShellExecute(programExe, arguments,, "runas")
Marshal.FinalReleaseComObject(Shell32)
Shell32 = Nothing
If wait Then WaitChildProcessExit()
End Sub
Sub WaitChildProcessExit()
Dim currentProcessId As Integer ' (1)
Using currentProcess = Process.GetCurrentProcess() ' (2)
currentProcessId = currentProcess.Id ' (3)
End Using ' (4)
Dim childProcessQuery = _
"SELECT * FROM Win32_Process " & _
"WHERE ParentProcessId=" & currentProcessId
Dim searcher As New ManagementObjectSearcher(childProcessQuery)
Dim childEnum = searcher.Get().GetEnumerator()
If Not childEnum.MoveNext() Then Exit Sub
Dim childProcessID = childEnum.Current("ProcessId")
Try
Using childProcess = Process.GetProcessById(childProcessID)
childProcess.WaitForExit()
End Using
Catch ex As ArgumentException
End Try
End Sub
End Module
The JScript et VBScript scripts can be translated to JScript.NET and VB.NET, respectively, without much changes. However, it may lead to less optimized solutions. The RunAsAdministrator
is the same in both the source file (JScript.NET or VB.NET) and the scripts (JScript or VBScript). So, instead of being redundant, I preferred to use early-binding instead.
Let's add a Main
method to the Program
module for testing.
Sub Main(args As String())
RunAsAdministrator(args(0), args(1), True) ' Wait
RunAsAdministrator(args(0), args(1)) ' Do not wait
RunAsAdministrator(args(0), args(1))
End Sub
Follow the next steps for execution.
PS> vbc -r:Interop.Shell32.dll admin.vb
PS> .\admin.exe "powershell" "-NoExit"
3.4. JScript.NET
import System;
import System.Reflection;
import System.Management;
import System.Diagnostics;
import System.Runtime.InteropServices;
import Shell32;
/**
* @param {string} programExe - the path to the program to elevate
* @param {string} command - the arguments string used to run the program
* @param {bool} wait - the execution should pause til the process exits
*/
function RunAsAdministrator(programExe, command, wait) {
var Shell32 = new ShellClass();
Shell32.ShellExecute(programExe, command, Missing.Value, 'runas');
Marshal.FinalReleaseComObject(Shell32);
Shell32 = null;
if (wait) WaitChildProcessExit();
}
function WaitChildProcessExit() {
var currentProcess = Process.GetCurrentProcess();
var currentProcessId = currentProcess.Id;
currentProcess.Dispose();
var childProcessQuery =
'SELECT * FROM Win32_Process ' +
'WHERE ParentProcessId=' + currentProcessId;
var searcher = new ManagementObjectSearcher(childProcessQuery);
var childEnum = searcher.Get().GetEnumerator();
if (!childEnum.MoveNext()) return;
var childProcessID = childEnum.Current["ProcessId"];
try {
var childProcess = Process.GetProcessById(childProcessID);
childProcess.WaitForExit();
childProcess.Dispose();
} catch (error: ArgumentException) { }
}
We can append the snippet below to the source file above to run a small test. Note that when the third value is omitted, wait
takes the value undefined
. However, both undefined
and false
are falsy values in a condition expression. So, the outcome is the same as assigning the value false
to the variable wait
.
var args = Environment.GetCommandLineArgs();
RunAsAdministrator(args[1], args[2], true); // Wait
RunAsAdministrator(args[1], args[2]); // Do not wait
RunAsAdministrator(args[1], args[2]);
Follow the next steps for execution.
PS> jsc -r:Interop.Shell32 admin.js
PS> .\admin.exe "powershell" "-NoExit"
3.5 PowerShell as .NET
The snippet below shows how we would implement the script execution pause with a .NET mindset.
using namespace System.Management
using namespace System.Diagnostics
function Wait-ChildProcess {
[CmdletBinding()]
$currentProcess = [Process]::GetCurrentProcess() # (1)
$currentProcessId = $currentProcess.Id # (2)
$currentProcess.Dispose() # (3)
$childProcessQuery =
'SELECT * FROM Win32_Process ' +
'WHERE ParentProcessId=' + $currentProcessId
$searcher = [ManagementObjectSearcher]::new($childProcessQuery)
$childEnum = $searcher.Get().GetEnumerator()
if (-not $childEnum.MoveNext()) {
return
}
$childProcessID = $childEnum.Current['ProcessId'];
try {
$childProcess = [Process]::GetProcessById($childProcessID);
$childProcess.WaitForExit();
$childProcess.Dispose();
} catch [ArgumentException] { }
}
Instantiating or retrieving process objects in .NET is costly. In lines (1), (2) and (3), we initialize a variable to store the whole current process object only to retrieve its identifier. In .NET Core, we can get the current process identifier without returning the entire object with the help of the Environment.ProcessId
property. In PowerShell Core, those 3 lines translate to:
#Requires -PSEdition Core
$currentProcessId = [Environment]::ProcessId # $PID
Changing [Environment]::ProcessId
to $PID
suffices to make the whole script backwards compatible. I go through this step to show how easy it is to script in PowerShell with a .NET background and vice-versa.
In the .NET solutions we can transform into .NET Core solutions, I marked the lines (with numbers) to shrink into a single line to retrieve the current process identifier using Environment.ProcessId
. The conversions look like these:
- In VB.NET
Dim currentProcessId = Environment.ProcessId
- In C#
using System;
var currentProcessId = Environment.ProcessId;
- In F#
open System
let currentProcessId = Environment.ProcessId |> string
The .NET compilers do not reference the libraries to make the property available. We must use the dotnet build
command line utility to compile the source files. So, we create project files .csproj
, .fsproj
, and .vbproj
with the following content:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Configuration>Release</Configuration>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Management" Version="9.0.0" />
<!-- Required for early-binding Shell32 object -->
<Reference Include="Interop.Shell32.dll">
<HintPath>Interop.Shell32.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
Then we build and execute the projects. Let us start with VB:
PS> dotnet build admin.vbproj
PS> bin\Release\net9.0\admin.exe "powershell" "-noExit"
3.6 Early-bound Shell32 Variable
A Shell
object requires a Single-Threaded Apartment (STA), while C# and F# COM apartment models are multithreaded. Thus, when we use the early-bound Shell Automation Service object in those two languages, we have to set the STAThreaded
attribute to the entry method or function. So before we compile the F# solution above, we will modify the testing section to the snippet below:
open System
[<STAThread>]
[<EntryPoint>]
let main args =
RunAsAdministrator args.[0] args.[1] true // Wait
RunAsAdministrator args.[0] args.[1] false // Do not wait
RunAsAdministrator args.[0] args.[1] false
0
Building F# projects does not automatically include the F# files. So, we insert <Compile Include="admin.fs" />
into the <ItemGroup>
of the F# project file to ensure that dotnet
will compile our source file. Then we build and execute the output assembly.
PS> dotnet build admin.fsproj
PS> bin\Release\net9.0\admin.exe "powershell" "-noExit"
About C#, let us replace the ShellType
and Shell32
initializations with a single line in the RunAsAdministrator
method:
using Shell32;
Shell Shell32 = new();
The testing section of the C# solution looks like the snippet below:
using System;
static class Program
{
[STAThread]
static void Main(string[] args)
{
RunAsAdministrator(args[0], args[1], true); // Wait
RunAsAdministrator(args[0], args[1]); // Do not wait
RunAsAdministrator(args[0], args[1]);
}
/* ... */
}
Then we build and execute the C# project.
PS> dotnet build admin.csproj
PS> bin\Release\net9.0\admin.exe "powershell" "-noExit"
-
Administrator executables can trigger the UAC prompt themselves. They are identifiable by the shield overlay on the executable icon.↩
-
The behavior is not specific to
runas
orRun as Administrator
,ShellExecute
is non-blocking for every shortcut in the context menu.↩ -
An important takeaway is that the relationship between the current process and the elevated process is a parent-child relationship when we use
ShellExecute
.↩ -
It depends on the program runtime capabilities. A program like PowerShell has such capabilities. The method to pause the script execution (Section 2) can be used to retrieve the parent process identifier from the child process.↩
Comments
Post a Comment