How to Concretely Isolate "Only the Operations That Need Administrator Privileges" in a Windows App

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

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614507)
First published
Cite this article(DOI: 10.5281/zenodo.21614506)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). How to Concretely Isolate "Only the Operations That Need Administrator Privileges" in a Windows App. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614506 https://comcomponent.com/en/blog/2026/03/16/001-windows-admin-broker-deep-dive/

DOI (latest version)
10.5281/zenodo.21614506
DOI (this version)
10.5281/zenodo.22217149

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.

Elevation is a matter of process boundariesDiagram showing that you cannot run only part of the work inside the same process as administrator, and that because elevation is a matter of process boundaries the design has to carve that work out into a separate execution unit.Impossible within one processThis is what you can doWant only part of the work to run as administratorElevate inside the same processCarve the work into a separate execution unit

Figure 1: Elevation is per process, not per function, so only a design that carves the work out can work.

This article proceeds in this order.

  1. The premises first
  2. Which isolation model to choose
  3. The most practical shape: asInvoker + an administrator helper EXE
  4. Traps you do not want to miss during implementation
  5. 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)

How to Read This Article

This is a long article, so here is a map up front.

What you want to know Where to read
Only how to choose an isolation model Sections 1-4 (the conclusion, a comparison of the four models, the recommended shape)
The design decisions and the reasoning behind them Section 5 (allowlist, fixed paths, runas, the pipe ACL, PID verification)
The implementation code Sections 7-14 (structure, manifests, the shared contract, the UI side, the helper side)
How to confirm the split actually worked 15.6
Shapes to avoid Section 16

The full code is also in the GitHub sample, identical to what is here. If you only want the design discussion, read sections 1-5 and 15-16; if you want the implementation as well, read straight through.

What You Need Before Trying It

To actually run this sample you need the following.

  • A Windows machine (the UAC elevation prompt, the explicit ACL via PipeSecurity, GetNamedPipeClientProcessId, and writing to HKLM are all Windows-only)
  • .NET 8 SDK or later
  • An account that can approve elevation. An administrator account gets a consent prompt; a standard user gets a credential prompt. If you want to exercise both paths, prepare both kinds of account
  • A machine where rewriting HKLM is acceptable. The sample creates HKLM\SOFTWARE\Classes\*\shell\MyApp.Open machine-wide. Trying it on an evaluation VM is safer than on your daily development machine

The exact build and run commands are collected in the sample’s README. Just keep one thing in mind up front: publish the UI and the helper into the same folder before running them (the helper resolves the MyApp.exe in its own folder as a fixed path).

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.

The skeleton of the practical landing pointDiagram showing the skeleton in which the UI keeps running as asInvoker, work that needs administrator privileges is carved into a separate requireAdministrator EXE launched with runas, and only typed requests travel over a named pipe.Launched with runasTyped request over a named pipeThe UI stays asInvokerhelper EXE (requireAdministrator)The helper re-validates the request

Figure 2: The skeleton is set by three things: a non-elevated UI, an elevated helper, and IPC that carries typed requests.

Knowledge map for this article

This article covers a concrete implementation that isolates only the operations that need administrator rights in a Windows app. UAC is controlled by the integrity level of a process, and a parent and child process inherit the token at the same level, so only part of a single process cannot be elevated; the basic shape is therefore the Administrator Broker Model, which combines a standard-user UI with an administrator-privileged helper EXE. The helper is launched with runas, the UI and the helper communicate over a named pipe with an explicit PipeSecurity instead of relying on the default ACL, and verifying the PID of the connecting process along with an allowlist on the helper side that accepts only fixed operations closes off any opening for arbitrary command execution. PipeOptions.CurrentUserOnly cannot be used for this purpose because it also checks for a difference in integrity level.

