Comment isoler concrètement, dans une application Windows, « uniquement les opérations nécessitant des privilèges administrateur »

· Mis à jour le: · · Développement Windows, Sécurité, UAC, C# / .NET, Win32

Dans un précédent article, « Checklist pour respecter le minimum de sécurité dans le développement d’applications Windows », nous avions posé la ligne suivante : partir d’asInvoker par défaut, et n’isoler que les traitements nécessitant des privilèges administrateur.

Cette fois, nous allons jusqu’à la façon concrète de l’écrire.

Dans une application Windows, il n’est pas possible de faire commodément « s’exécuter en administrateur » seulement une partie du même processus. L’élévation est une question de frontière de processus : ce qu’il faut, c’est une conception qui extrait ce seul traitement vers une unité d’exécution séparée.

Cet article avance dans l’ordre suivant.

  1. D’abord les prémisses
  2. Quel modèle d’isolation choisir
  3. La forme la plus pratique au quotidien : asInvoker + helper EXE administrateur
  4. Les pièges à ne pas manquer à l’implémentation
  5. Des exemples de code concrets

Les exemples de code présupposent une application de bureau .NET 8 / Windows. Le framework d’UI peut être WPF, WinForms ou WinUI indifféremment ; les différences se limitent essentiellement aux gestionnaires d’événements côté UI.

Le code présenté dans cet article est publié sur GitHub sous la forme d’un ensemble d’échantillons compilables et exécutables (bibliothèque de contrat partagée, démos UI / helper administrateur, et tests unitaires exécutables aussi sous Linux).

windows-admin-broker-deep-dive - komurasoft-blog-samples (GitHub)

1. La conclusion d’abord

Voici d’abord les points d’atterrissage pratiques.

  • L’application UI ordinaire continue de tourner en asInvoker
  • Les traitements nécessitant des privilèges administrateur sont extraits dans un EXE séparé
  • Ce helper EXE est configuré en requireAdministrator
  • Il est lancé via runas
  • Pour communiquer avec le helper, on utilise une IPC comme un tube nommé (named pipe), plutôt que l’entrée/sortie standard, qui s’entend mal avec runas
  • On ne transmet au helper que des requêtes typées, jamais de « chaîne de commande brute »
  • Côté helper, on revalide le contenu de la requête
  • L’origine de la connexion IPC est restreinte via le SID de l’utilisateur appelant et le PID attendu

« C’est plus facile si tout tourne en administrateur » n’est vrai que la première fois. Ensuite, l’UAC, le glisser-déposer, la conception des logs, les entrées externes, le support en exploitation, le chargement de DLL, l’emplacement de sauvegarde des paramètres : tout cela finit, à peu près systématiquement, par vous faire grise mine.

2. Poser la prémisse : impossible de rendre administrateur seulement une partie du même processus

L’UAC de Windows n’est pas contrôlée par une « élévation fonction par fonction », mais par le token / le niveau d’intégrité avec lequel le processus s’exécute. Les applications qui ont besoin d’un token d’accès administrateur sont soumises à l’invite d’élévation, et les processus enfants héritent du token de leur parent au même niveau d’intégrité. Autrement dit, exécuter soudainement une seule méthode avec des privilèges administrateur à l’intérieur d’un processus UI non élevé n’est pas une conception possible. Si c’est nécessaire, on utilise une autre unité d’exécution : un processus séparé, un service, une tâche, un objet COM élevé, etc.

Si l’on raisonne sans cette prémisse, on aboutit à une demande de conception un peu pathétique : « je voudrais que ça devienne administrateur juste le temps qu’on appuie sur ce bouton ». Windows ne comble pas cet écart par magie.

3. Quel modèle d’isolation choisir

Microsoft Learn répertorie principalement quatre façons d’isoler les applications qui ont besoin de privilèges administrateur.

