How to Concretely Isolate "Only the Operations That Need Administrator Privileges" in a Windows App
· Go Komura · Windows Development, Security, UAC, C# / .NET, Win32
In the earlier post “A Minimum Security Checklist for Windows Application Development,” we drew the line: default to asInvoker, and isolate only the operations that need administrator privileges.
This time we go all the way to how to actually write that part.
In a Windows app, you cannot conveniently run just a portion of the same process “as administrator.” Elevation is a process-boundary matter, so what you need is a design that carves out just that operation into a separate execution unit.
This article proceeds in this order.
- The premises first
- Which isolation model to choose
- The most practical shape:
asInvoker+ an administrator helper EXE - Traps you do not want to miss during implementation
- Concrete code examples
The code examples assume .NET 8 / Windows desktop apps. The UI framework can be any of WPF / WinForms / WinUI; the differences are limited to the UI-side event handlers.
The code that appears in this article is published on GitHub as a complete buildable, runnable sample set (a shared contract library, UI / administrator helper demos, and unit tests that also run on Linux).
windows-admin-broker-deep-dive - komurasoft-blog-samples (GitHub)
1. The Conclusion First
The practical landing points, up front.
- Keep the ordinary UI app running as
asInvoker - Carve operations that need administrator privileges into a separate EXE
- Make that helper EXE
requireAdministrator - Launch it with
runas - For communication with the helper, use IPC such as named pipes — not standard input/output, which does not play well with
runas - Pass the helper only typed requests, never “raw command strings”
- On the helper side, validate the request contents again
- Restrict who can connect over IPC using the calling user’s SID and the expected PID
“Running as administrator is easier” is only true the first time. Later, UAC, drag & drop, log design, external input, support operations, DLL loading, and settings storage locations all start giving you dirty looks.
2. Setting the Premise: You Cannot Make Part of the Same Process Administrator
Windows UAC is controlled not by “per-function elevation” but by which token / integrity level the process runs with. Apps that need an administrator access token are subject to the elevation prompt, and parent and child processes inherit tokens at the same integrity level. In other words, the design of suddenly executing one particular method with administrator privileges inside a non-elevated UI process is not possible. If you need it, you use a different execution unit: a separate process, a service, a task, elevated COM, and so on.
If you think about it without this premise, you end up with the somewhat pitiable design request: “I want it to become administrator just for the moment this button is pressed.” Windows does not fill that gap with magic.
3. Which Isolation Model to Choose
Microsoft Learn lists mainly the following four ways to isolate apps that need administrator privileges.
| Model | Rough shape | Good fit |
|---|---|---|
| Administrator Broker Model | Standard-user UI app + administrator helper EXE | Administrative operations are sporadic; showing UAC only at the needed moment is fine |
| Operating System Service Model | Standard-user UI + resident service | Always-on administrative functions, background monitoring, unattended processing |
| Elevated Task Model | Standard-user UI + a scheduled task with administrator privileges | Short, fixed-form jobs that finish each time |
| Administrator COM Object Model | Standard-user UI + elevated COM | An existing COM design exists and the functionality is quite limited |
Rough guidance for choosing:
3.1 The Broker EXE Is the Easiest First Candidate
A broker EXE fits operations like these:
- Registering / unregistering Explorer integration
- Machine-wide configuration changes under HKLM
- Registering / unregistering the app’s own service
- Adding / removing firewall rules
- Administrator operations under Program Files
These tend to be unnecessary in normal use and needed only when a specific button on the settings screen is pressed. In that case, rather than reaching for a resident service, the shape where an administrator helper EXE launches once and exits is more natural.
3.2 Choose a Service for “Always-On,” “Unattended,” “Frequent”
A service is the model where the standard-user app communicates via RPC and the like. The advantage is receiving administrative work without an elevation prompt — but in exchange, the responsibility of operating a resident process increases.
A service fits uses like these:
- Continuous monitoring
- Log collection
- Background updates
- Always-on integration with devices or daemons
- Administrative functions shared by multiple UI sessions
3.3 A Task Suits “Short, Fixed-Form Work”
The Elevated Task Model launches a scheduled task that runs with administrator privileges from the standard-user app. It is lighter than a service and closes when done, so it fits one-shot fixed-form jobs.
3.4 Elevated COM Is Quite Limited
The COM elevation moniker looks handy, but its applicable scope is narrow. Microsoft Learn also states that the UI controlling elevated COM must be presented by the COM side, so it is not suited to “letting a non-elevated UI do whatever it wants with elevated COM.”
4. This Article’s Recommendation: asInvoker UI + requireAdministrator Helper EXE
From here, we make the most practical shape concrete.
[ MyApp.exe ] asInvoker
|
| ShellExecute / ProcessStartInfo + Verb=runas
v
[ MyApp.AdminBroker.exe ] requireAdministrator
|
| named pipe
v
[ Executes only fixed operations that need administrator privileges ]
There are three key points.
- The UI process stays non-elevated to the end
- The administrator helper is short-lived
- The helper accepts only a fixed allowlist of operations
Just holding to these three cleans up the design considerably.
5. Rules You Do Not Want to Miss in the Implementation
These are better decided before writing code.
5.1 Do Not Turn the Helper into a “Do-Anything Box”
Bad examples:
- The UI passes the helper a whole
reg add ...string - The UI passes the helper a whole
sc.exe ...string - The UI passes the helper arbitrary registry paths or arbitrary EXE paths
Do this, and if the UI is compromised, the helper falls with it. The administrator helper is inside the elevation boundary. Creating a “can-run-anything opening” there is quite dangerous.
The good shape looks like this:
set-explorer-context-menuinstall-serviceadd-firewall-rule
Fix the operations themselves, and keep the required arguments to bool / enum / numbers / constrained strings.
5.2 Paths Passed to the Helper Are Absolute — and the UI Should Not Decide Too Much
The helper EXE launched with runas is itself specified by absolute path.
Avoid relying on PATH search or relative paths.
Furthermore, what the helper operates on should also be resolved and fixed on the helper side as much as possible.
In this sample, the target EXE registered in the Explorer context menu is fixed to the MyApp.exe in the same folder as the helper.
5.3 If You Use Verb=\"runas\", Explicitly Set UseShellExecute=true
In .NET, ProcessStartInfo.Verb only takes effect when UseShellExecute=true.
Moreover, the default of UseShellExecute differs between .NET Framework and .NET Core / .NET.
Leave this to the default, and you later get the quietly infuriating accident of “works in some environments and not in others.”
So always set it explicitly.
5.4 runas and Standard I/O Redirection Do Not Mix
With UseShellExecute=true, communication built on standard input/output redirection becomes hard to use.
Therefore, it is more natural to use a different IPC mechanism such as a named pipe for the exchange with the helper.
5.5 Do Not Rely on the Default ACL of Named Pipes
With the default security descriptor, named pipes default to granting read access to Everyone and anonymous. Using that as is for the administrator helper’s IPC is quite sloppy.
Always set an explicit PipeSecurity.
5.6 PipeOptions.CurrentUserOnly Is Not Used in This Scenario
At first glance this looks convenient.
But on Windows, CurrentUserOnly checks not just the user account but also the elevation level.
That means it is not suited to communication between a non-elevated UI and an elevated helper.
Moreover, in standard-user environments UAC becomes a credential prompt, and the helper may run as a different administrator account.
In that case, if the helper builds the ACL straight from WindowsIdentity.GetCurrent(), the original UI user may no longer be able to connect.
So in this article we use this shape:
- The UI side obtains its own SID and passes it to the helper
- The helper grants pipe connection rights only to the UI user’s SID
- Additionally, the helper checks the connecting PID with
GetNamedPipeClientProcessId
5.7 PID Verification Is an Extra Defense to Reduce “Crude Queue-Jumping”
A random pipe name alone helps a lot, but the chance that another process running as the same user connects first is not zero.
So on the helper side, use GetNamedPipeClientProcessId and verify that it matches the expected UI process PID.
Of course, a matching PID does not mean everything can be trusted. If the UI is compromised, dangerous requests will reach the helper too. Which is exactly why the helper-side operation allowlist and argument validation are necessary.
6. The Sample Scenario
This article uses the example of registering / unregistering an Explorer right-click menu entry machine-wide.
The reasons are simple:
- It requires administrator privileges
- The operation’s boundary is clear
- No arbitrary command strings need to be passed to the helper
- It genuinely occurs in real-world work
The registration targets are fixed keys like these:
HKLM\SOFTWARE\Classes\*\shell\MyApp.OpenHKLM\SOFTWARE\Classes\*\shell\MyApp.Open\command
The UI has only a “Register in the Explorer right-click menu” checkbox; the actual registry operations happen on the helper side.
7. Solution Structure
MyApp/
MyApp/ UI app (asInvoker)
app.manifest
ElevationBrokerClient.cs
SettingsPage.xaml.cs
MyApp.AdminBroker/ Administrator helper (requireAdministrator)
app.manifest
Program.cs
BrokerLaunchOptions.cs
ExplorerContextMenuRegistration.cs
MyApp.BrokerProtocol/ Shared contract
BrokerProtocol.cs
Keeping the shared contract in a separate project makes it easy to align between the UI and the helper:
- operation names
- request / response types
- the pipe message format
8. Manifests
8.1 UI Side (MyApp/app.manifest)
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApp.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
8.2 Helper Side (MyApp.AdminBroker/app.manifest)
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApp.AdminBroker.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
The UI stays asInvoker throughout.
Only the helper is requireAdministrator.
Reverse these, and the point of splitting them disappears.
9. Shared Contract Code
9.1 MyApp.BrokerProtocol/BrokerProtocol.cs
using System.Buffers.Binary;
using System.Text.Json;
namespace MyApp.BrokerProtocol;
public static class BrokerJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
public static class BrokerOperations
{
public const string SetExplorerContextMenu = "set-explorer-context-menu";
}
public sealed record BrokerRequest(string Operation, JsonElement Payload);
public sealed record BrokerResponse(bool Success, string? ErrorCode, string? Message)
{
public static BrokerResponse Ok(string? message = null) => new(true, null, message);
public static BrokerResponse Fail(string errorCode, string message) =>
new(false, errorCode, message);
}
public sealed record SetExplorerContextMenuRequest(bool Enabled);
public static class PipeMessageSerializer
{
private const int MaxPayloadBytes = 256 * 1024;
public static async Task WriteAsync<T>(Stream stream, T value, CancellationToken cancellationToken)
{
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(value, BrokerJson.Options);
if (payload.Length > MaxPayloadBytes)
{
throw new InvalidDataException($"Payload is too large: {payload.Length} bytes.");
}
byte[] header = new byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length);
await stream.WriteAsync(header.AsMemory(0, header.Length), cancellationToken);
await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancellationToken);
await stream.FlushAsync(cancellationToken);
}
public static async Task<T> ReadAsync<T>(Stream stream, CancellationToken cancellationToken)
{
byte[] header = await ReadExactAsync(stream, sizeof(int), cancellationToken);
int payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header);
if (payloadLength <= 0 || payloadLength > MaxPayloadBytes)
{
throw new InvalidDataException($"Invalid payload length: {payloadLength}");
}
byte[] payload = await ReadExactAsync(stream, payloadLength, cancellationToken);
return JsonSerializer.Deserialize<T>(payload, BrokerJson.Options)
?? throw new InvalidDataException($"Failed to deserialize {typeof(T).FullName}.");
}
private static async Task<byte[]> ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
{
byte[] buffer = new byte[length];
int offset = 0;
while (offset < length)
{
int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken);
if (read == 0)
{
throw new EndOfStreamException("Pipe was closed before the expected number of bytes was read.");
}
offset += read;
}
return buffer;
}
}
The point is: do not just stream JSON down the pipe loosely — send it length-prefixed. Keeping the protocol simple — one request, one response — makes it less accident-prone.
10. UI Side: Launching and Communicating with the Helper
10.1 MyApp/ElevationBrokerClient.cs
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO.Pipes;
using System.Security.Principal;
using System.Text.Json;
using MyApp.BrokerProtocol;
namespace MyApp;
public sealed class ElevationBrokerClient
{
private readonly string _helperExePath;
public ElevationBrokerClient(string helperExePath)
{
_helperExePath = Path.GetFullPath(helperExePath);
if (!Path.IsPathRooted(_helperExePath))
{
throw new ArgumentException("Helper executable path must be absolute.", nameof(helperExePath));
}
if (!File.Exists(_helperExePath))
{
throw new FileNotFoundException("Helper executable was not found.", _helperExePath);
}
}
public async Task SetExplorerContextMenuEnabledAsync(bool enabled, CancellationToken cancellationToken = default)
{
string pipeName = $"myapp-broker-{Guid.NewGuid():N}";
int clientPid = Environment.ProcessId;
string clientSid = GetCurrentUserSid();
StartHelper(pipeName, clientPid, clientSid);
using var pipe = new NamedPipeClientStream(
serverName: ".",
pipeName: pipeName,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(TimeSpan.FromSeconds(30));
await pipe.ConnectAsync(connectCts.Token);
BrokerRequest request = new(
BrokerOperations.SetExplorerContextMenu,
JsonSerializer.SerializeToElement(
new SetExplorerContextMenuRequest(enabled),
BrokerJson.Options));
await PipeMessageSerializer.WriteAsync(pipe, request, cancellationToken);
BrokerResponse response = await PipeMessageSerializer.ReadAsync<BrokerResponse>(pipe, cancellationToken);
if (!response.Success)
{
throw new InvalidOperationException(
$"Admin broker returned an error. Code={response.ErrorCode}, Message={response.Message}");
}
}
private void StartHelper(string pipeName, int clientPid, string clientSid)
{
string workingDirectory = Path.GetDirectoryName(_helperExePath)
?? throw new InvalidOperationException("Helper executable directory could not be resolved.");
var startInfo = new ProcessStartInfo
{
FileName = _helperExePath,
Arguments = BuildArguments(pipeName, clientPid, clientSid),
WorkingDirectory = workingDirectory,
UseShellExecute = true,
Verb = "runas"
};
try
{
Process.Start(startInfo)
?? throw new InvalidOperationException("The helper process could not be started.");
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
throw new OperationCanceledException("The administrator approval was canceled.", ex);
}
}
private static string GetCurrentUserSid()
{
using WindowsIdentity identity = WindowsIdentity.GetCurrent();
return identity.User?.Value
?? throw new InvalidOperationException("Current user SID could not be resolved.");
}
private static string BuildArguments(string pipeName, int clientPid, string clientSid)
{
return string.Join(
" ",
"--pipe",
QuoteArgument(pipeName),
"--client-pid",
clientPid.ToString(CultureInfo.InvariantCulture),
"--client-sid",
QuoteArgument(clientSid));
}
private static string QuoteArgument(string value)
{
return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
}
What gets passed to the helper here is only the pipe name and the minimum information needed to verify the connecting party.
The administrator operation itself is confined to the typed request sent through the pipe.
This QuoteArgument is a minimal implementation that assumes the simple values passed in this sample — a pipe name, a PID, a SID. If you pass arbitrary Windows paths or free-form strings as command-line arguments, replace it with dedicated escaping that follows the Windows argv parsing rules.
11. Helper Side: Parsing the Launch Arguments
11.1 MyApp.AdminBroker/BrokerLaunchOptions.cs
namespace MyApp.AdminBroker;
internal sealed class BrokerLaunchOptions
{
public required string PipeName { get; init; }
public required int ExpectedClientProcessId { get; init; }
public required string ClientUserSid { get; init; }
public static BrokerLaunchOptions Parse(string[] args)
{
string? pipeName = null;
int? clientPid = null;
string? clientSid = null;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--pipe":
pipeName = ReadNextValue(args, ref i, "--pipe");
break;
case "--client-pid":
string pidText = ReadNextValue(args, ref i, "--client-pid");
if (!int.TryParse(pidText, out int pid) || pid <= 0)
{
throw new ArgumentException($"Invalid client PID: {pidText}");
}
clientPid = pid;
break;
case "--client-sid":
clientSid = ReadNextValue(args, ref i, "--client-sid");
break;
default:
throw new ArgumentException($"Unknown argument: {args[i]}");
}
}
if (string.IsNullOrWhiteSpace(pipeName))
{
throw new ArgumentException("--pipe is required.");
}
if (clientPid is null)
{
throw new ArgumentException("--client-pid is required.");
}
if (string.IsNullOrWhiteSpace(clientSid))
{
throw new ArgumentException("--client-sid is required.");
}
return new BrokerLaunchOptions
{
PipeName = pipeName,
ExpectedClientProcessId = clientPid.Value,
ClientUserSid = clientSid
};
}
private static string ReadNextValue(string[] args, ref int index, string optionName)
{
if (index + 1 >= args.Length)
{
throw new ArgumentException($"A value is required after {optionName}.");
}
index++;
return args[index];
}
}
The helper side errors out the moment arguments are missing or extra arguments are present. Inside the elevation boundary, “interpret it as best we can for now” is something you should not do.
12. Helper Side: Pipe Creation, Client PID Verification, Dispatch
12.1 MyApp.AdminBroker/Program.cs
using System.ComponentModel;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using MyApp.BrokerProtocol;
namespace MyApp.AdminBroker;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
BrokerLaunchOptions options = BrokerLaunchOptions.Parse(args);
using var brokerCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using NamedPipeServerStream pipe = CreatePipeServer(options);
await pipe.WaitForConnectionAsync(brokerCts.Token);
VerifyClientProcessId(pipe, options.ExpectedClientProcessId);
BrokerRequest request = await PipeMessageSerializer.ReadAsync<BrokerRequest>(pipe, brokerCts.Token);
BrokerResponse response = await DispatchAsync(request);
await PipeMessageSerializer.WriteAsync(pipe, response, brokerCts.Token);
return response.Success ? 0 : 2;
}
private static Task<BrokerResponse> DispatchAsync(BrokerRequest request)
{
try
{
return request.Operation switch
{
BrokerOperations.SetExplorerContextMenu => HandleSetExplorerContextMenuAsync(request.Payload),
_ => Task.FromResult(
BrokerResponse.Fail(
"unsupported_operation",
$"Unsupported operation: {request.Operation}"))
};
}
catch (JsonException ex)
{
return Task.FromResult(BrokerResponse.Fail("invalid_payload", ex.Message));
}
catch (Exception ex)
{
return Task.FromResult(BrokerResponse.Fail("broker_failure", ex.Message));
}
}
private static NamedPipeServerStream CreatePipeServer(BrokerLaunchOptions options)
{
var pipeSecurity = new PipeSecurity();
var clientSid = new SecurityIdentifier(options.ClientUserSid);
SecurityIdentifier helperSid = WindowsIdentity.GetCurrent().User
?? throw new InvalidOperationException("Helper user SID could not be resolved.");
pipeSecurity.AddAccessRule(new PipeAccessRule(
clientSid,
PipeAccessRights.ReadWrite,
AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(
helperSid,
PipeAccessRights.FullControl,
AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
PipeAccessRights.FullControl,
AccessControlType.Allow));
return NamedPipeServerStreamAcl.Create(
options.PipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 1,
transmissionMode: PipeTransmissionMode.Byte,
options: PipeOptions.Asynchronous | PipeOptions.WriteThrough,
inBufferSize: 0,
outBufferSize: 0,
pipeSecurity: pipeSecurity);
}
private static void VerifyClientProcessId(NamedPipeServerStream pipe, int expectedClientProcessId)
{
if (!GetNamedPipeClientProcessId(
pipe.SafePipeHandle.DangerousGetHandle(),
out uint actualClientProcessId))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (actualClientProcessId != (uint)expectedClientProcessId)
{
throw new InvalidOperationException(
$"Unexpected pipe client PID. Expected={expectedClientProcessId}, Actual={actualClientProcessId}");
}
}
private static Task<BrokerResponse> HandleSetExplorerContextMenuAsync(JsonElement payload)
{
SetExplorerContextMenuRequest request = payload.Deserialize<SetExplorerContextMenuRequest>(BrokerJson.Options)
?? throw new JsonException("Payload could not be parsed.");
ExplorerContextMenuRegistration.Apply(request.Enabled);
return Task.FromResult(BrokerResponse.Ok("Explorer context menu setting was updated."));
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeClientProcessId(
IntPtr pipe,
out uint clientProcessId);
}
What is doing the work here:
- The pipe ACL is assembled explicitly
- The ACL is granted not just to the helper’s current user SID but also to the calling UI user’s SID
- After connection, the client PID is verified
- Even after receiving the request, it is dispatched by operation name
Keeping the shape where switch (request.Operation) lets through only fixed operations makes the helper less likely to become “an elevated anything-box.”
13. The Administrator Operation Itself: Explorer Right-Click Menu Registration
13.1 MyApp.AdminBroker/ExplorerContextMenuRegistration.cs
using System;
using System.IO;
using Microsoft.Win32;
namespace MyApp.AdminBroker;
internal static class ExplorerContextMenuRegistration
{
private const string MenuKeyPath = @"SOFTWARE\Classes\*\shell\MyApp.Open";
private const string CommandKeyPath = @"SOFTWARE\Classes\*\shell\MyApp.Open\command";
private const string MenuText = "Open with MyApp";
private const string ClientExecutableName = "MyApp.exe";
public static void Apply(bool enabled)
{
string clientExePath = ResolveClientExecutablePath();
using RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, GetRegistryView());
if (enabled)
{
using RegistryKey menuKey = hklm.CreateSubKey(MenuKeyPath)
?? throw new InvalidOperationException($"Failed to create registry key: {MenuKeyPath}");
menuKey.SetValue(null, MenuText, RegistryValueKind.String);
menuKey.SetValue("Icon", $"\"{clientExePath}\",0", RegistryValueKind.String);
using RegistryKey commandKey = hklm.CreateSubKey(CommandKeyPath)
?? throw new InvalidOperationException($"Failed to create registry key: {CommandKeyPath}");
commandKey.SetValue(null, $"\"{clientExePath}\" \"%1\"", RegistryValueKind.String);
}
else
{
hklm.DeleteSubKeyTree(@"SOFTWARE\Classes\*\shell\MyApp.Open", throwOnMissingSubKey: false);
}
}
private static string ResolveClientExecutablePath()
{
string clientExePath = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, ClientExecutableName));
if (!File.Exists(clientExePath))
{
throw new FileNotFoundException("Client executable was not found.", clientExePath);
}
return clientExePath;
}
private static RegistryView GetRegistryView()
{
return Environment.Is64BitOperatingSystem
? RegistryView.Registry64
: RegistryView.Registry32;
}
}
The crux of this code lies in what it does not receive from the UI.
- It does not receive arbitrary registry paths from the UI
- It does not receive arbitrary command strings from the UI
- The EXE being registered is resolved and fixed on the helper side
- The request content is
Enabledonly
In other words, the helper is constrained to have exactly one meaning: “toggle the registration state of the Explorer right-click menu.”
14. Calling It from the UI
14.1 MyApp/SettingsPage.xaml.cs
using System.Windows;
namespace MyApp;
public partial class SettingsPage
{
private readonly ElevationBrokerClient _broker = new(
Path.Combine(AppContext.BaseDirectory, "MyApp.AdminBroker.exe"));
private async void ExplorerMenuCheckBox_Click(object sender, RoutedEventArgs e)
{
bool enabled = ExplorerMenuCheckBox.IsChecked == true;
try
{
await _broker.SetExplorerContextMenuEnabledAsync(enabled);
MessageBox.Show("Setting has been updated.", "MyApp");
}
catch (OperationCanceledException)
{
MessageBox.Show("The administrator approval prompt was canceled.", "MyApp");
ExplorerMenuCheckBox.IsChecked = !enabled;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "Failed to update the setting.");
ExplorerMenuCheckBox.IsChecked = !enabled;
}
}
}
The UI side is ordinary.
- Read the checkbox state
- Call the broker client
- Roll the UI back on failure
That is all. It does not touch the registry directly. That is what isolation means.
15. What This Implementation Holds On To
The lines this sample actually defends are these.
15.1 Separation of Responsibility Between UI and Helper
- The UI only receives the user’s actions
- The helper executes only fixed administrator operations
15.2 No “Arbitrary Execution Opening” in the Helper
- It accepts no arbitrary registry paths
- It accepts no arbitrary command lines
- It accepts no arbitrary EXE paths
15.3 The Launch Path Is Fixed
- The helper EXE is an absolute path
runasis explicitUseShellExecute = trueis explicit
15.4 The IPC Connecting Party Is Restricted
- The pipe ACL is limited to the UI user’s SID
- The client PID is verified after connection
15.5 The Targets of the Administrator Operation Are Also Fixed
- The registry hive / path is fixed
- The EXE being registered is also resolved as fixed
Go this far, and you are quite distant from the state of “if the UI is compromised, anything can be done through the helper.”
16. Common Anti-Patterns
16.1 Making the Entire UI requireAdministrator
Only one button on the settings screen needs administrator privileges, yet everything launches elevated. This crushes the privilege boundary carelessly.
16.2 Passing the Helper Raw String Commands
For example, this design:
UI -> helper receives "reg add HKLM\\.... /v ... /d ..."
This turns the helper into a command executor. Better not to.
16.3 Using the Default ACL of Named Pipes As Is
“It is local IPC, so it should be fine” is a bit dangerous. Pipes are subject to Windows security, so build a proper ACL.
16.4 Jumping at CurrentUserOnly
It looks convenient, but it does not suit this article’s case of a medium-integrity UI talking to a high-integrity helper. Explicit ACLs are easier to handle here.
16.5 The Helper Accepting Arbitrary Paths to Operate On
For example:
- Copying arbitrary files into Program Files
- Writing arbitrary keys into HKLM
- Deleting an arbitrary service by name
- Adding firewall rules from arbitrary commands
If the helper accepts these, the helper itself becomes a general-purpose execution opening with administrator privileges. Operations should always be fixed.
17. Summary
“Only part of the processing needs administrator privileges” is not an unusual situation in Windows apps.
But the way to solve it is not “make everything requireAdministrator” — it is cutting an execution boundary.
The shape that is easiest to adopt first is this:
- The UI is
asInvoker - Administrator work is isolated into a helper EXE
- The helper is
requireAdministrator - Launch is via
runas - Communication is over a named pipe
- The helper accepts only fixed operations
- The connecting party is restricted via the pipe ACL and client PID
- The helper re-validates the arguments
With this shape in place, migrating to a service later is also easier. If the operation contract is cleanly separated, the boundary between UI and administrator work becomes a design asset in itself.
In security, not leaving sloppy boundaries beats adding flashy features. Administrator privileges are the same. Do not hand them over wholesale — grant them only where needed, as narrowly as possible. That kind of unglamorous discipline pays off later.
18. References
- The complete sample code for this article (shared contract library, demos, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
- Original article: A Minimum Security Checklist for Windows Application Development https://comcomponent.com/en/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- Administrator Broker Model - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/administrator-broker-model
- Developing Applications that Require Administrator Privilege https://learn.microsoft.com/en-us/windows/win32/secauthz/developing-applications-that-require-administrator-privilege
- Operating System Service Model - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/operating-system-service-model
- Elevated Task Model - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/elevated-task-model
- Administrator COM Object Model - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/administrator-com-object-model
- The COM Elevation Moniker https://learn.microsoft.com/en-us/windows/win32/com/the-com-elevation-moniker
- How User Account Control works https://learn.microsoft.com/en-us/windows/security/application-security/application-control/user-account-control/how-it-works
- ProcessStartInfo.UseShellExecute https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-diagnostics-processstartinfo-useshellexecute
- Named Pipe Security and Access Rights https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights
- PipeOptions Enum https://learn.microsoft.com/en-us/dotnet/api/system.io.pipes.pipeoptions?view=net-10.0
- NamedPipeServerStreamAcl.Create https://learn.microsoft.com/en-us/dotnet/api/system.io.pipes.namedpipeserverstreamacl.create?view=net-10.0
- GetNamedPipeClientProcessId https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-getnamedpipeclientprocessid
- RegistryView Enum https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.registryview?view=net-8.0
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Storing Secrets in Windows Apps - Avoiding Plaintext Configuration with DPAPI
To avoid storing connection credentials and API tokens in plaintext configuration files in Windows apps, we walk through DPAPI / Protecte...
A Minimum Security Checklist for Windows App Development
A checklist-style guide to the security basics for WPF / WinForms / WinUI / C++ / C# business apps: privileges, signing, updates, secrets...
Why Windows Became What It Is Today: The Evolution of Windows Through a Developer's Eyes
A look at the changes from Windows 95 to Windows 11 — not as a visual timeline, but from a Windows application developer's perspective: c...
When Do You Actually Need Administrator Privileges on Windows? - UAC, Protected Areas, and How to Tell by Design
A practical look at when administrator privileges are required on Windows, from the perspectives of UAC, protected areas, services, drive...
Handling Windows Impersonation Tokens Correctly — Borrowing Privileges per Thread and Reverting Safely
A practical guide to Windows impersonation tokens — access tokens, primary tokens, thread tokens, impersonation levels, RevertToSelf, and...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
This topic touches the privilege design of an entire Windows app — UAC, helper EXEs, deciding when to use a service, and machine-wide configuration changes — so it fits well with our Windows application development service.
Technical Consulting & Design Review
If you want to move away from habitual `requireAdministrator` in an existing app and rework the broker design and IPC boundaries, this topic works well as a technical consulting / design review engagement.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links