Isolating administrator-privileged operationsDiagram showing why, under the constraint of UAC integrity levels, the Administrator Broker Model connects a standard-user UI to an administrator helper EXE over a named pipe and guards the boundary with an ACL, PID verification, and an operation allowlist, together with how it is chosen over the other isolation modelsusesusesrequiresrequiresusesusesconfigured bymay causemitigatesmitigatesusesusespreventsrecommended forrecommended forrecommended forrecommended forincompatible withrequiresrequiresrequiresAdministrator Broker ModelUAC (User Account Control)Integrity LevelAdministrator PrivilegesrequestedExecutionLevel (Manifest)runas Verb (ShellExecute)Named PipeExplicit ACL via PipeSecurityUnauthorized Named Pipe AccessClient Process ID VerificationHelper Operation AllowlistArbitrary Command Execution Entry PointInfrequent Administrator OperationOperating System Service ModelContinuous unattended admin operationsElevated Task ModelShort Routine Administrative JobAdministrator COM Object ModelExisting COM-Based IntegrationPipeOptions.CurrentUserOnly

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (21 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

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.

UAC is controlled by token and integrity levelDiagram showing that UAC is controlled not by per-function elevation but by which token and integrity level a process runs with, that parent and child processes inherit the same integrity level, and that per-method elevation is therefore impossible and a separate execution unit is required.What UAC controlsThe process token and integrity levelParent and child inherit the same levelPer-method elevation is impossibleSeparate process, service, task, or elevated COM

Figure 3: Because the unit of control is the process, work that needs elevation has to live in a separate execution unit.

2.1 Fix the Meaning of Integrity Levels First

From here on, the terms medium integrity / high integrity keep coming up. Let us pin down what they map to.

Integrity level What it means in this article Example
medium A process running as a standard user An asInvoker UI app
high An elevated process A requireAdministrator helper EXE

Windows Mandatory Integrity Control defines four levels - low / medium / high / system - and a standard user receives medium while an elevated user receives high. So the design in this article is about drawing an explicit line between a medium UI process and a high helper process, and letting them talk across it.

Once that mapping is in your head, the CurrentUserOnly discussion in 5.6 and section 16.4 both read straightforwardly.

Where the line between medium and high fallsDiagram showing that an asInvoker UI running as a standard user receives medium integrity while an elevated requireAdministrator helper receives high integrity, and that this article is about drawing an explicit line between the two and letting them talk.Draw a line and let them talkmedium: the asInvoker UI processhigh: the elevated helper processStandard user tokenElevated user token

Figure 4: This design is about drawing an explicit boundary between the medium UI and the high helper.

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.

Where a broker EXE fitsDiagram showing that when an administrative operation is unnecessary in normal use and needed only when a specific button on the settings screen is pressed, launching an administrator helper EXE once and letting it exit is more natural than bringing in a resident service.Not needed at this frequencyPress a specific button on the settings screenLaunch the helper EXE onceThe operation finishes and the helper is goneBring in a resident service

Figure 5: For sporadic administrative operations, a helper EXE that lives only for the moment it is needed is the most natural fit.

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.

The trade-off of the service modelDiagram showing the trade-off in which the service model can accept administrative work without an elevation prompt but in exchange adds the responsibility of operating a resident process.AdvantageCostOperating System Service ModelAccepts the work without an elevation promptThe responsibility of operating a resident processFits always-on, unattended, frequent uses

Figure 6: A service trades the absence of a prompt for the burden of running a resident process.

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

How narrow the use of elevated COM isDiagram showing that elevated COM looks handy but the UI that controls it has to be presented by the COM side, so it is not suited to letting a non-elevated UI drive elevated COM freely.Not suited to this directionElevated COM looks handyA non-elevated UI drives it freelyThe applicable scope is narrowThe controlling UI is presented by the COM side

Figure 7: Elevated COM assumes the COM side owns the control UI, so it is not a general-purpose escape hatch.

4. This Article’s Recommendation: asInvoker UI + requireAdministrator Helper EXE

From here, we make the most practical shape concrete.

high integrity ── short-lived elevated processmedium integrity ── never elevatedLaunch by absolute path with Verb=runas(the UAC prompt appears here)Typed request(no raw command strings)MyApp.AdminBroker.exe(requireAdministrator)Named pipe endpointOnly the SID of the UI user may connectThe connecting PID is checked as wellDispatch through the operation allowlistArguments are validated again on the helper sideMyApp.exe(asInvoker)Receives the user actions and only assembles requestsFixed targets that need administrator privilegesKeys under HKLM / service registration / firewall rules

Figure 8: Make the elevation boundary coincide with the process boundary. The UI stays at medium; only the helper runs at high, and only briefly

There are three key points.

  1. The UI process stays non-elevated to the end
  2. The administrator helper is short-lived
  3. The helper accepts only a fixed allowlist of operations

Just holding to these three cleans up the design considerably.

The three points to hold toDiagram showing that just holding to three points - the UI process stays non-elevated to the end, the administrator helper is short-lived, and the helper accepts only a fixed allowlist of operations - cleans up the design considerably.The UI stays non-elevated to the endThe design gets considerably cleanerThe helper is short-livedOnly allowlisted operations are accepted

Figure 9: Non-elevated, short-lived, allowlisted - hold those three and the shape of the privilege boundary settles.

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-menu
  • install-service
  • add-firewall-rule

Fix the operations themselves, and keep the required arguments to bool / enum / numbers / constrained strings.

The shape that keeps the helper from being a do-anything boxDiagram showing that passing raw command strings or arbitrary paths to the helper opens a can-run-anything hole so that a compromised UI takes the helper with it, and that the fix is to fix the operations themselves and constrain arguments to limited types.Pass a raw command stringA can-run-anything opening appearsIf the UI falls, the helper falls tooFix the operations and constrain the argument typesThe meaning of the helper narrows

Figure 10: Only fixed operations and constrained arguments should cross into the elevation boundary.

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 hit the quietly infuriating failure mode of “works in some environments and not in others.”

So always set it explicitly.

What to set explicitly for a runas launchDiagram showing that ProcessStartInfo.Verb only takes effect when UseShellExecute is true, that its default differs between .NET Framework and .NET, and that leaving it to the default makes the app work in some environments and not in others, so it must always be set explicitly.Leave it alone andSoYou want to use Verb=runasUseShellExecute=true is requiredThe default differs between Framework and .NETIt works in some environments and not in othersAlways set it explicitly

Figure 11: Because Verb has a precondition and the default differs, pin UseShellExecute down 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.

On top of that, who connects to the pipe, and with which token, changes with the kind of UAC prompt. Pinning that down in a table makes it easier to follow why an explicit ACL is needed.

Account the UI runs as UAC prompt shown Account the helper runs as WindowsIdentity.GetCurrent() of the helper that creates the pipe SID of the connecting UI
Administrator account (not elevated) Consent prompt (just press Yes) The elevated token of the same user The same user as the UI The UI user
Standard user Credential prompt (enter another account’s credentials) The other administrator account that was entered A different user from the UI The UI user

Read it like this.

  • In the top row, “the helper’s current user = the UI user,” so an ACL built from the helper’s own SID happens to work
  • In the bottom row, the helper’s current user and the UI user are different people. Build the ACL from WindowsIdentity.GetCurrent() alone here and the original UI user can no longer send its own request
  • In both rows, CurrentUserOnly is rejected by the elevation-level gap between the “medium UI” and the “high helper”

In other words, the only shape that works in both rows is “receive the SID from the UI side and grant connection rights to that SID.”

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
Passing the SID makes both paths workDiagram showing the only shape that works for both the consent prompt and the credential prompt, where the UI obtains its own SID and passes it to the helper, the helper grants pipe connection rights to that SID alone, and the connecting PID is checked as well.Rejected by the elevation-level gapThe UI obtains its own SID and passes it onThe helper grants access to that SID onlyThe connecting PID is checked as wellUse CurrentUserOnlyDoes not work for this case

Figure 12: The only shape that survives both prompt paths is an ACL built from the SID the UI hands over.

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.

Layered extra defensesDiagram showing the idea of stacking layers - a random pipe name, an ACL limited to one SID, a check of the connecting PID, and an operation allowlist with argument validation - to reduce both crude queue-jumping and dangerous requests.Random pipe nameACL limited to one SIDMatch the connecting PIDAllowlist and argument re-validationA matching PID does not make the request trustworthy

Figure 13: No single layer is complete, so stack layers on both the connection and the request.

Laid out in order from launch to exit, the rules so far look like this. It is enough if you can see that every move on the UI side is paired with a check on the helper side.

Target that needs administrator privilegesAdminBroker.exe(high)Windows / UACMyApp.exe(medium)Target that needs administrator privilegesAdminBroker.exe(high)Windows / UACMyApp.exe(medium)Pick a pipe name and prepare its own SID and PIDLaunch by absolute path with Verb=runasOn approval, start it with the elevated tokenCreate the pipe with an ACL that allows only that SIDConnect to the pipeMatch the connecting PIDTyped request (operation name and arguments)Reject non-allowlisted operations and unexpected argumentsOperate only on the fixed targetReturn the resultExit so that no elevated state is left behind

Figure 14: Launch, connection, and request each have a matching check on the helper side. Drop any one of them and that step becomes a straight pass-through

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.Open
  • HKLM\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 harder to get wrong.

A simple length-prefixed protocolDiagram showing that instead of streaming JSON loosely down the pipe you send it with a length header, and that a protocol of exactly one request and one response is less error-prone.Write the length headerWrite the JSON bodyThe peer reads exactly that many bytesThe simplicity of one request and one response pays off

Figure 15: Send messages length-prefixed and keep it to one request and one response, and fewer things go wrong.

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.

Division of roles between launch arguments and the pipeDiagram showing the division of roles in which the helper launch arguments carry only the pipe name and the minimum information needed to verify the connecting party, while the administrator operation itself is confined to the typed request sent through the pipe.CarryConfineLaunch argumentsThe pipe name, PID, and SID and nothing moreInside the pipeThe administrator operation as a typed requestThe operation itself never rides on the arguments

Figure 16: Arguments handle only the setup of the connection; the substance of the operation stays inside the pipe as a typed request. 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.”

The order of the checks that do the work on the helper sideDiagram showing the order of the checks that do the work on the helper side - create the pipe with an explicit ACL, verify the client PID after connection, and dispatch the request by operation name so that only fixed operations get through.In the allowlistNot in the allowlistCreate the pipe with an explicit ACLVerify the connecting PIDDispatch by operation nameRun only the fixed operationReject and respond

Figure 17: Only a request that clears all three stages - ACL, PID, dispatch - reaches the fixed operation.

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 Enabled only

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
  • runas is explicit
  • UseShellExecute = true is 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.”

15.6 Confirm That the Split Actually Worked

Everything so far has been design. Whether what you wrote really is separated cannot be known without running it. “The whole UI had quietly become elevated” is the kind of breakage that is hard to spot by reading the code.

Four stages for confirming that the split workedDiagram showing the flow of checking four things in order - whether the UI process stays non-elevated, whether the UAC prompt appears only when the helper starts, whether the administrator operation actually took effect, and whether it fails when it should fail.Does the UI stay non-elevatedDoes the prompt appear only when the helper startsDid the operation actually take effectDoes it fail when it should failSkip the fourth and it is not a check of isolation at all

Figure 18: Running through the four stages in order catches elevation leaks the code alone will not reveal.

Check these four things, in this order.

1. Does the UI Process Stay Non-Elevated

This is the most important point. Start the UI and check after running an administrator operation once.

  • Task Manager: on the Details tab, right-click the column headers and show the Elevated column. MyApp.exe showing No and only MyApp.AdminBroker.exe showing Yes is what you want
  • Process Explorer: show the Integrity column. The UI at Medium and the helper at High is correct (Windows integrity levels map as in 2.1: standard user = medium, elevated = high)

If you would rather see it from code, checking once right after the UI starts is enough.

using System.Security.Principal;

using WindowsIdentity identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);