Modèle Forme sommaire Cas d’usage adapté
Administrator Broker Model Application UI en utilisateur standard + helper EXE administrateur Les opérations administratives sont sporadiques ; il suffit d’afficher l’UAC au moment nécessaire
Operating System Service Model UI en utilisateur standard + service résident Fonctions administratives permanentes, surveillance en arrière-plan, traitements non supervisés
Elevated Task Model UI en utilisateur standard + tâche planifiée avec privilèges administrateur Travaux courts et standardisés qui se terminent à chaque fois
Administrator COM Object Model UI en utilisateur standard + COM élevé Il existe déjà une conception COM et les fonctionnalités sont assez limitées

Voici des repères pour choisir.

3.1 Le helper EXE est le premier candidat à envisager

Le helper EXE convient bien à des opérations comme celles-ci.

  • Enregistrement / désenregistrement de l’intégration avec l’Explorateur
  • Changements de configuration machine-wide sous HKLM
  • Enregistrement / désenregistrement du service de sa propre application
  • Ajout / suppression de règles de pare-feu
  • Opérations administrateur sous Program Files

Ces opérations tendent à être inutiles en usage normal, et nécessaires seulement quand on appuie sur un bouton précis de l’écran de paramètres. Dans ce cas, plutôt que de mobiliser un service résident, la forme où un helper EXE administrateur se lance une seule fois puis se termine est plus naturelle.

3.2 On choisit un service pour ce qui est « permanent », « non supervisé », « fréquent »

Le service est le modèle où l’application en utilisateur standard communique par RPC ou équivalent. L’avantage est de pouvoir recevoir un traitement administratif sans invite d’élévation — mais en contrepartie, la responsabilité d’exploiter un processus résident augmente.

Le service convient à des usages comme ceux-ci.

  • Surveillance permanente
  • Collecte de logs
  • Mises à jour en arrière-plan
  • Liaison permanente avec un équipement ou un démon
  • Fonctions administratives partagées entre plusieurs sessions UI

3.3 La tâche convient aux « traitements standardisés courts »

L’Elevated Task Model consiste à lancer, depuis l’application en utilisateur standard, une tâche planifiée qui s’exécute avec des privilèges administrateur. C’est plus léger qu’un service et cela se referme une fois terminé, ce qui convient aux jobs standardisés exécutés une fois à chaque occasion.

3.4 Le COM élevé est assez limité

Le COM elevation moniker a l’air pratique, mais son champ d’application est restreint. Microsoft Learn précise d’ailleurs que l’UI permettant de contrôler un COM élevé doit être présentée par le côté COM lui-même : cela ne convient pas à une approche où une UI non élevée ferait ce qu’elle veut d’un COM élevé.

4. La recommandation de cet article : UI asInvoker + helper EXE requireAdministrator

À partir d’ici, nous concrétisons la forme la plus utile en pratique.

[ MyApp.exe ]  asInvoker
      |
      |  ShellExecute / ProcessStartInfo + Verb=runas
      v
[ MyApp.AdminBroker.exe ]  requireAdministrator
      |
      |  named pipe
      v
[ Exécute uniquement les traitements fixes nécessitant des privilèges administrateur ]

Il y a trois points clés.

  1. Le processus UI reste non élevé jusqu’au bout
  2. Le helper administrateur a une vie courte
  3. Les opérations acceptées par le helper se limitent à une allowlist fixe

Le simple fait de respecter ces trois points assainit considérablement la conception.

5. Règles à ne pas manquer à l’implémentation

Voici des points qu’il vaut mieux trancher avant d’écrire le code.

5.1 Ne pas faire du helper un « couteau suisse »

Voici de mauvais exemples.

  • L’UI transmet au helper une commande reg add ... entière sous forme de chaîne
  • L’UI transmet au helper une commande sc.exe ... entière sous forme de chaîne
  • L’UI transmet au helper un chemin de registre arbitraire ou un chemin d’EXE arbitraire

