How to Isolate Only the Administrator-Required Work in a Windows App

· · Windows Development, Security, UAC, C# / .NET, Win32

Bottom line

When a Windows app has work that needs administrator rights, the safe and practical approach is not to run the whole app elevated, but to split only the administrator-required part into a separate EXE.

  • Keep the UI app at asInvoker
  • Move the administrator work into a separate helper EXE marked requireAdministrator
  • Launch the helper with runas
  • Use IPC such as a named pipe to talk to the helper
  • Send only typed requests to the helper
  • Re-validate every request on the helper side

Premise: you cannot elevate just part of the same process

Windows UAC is per-process. You cannot say “only this one method runs as administrator” inside a single process. If a piece of work needs administrator rights, it has to live in a different execution unit such as a separate process or a service.

Choosing a separation model

Model What it is When it fits
Administrator Broker asInvoker UI + administrator helper EXE You only need UAC at the moment the work happens
OS Service UI + a long-running service Always-on management features
Elevated Task UI + an elevated scheduled task One-shot routine jobs
COM Object UI + an elevated COM object Niche cases where you already have a COM design

The easiest one to start with is the broker EXE. It is a great fit when only a specific button on a settings screen needs administrator rights.

[MyApp.exe]  asInvoker
     |
     | runas
     v
[MyApp.AdminBroker.exe]  requireAdministrator
     |
     | named pipe
     v
[run the administrator operation]

Key points:

  1. The UI process stays unelevated from start to finish
  2. The administrator helper is short-lived
  3. The helper only accepts a fixed allow-list of operations

Rules to follow in the implementation

Do not let the helper become a generic command runner

Bad: the UI sends a raw command string (reg add ..., etc.) to the helper. If the UI gets compromised, the helper goes down with it.

Good: the operation name is fixed (set-explorer-context-menu, install-service, and so on). Arguments are limited to bool, enum, numbers, and tightly constrained strings.

Things to watch when launching

  • Always pass the helper EXE’s path as an absolute path
  • In .NET, set UseShellExecute=true explicitly (Verb="runas" only works with UseShellExecute=true)
  • runas does not play well with stdio redirection, so use a named pipe for IPC

Named-pipe security

  • Do not rely on the default ACL; set an explicit PipeSecurity
  • PipeOptions.CurrentUserOnly cannot be used between an unelevated UI and an elevated helper (it also checks elevation level)
  • On the UI side, get your own SID and pass it to the helper
  • On the helper side, grant pipe-connect rights only to that UI user’s SID
  • Use GetNamedPipeClientProcessId to also verify the connecting PID

About PID verification

PID verification is defense-in-depth against “another process running as the same user connects first.” Even if the PID matches, a compromised UI can still send dangerous requests, so the operation allow-list and argument re-validation are the real defenses.

Concrete code sketches

Shared contract (BrokerProtocol)

Define the operation names and request/response types in a shared project.

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);

Frames on the pipe are length-prefixed JSON (a 4-byte header plus payload).

UI side (ElevationBrokerClient)

  • Launch the helper via ProcessStartInfo with UseShellExecute=true, Verb="runas"
  • Generate a random pipe name and pass your own PID and SID to the helper
  • After the pipe connects, send a typed request

Helper side (AdminBroker)

  • Accept the pipe name, client PID, and client SID from the launch arguments
  • Build the pipe ACL explicitly (grant connect rights to the calling UI user’s SID)
  • After the connection comes in, verify the client PID
  • Dispatch only fixed operations using switch (request.Operation)

Example administrator operation

For something like registering an Explorer right-click menu in the registry:

  • Do not accept an arbitrary registry path from the UI
  • Do not accept an arbitrary command string from the UI
  • The target EXE for the registration is resolved on the helper side, hard-coded
  • The only thing the request carries is Enabled (a bool)

The helper then has exactly one meaning: “toggle the registration state of the Explorer right-click menu.”

Common anti-patterns

  1. Marking the whole UI as requireAdministrator — sloppily destroys the privilege boundary
  2. Sending a raw command string to the helper — turns the helper into a general-purpose execution endpoint
  3. Using the default ACL of the named pipe — set the ACL explicitly
  4. Reaching for CurrentUserOnly — not suitable for an unelevated UI talking to an elevated helper
  5. A helper that takes an arbitrary path and acts on it — always lock operations down

Summary

  • UI is asInvoker, helper is requireAdministrator
  • IPC is a named pipe; restrict the caller via the pipe ACL and the client PID
  • The helper only accepts fixed operations and re-validates the arguments
  • This design is also easy to migrate into a service later

References

Related Articles

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

Related Topics

These topic pages place the article in a broader service and decision context.

Where This Topic Connects

This article connects naturally to the following service pages.

Back to the Blog