// Should be false in the UI process
bool isElevatedAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator);

2. Does the UAC Prompt Appear Only When the Helper Starts

  • A prompt appears when the UI starts -> the UI-side manifest is not asInvoker
  • It appears the moment you click the settings checkbox -> that is what you want
  • The setting changes although no prompt ever appears -> the helper may be permanently elevated through some other path

An administrator account gets a consent prompt; a standard user gets a credential prompt (the table in 5.6). Trying both also confirms that the SID hand-off is correct.

3. Did the Administrator Operation Actually Take Effect

For the Explorer menu registration, looking directly at the registry is the fastest way.

reg query "HKLM\SOFTWARE\Classes\*\shell\MyApp.Open" /s

Check the unregister side the same way. Test only the registration and never the removal, and a bug stays behind on the DeleteSubKeyTree side.

4. Does It “Fail When It Should Fail”

Without checking this, you cannot tell whether the split really holds.

  • Cancel the elevation prompt -> the setting must not change, and the UI checkbox must roll back (the handling of ERROR_CANCELLED = 1223; section 10)
  • Launch the helper directly -> even if you type MyApp.AdminBroker.exe --pipe x --client-pid 1 --client-sid S-1-5-18 by hand, the connecting-PID check and the timeout must keep it from getting anywhere
  • Send an operation that is not in the allowlist -> it must be rejected with unsupported_operation (DispatchAsync in section 12)