Si vous faites cela, quand l’UI est compromise, le helper tombe avec elle. Le helper administrateur se trouve à l’intérieur de la frontière d’élévation. Y créer une « porte capable de tout exécuter » est plutôt dangereux.

La bonne forme ressemble à ceci.

  • set-explorer-context-menu
  • install-service
  • add-firewall-rule

On fixe ainsi les opérations elles-mêmes, et on limite les arguments nécessaires à des bool / enum / nombres / chaînes limitées.

5.2 Les chemins transmis au helper sont absolus, et l’UI ne doit pas trop décider

Le helper EXE lancé via runas est lui-même spécifié par un chemin absolu. On évite de s’en remettre à la recherche via PATH ou à des chemins relatifs.

De plus, ce que le helper exécute doit lui aussi, autant que possible, être résolu et fixé côté helper. Dans l’exemple de cet article, l’EXE cible enregistré dans le menu contextuel de l’Explorateur est fixé au MyApp.exe situé dans le même dossier que le helper.

5.3 Si vous utilisez Verb="runas", explicitez UseShellExecute=true

En .NET, ProcessStartInfo.Verb n’a d’effet que lorsque UseShellExecute=true. De plus, la valeur par défaut de UseShellExecute diffère entre .NET Framework et .NET Core / .NET. Laisser cela à la valeur par défaut provoque plus tard l’incident discrètement agaçant du « ça marche sur certains environnements, pas sur d’autres ».

C’est pourquoi il faut toujours l’expliciter.

5.4 runas et la redirection de l’entrée/sortie standard s’entendent mal

Avec UseShellExecute=true, une communication reposant sur la redirection de l’entrée/sortie standard devient difficile à utiliser. C’est pourquoi il est plus naturel d’utiliser un autre mécanisme d’IPC, comme un tube nommé (named pipe), pour les échanges avec le helper.

5.5 Ne pas s’appuyer sur l’ACL par défaut du tube nommé

Avec le descripteur de sécurité par défaut, un tube nommé accorde par défaut un droit de lecture à Everyone et aux comptes anonymes. Utiliser cela tel quel pour l’IPC d’un helper administrateur est plutôt négligé.

Il vaut mieux toujours définir un PipeSecurity explicite.

5.6 PipeOptions.CurrentUserOnly n’est pas utilisé dans ce contexte

Au premier regard, cela semble pratique. Mais sous Windows, CurrentUserOnly vérifie non seulement le compte utilisateur mais aussi le niveau d’élévation. Autrement dit, cela ne convient pas à la communication entre une UI non élevée et un helper élevé.

De plus, dans un environnement utilisateur standard, l’UAC devient une invite d’identification (credential prompt), et il arrive que le helper s’exécute sous un compte administrateur différent. Dans ce cas, si le helper construit son ACL directement à partir de WindowsIdentity.GetCurrent(), l’utilisateur UI d’origine peut se retrouver dans l’incapacité de se connecter.

C’est pourquoi, dans cet article, on adopte la forme suivante.

  • L’UI récupère son propre SID et le transmet au helper
  • Côté helper, on n’accorde le droit de connexion au tube qu’au SID de l’utilisateur UI
  • On vérifie de plus le PID de la connexion avec GetNamedPipeClientProcessId

5.7 La vérification du PID est une défense supplémentaire contre les « intrusions grossières »

Un nom de tube aléatoire aide déjà beaucoup, mais la possibilité qu’un autre processus tournant sous le même utilisateur se connecte en premier n’est pas nulle. C’est pourquoi, côté helper, on utilise GetNamedPipeClientProcessId pour vérifier que le PID correspond bien au PID du processus UI attendu.

Bien sûr, un PID correct ne signifie pas qu’on peut tout faire confiance. Si l’UI est compromise, des requêtes dangereuses parviendront aussi au helper. C’est précisément pour cela que l’allowlist d’opérations et la validation des arguments côté helper sont nécessaires.

