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.

UAC prompt

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.

UAC prompt extended

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.

Administrator: PowerShell

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 Administrator2 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"


  1. Administrator executables can trigger the UAC prompt themselves. They are identifiable by the shield overlay on the executable icon.

  2. The behavior is not specific to runas or Run as Administrator, ShellExecute is non-blocking for every shortcut in the context menu.

  3. An important takeaway is that the relationship between the current process and the elevated process is a parent-child relationship when we use ShellExecute.

  4. 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

Popular posts from this blog

Start a Program on the Remote Active Desktop with PowerShell

Git-Log or Git Graph in Visual Studio Code

Git-Commit: Reuse Commit Messages