The concrete commands for these steps are collected in the same order in the sample’s README section “Verification steps on Windows”.

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.

The boundary becomes a design assetDiagram showing that keeping the operation contract cleanly separated turns the boundary between the UI and the administrator work into a design asset in itself, which also makes a later move to a service easier.Keep the operation contract separateA clear boundary between UI and administrator workThe boundary itself becomes a design assetMoving to a service later is easier too

Figure 19: A boundary cut with the broker pattern stays a usable asset when you later move to a service.

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

Note that some of the links below carry a version qualifier such as view=net-10.0 in the URL. That only tells Microsoft Learn which .NET version of the documentation to display; it does not mean the page conflicts with .NET 8, which this article assumes. The PipeOptions / NamedPipeServerStreamAcl / RegistryView members used here are all available in .NET 8. If you want the display to match .NET 8, switch versions with the selector at the top of the page.

  • 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
  • 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
  • Mandatory Integrity Control - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control
  • Process Explorer - Sysinternals https://learn.microsoft.com/en-us/sysinternals/downloads/process-explorer
  • WindowsPrincipal.IsInRole Method https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsprincipal.isinrole
  • 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

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

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

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.

Frequently Asked Questions

Common questions about the topic of this article.

Can I run only part of the work inside the same process with administrator privileges?
No. Windows UAC is not per-function elevation; it is controlled by which token and integrity level a process runs with. Parent and child processes inherit tokens at the same integrity level, so a design that runs one particular method with administrator privileges inside a non-elevated UI process is impossible. Work that needs elevation has to be carved out into a different execution unit: a separate process, a service, a scheduled task, or elevated COM.
What options are there for isolating work that needs administrator privileges?
Microsoft Learn lists mainly four models: the Administrator Broker Model, which combines a standard-user UI with an administrator helper EXE; the Operating System Service Model, which uses a resident service; the Elevated Task Model, which uses a scheduled task with administrator privileges; and the Administrator COM Object Model, which uses elevated COM. A broker EXE suits cases where administrative operations are sporadic and it is fine to show UAC only at the moment it is needed, a service suits always-on, unattended, or frequent work, and a task suits short fixed-form jobs that finish in one shot.
Can I use standard input/output to talk to a helper EXE launched with runas?
It is awkward, so it is better avoided. In .NET, ProcessStartInfo.Verb only takes effect when UseShellExecute=true, and with UseShellExecute=true, communication that assumes standard input/output redirection stops working. That is why IPC such as a named pipe is the natural choice for the exchange with the helper. Do not rely on the pipe's default ACL: set an explicit PipeSecurity that limits connection rights to the calling user's SID, and also verify the connecting PID with GetNamedPipeClientProcessId.
Isn't PipeOptions.CurrentUserOnly enough to make a named pipe safe?
It is not suited to communication between a non-elevated UI and an elevated helper. On Windows, CurrentUserOnly checks not just the user account but also the elevation level, so processes at different integrity levels cannot connect. On top of that, in a standard-user environment UAC becomes a credential prompt and the helper may run as a different administrator account. It is easier to have the UI obtain its own SID and pass it to the helper, and have the helper use an explicit ACL that grants pipe connection rights to that SID alone.

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.

Back to the Blog