6. Le scénario de l’exemple

Cet article prend l’exemple d’enregistrer / désenregistrer, à l’échelle de la machine, une entrée dans le menu contextuel (clic droit) de l’Explorateur.

Les raisons sont simples.

  • Cela nécessite des privilèges administrateur
  • La frontière de l’opération est claire
  • Il n’est pas nécessaire de transmettre au helper de chaîne de commande arbitraire
  • Cela se rencontre bel et bien en pratique

Les cibles d’enregistrement sont des clés fixes comme celles-ci.

  • HKLM\SOFTWARE\Classes\*\shell\MyApp.Open
  • HKLM\SOFTWARE\Classes\*\shell\MyApp.Open\command

L’UI ne porte qu’une case à cocher « Enregistrer dans le menu contextuel de l’Explorateur » ; les opérations de registre proprement dites se déroulent côté helper.

7. Structure de la solution

MyApp/
  MyApp/                         Application UI (asInvoker)
    app.manifest
    ElevationBrokerClient.cs
    SettingsPage.xaml.cs
  MyApp.AdminBroker/             Helper administrateur (requireAdministrator)
    app.manifest
    Program.cs
    BrokerLaunchOptions.cs
    ExplorerContextMenuRegistration.cs
  MyApp.BrokerProtocol/          Contrat partagé
    BrokerProtocol.cs

Garder le contrat partagé dans un projet séparé permet d’aligner facilement entre l’UI et le helper :

  • les noms d’opérations
  • les types de requête / réponse
  • le format des messages du tube

8. Manifestes

8.1 Côté UI (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 Côté helper (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>

L’UI reste tout du long en asInvoker. Seul le helper est en requireAdministrator. Inverser ces deux réglages fait disparaître l’intérêt même de la séparation.

9. Code du contrat partagé

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

L’essentiel est de ne pas laisser du JSON s’écouler tel quel dans le tube, mais de l’envoyer précédé de sa longueur. Garder un protocole simple — une requête, une réponse — réduit les risques d’accident.

10. Côté UI : lancement et communication avec le 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("L'approbation des privilèges administrateur a été annulée.", 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("\"", "\\\"") + "\"";
    }
}

Ce qui est transmis ici au helper se limite au nom du tube et au minimum d’informations nécessaires à la vérification de l’origine de la connexion. L’opération administrative proprement dite reste confinée à la requête typée envoyée à travers le tube. Ce QuoteArgument est une implémentation minimale qui présuppose les valeurs simples transmises dans cet exemple — un nom de tube, un PID, un SID. Si vous transmettez des chemins Windows arbitraires ou des chaînes libres comme arguments de ligne de commande, remplacez-le par un traitement d’échappement dédié conforme aux règles d’analyse de l’argv Windows.

11. Côté helper : analyse des arguments de lancement

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];
    }
}

Côté helper, on passe en erreur dès qu’il manque des arguments ou qu’il y en a en trop. À l’intérieur de la frontière d’élévation, « on interprète tant bien que mal » est une attitude à éviter.

12. Côté helper : création du tube, vérification du PID client, 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);
}

Voici ce qui fait le travail ici.

  • L’ACL du tube est assemblée explicitement
  • L’ACL est accordée non seulement au SID de l’utilisateur courant du helper, mais aussi au SID de l’utilisateur UI appelant
  • Après la connexion, le PID du client est vérifié
  • Même après réception de la requête, le dispatch se fait par nom d’opération

Garder la forme où switch (request.Operation) ne laisse passer qu’un ensemble fixe d’opérations réduit le risque que le helper devienne « une boîte élevée à tout faire ».

13. L’opération administrateur elle-même : enregistrement du menu contextuel de l’Explorateur

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

Le cœur de ce code réside dans ce qu’il ne reçoit pas de l’UI.

  • Il ne reçoit pas de l’UI de chemin de registre arbitraire
  • Il ne reçoit pas de l’UI de chaîne de commande arbitraire
  • L’EXE cible enregistré est résolu et fixé côté helper
  • Le contenu de la requête se limite à Enabled

Autrement dit, le helper est contraint de n’avoir qu’un seul sens : « basculer l’état d’enregistrement du menu contextuel de l’Explorateur ».

14. Appel depuis l’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;
        }
    }
}

Côté UI, c’est ordinaire.

  • Lire l’état de la case à cocher
  • Appeler le broker client
  • Rétablir l’UI en cas d’échec

C’est tout. On ne touche pas directement au registre. C’est cela, l’isolation.

15. Ce que cette implémentation respecte

Voici les lignes que cet exemple défend réellement.

15.1 Séparation des responsabilités entre l’UI et le helper

  • L’UI se contente de recevoir les actions de l’utilisateur
  • Le helper n’exécute que des opérations administrateur fixes

15.2 Aucune « porte d’exécution arbitraire » créée dans le helper

  • Il n’accepte aucun chemin de registre arbitraire
  • Il n’accepte aucune ligne de commande arbitraire
  • Il n’accepte aucun chemin d’EXE arbitraire

15.3 Le chemin de lancement est fixe

  • Le helper EXE est un chemin absolu
  • runas est explicite
  • UseShellExecute = true est explicite

15.4 L’origine de la connexion IPC est restreinte

  • L’ACL du tube est limitée au SID de l’utilisateur UI
  • Le PID du client est vérifié après la connexion

15.5 Les cibles de l’opération administrateur sont elles aussi fixes

  • La ruche / le chemin de registre sont fixes
  • L’EXE cible enregistré est lui aussi résolu de façon fixe

En allant jusque-là, on s’éloigne considérablement de l’état où « si l’UI est compromise, on peut tout faire via le helper ».

16. Anti-patterns courants

16.1 Passer l’UI entière en requireAdministrator

Un seul bouton de l’écran de paramètres nécessite des privilèges administrateur, mais tout se lance en élevé. Cela écrase la frontière de privilèges de façon négligée.

16.2 Transmettre au helper des commandes sous forme de chaîne brute

Prenons par exemple une conception comme celle-ci.

UI -> le helper reçoit "reg add HKLM\\.... /v ... /d ..."

Cela transforme le helper en exécuteur de commandes. Mieux vaut éviter.

16.3 Garder tel quel l’ACL par défaut du tube nommé

« C’est de l’IPC locale, donc ça doit aller » est un peu dangereux. Les tubes sont soumis à la sécurité Windows, il vaut donc mieux construire une véritable ACL.

16.4 Sauter sur CurrentUserOnly

Cela semble pratique, mais ne convient pas au cas de cet article : une UI en integrity moyenne dialoguant avec un helper en integrity haute. Une ACL explicite est ici plus facile à manier.

16.5 Le helper acceptant un chemin arbitraire à manipuler

Par exemple des choses comme celles-ci.

  • Copier un fichier arbitraire dans Program Files
  • Écrire une clé arbitraire dans HKLM
  • Supprimer un service arbitraire par son nom
  • Ajouter une règle de pare-feu à partir d’une commande arbitraire

Si le helper accepte cela, il devient lui-même une porte d’exécution générale avec des privilèges administrateur. Les opérations doivent toujours être fixées.

17. Résumé

« Seule une partie du traitement nécessite des privilèges administrateur » n’a rien d’exceptionnel dans les applications Windows. Mais la solution n’est pas de « tout passer en requireAdministrator » : c’est de découper une frontière d’exécution.

La forme la plus facile à adopter en premier est celle-ci.

  • L’UI est en asInvoker
  • Le traitement administrateur est isolé dans un helper EXE
  • Le helper est en requireAdministrator
  • Le lancement se fait via runas
  • La communication passe par un tube nommé
  • Le helper n’accepte que des opérations fixes
  • L’origine de la connexion est restreinte via l’ACL du tube et le PID du client
  • Le helper revalide les arguments côté serveur

Avec cette forme en place, migrer plus tard vers un service devient également plus facile. Si le contrat d’opération est proprement séparé, la frontière entre l’UI et le traitement administrateur devient elle-même un actif de conception.

En matière de sécurité, ne pas laisser traîner de frontières négligées est plus efficace qu’ajouter des fonctionnalités tape-à-l’œil. Il en va de même pour les privilèges administrateur. Ne pas les confier en bloc, mais les accorder uniquement là où c’est nécessaire, de la façon la plus étroite possible. Ce genre de discrétion sans éclat finit par payer plus tard.

18. Références

  • L’ensemble du code d’exemple de cet article (bibliothèque de contrat partagée, démos, tests unitaires) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
  • Article d’origine : Checklist pour respecter le minimum de sécurité dans le développement d’applications Windows https://comcomponent.com/fr/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

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Peut-on exécuter seulement une partie du traitement d'un même processus avec des privilèges administrateur ?
Non, ce n'est pas possible. L'UAC de Windows ne fonctionne pas par élévation fonction par fonction : elle est contrôlée par le token et le niveau d'intégrité avec lesquels le processus s'exécute. Les processus enfants héritent du token de leur parent au même niveau d'intégrité, il est donc impossible de concevoir un système où une seule méthode s'exécuterait avec des privilèges administrateur à l'intérieur d'un processus UI non élevé. Le traitement nécessaire doit être extrait vers une autre unité d'exécution : un processus séparé, un service, une tâche planifiée, ou un objet COM élevé.
Quelles sont les options pour isoler les traitements nécessitant des privilèges administrateur ?
Microsoft Learn distingue principalement quatre modèles : l'Administrator Broker Model, qui combine une UI en utilisateur standard avec un helper EXE administrateur ; l'Operating System Service Model, qui s'appuie sur un service résident ; l'Elevated Task Model, qui utilise une tâche planifiée avec des privilèges administrateur ; et l'Administrator COM Object Model, qui repose sur un objet COM élevé. Un helper EXE convient lorsque les opérations administratives sont sporadiques et qu'il suffit d'afficher l'UAC au moment nécessaire ; un service convient lorsqu'elles sont permanentes, non supervisées ou fréquentes ; une tâche planifiée convient aux traitements standardisés, courts et exécutés en une seule fois.
Peut-on utiliser l'entrée/sortie standard pour communiquer avec un helper EXE lancé via runas ?
Mieux vaut l'éviter, car c'est difficile à exploiter. En .NET, ProcessStartInfo.Verb n'est actif que lorsque UseShellExecute=true, et dès que UseShellExecute=true est activé, une communication reposant sur la redirection de l'entrée/sortie standard devient inutilisable. Il est donc plus naturel d'utiliser une IPC comme un tube nommé (named pipe) pour dialoguer avec le helper. Le tube ne doit pas s'appuyer sur l'ACL par défaut : il faut définir un PipeSecurity explicite, limiter le droit de connexion au SID de l'utilisateur appelant, puis vérifier également le PID de la connexion avec GetNamedPipeClientProcessId.
Utiliser PipeOptions.CurrentUserOnly sur un tube nommé n'est-il pas suffisamment sûr ?
Ce n'est pas adapté à la communication entre une UI non élevée et un helper élevé. Sous Windows, CurrentUserOnly vérifie non seulement le compte utilisateur mais aussi le niveau d'élévation, ce qui empêche la connexion entre des processus de niveaux d'intégrité différents. De plus, dans un environnement utilisateur standard, l'UAC peut devenir une invite d'identification (credential prompt), et le helper peut alors s'exécuter sous un autre compte administrateur. Il est plus simple de faire récupérer son propre SID par l'UI, de le transmettre au helper, puis de définir côté helper une ACL explicite qui n'accorde le droit de connexion au tube qu'à ce SID.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog