Come isolare concretamente "solo le operazioni che necessitano dei privilegi di amministratore" in un'app Windows
· Aggiornato il: · Go Komura · Sviluppo Windows, Sicurezza, UAC, C# / .NET, Win32
Nel post precedente “Una checklist di sicurezza minima per Windows Application Development”, abbiamo tracciato la linea: default su asInvoker e isolato solo le operazioni che richiedono privilegi di amministratore.
Questa volta andremo fino in fondo a come scrivere effettivamente quella parte.
In un’app Windows, non è possibile eseguire comodamente solo una parte dello stesso processo “come amministratore”. L’elevazione è una questione di confine del processo, quindi ciò di cui hai bisogno è una progettazione che ritagli proprio quell’operazione in un’unità di esecuzione separata.
Questo articolo procede in questo ordine.
- Innanzitutto le premesse
- Quale modello di isolamento scegliere
- La forma più pratica:
asInvoker+ un aiutante amministratore EXE - Trappole da non perdere durante l’implementazione
- Esempi concreti di codice
Gli esempi di codice presuppongono .NET 8 / Windows app desktop. Il framework dell’UI può essere uno qualsiasi tra WPF / WinForms / WinUI; le differenze sono limitate ai gestori di eventi lato UI.
Il codice visualizzato in questo articolo è pubblicato su GitHub come set di esempi completo compilabile ed eseguibile (una libreria di contratti condivisa, demo di supporto per UI / amministratore e test unitari eseguibili anche su Linux).
windows-admin-broker-deep-dive - komurasoft-blog-samples (GitHub)
Prima di provare
Per eseguire effettivamente questo esempio, è necessario quanto segue.
- Una macchina Windows (il prompt di elevazione UAC, l’ACL esplicito tramite
PipeSecurity,GetNamedPipeClientProcessIde la scrittura in HKLM sono tutti specifici di Windows) - .NET 8 SDK o successivo
- Un account che possa approvare l’elevazione. Con un account amministratore si ottiene il consent prompt; con un utente standard, il credential prompt. Se si vogliono verificare entrambi i percorsi, preparare entrambi gli account
- Una macchina sulla quale si possa scrivere in HKLM. L’esempio crea
HKLM\SOFTWARE\Classes\*\shell\MyApp.Opena livello di macchina. È più sicuro provare su una VM di valutazione piuttosto che sulla macchina di sviluppo quotidiana
I comandi specifici per la compilazione e l’esecuzione sono riassunti nel README del set di esempi su GitHub. Un punto da tenere presente fin da subito: UI e helper devono essere pubblicati nella stessa cartella prima di essere eseguiti (l’helper risolve in modo fisso il percorso MyApp.exe nella propria cartella).
1. Prima la conclusione
I pratici punti di atterraggio, davanti.
- Mantieni la normale app dell’UI in esecuzione come
asInvoker - Suddividere le operazioni che richiedono privilegi di amministratore in un EXE separato
- Crea quell’aiutante EXE
requireAdministrator - Avvialo con
runas - Per la comunicazione con l’helper, utilizzare IPC come named pipe — non input / output standard, che non funziona bene con
runas - Passa all’helper solo richieste digitate, mai “stringhe di comando grezze”
- Dal lato dell’assistente, convalidare nuovamente il contenuto della richiesta
- Limita chi può connettersi tramite IPC utilizzando il SID dell’utente chiamante e il PID previsto
“Eseguire come amministratore è più semplice” è vero solo la prima volta. Successivamente, UAC, trascina e rilascia, progettazione del registro, input esterno, operazioni di supporto, caricamento di DLL e impostazioni delle posizioni di storage iniziano tutti a darti occhiatacce.
2. Impostazione della premessa: non è possibile far parte dello stesso processo come amministratore
Windows UAC non è controllato dall’“elevazione per funzione” ma da quale token / livello di integrità viene eseguito il processo. Le app che necessitano di un token di accesso amministratore sono soggette alla richiesta di elevazione e i processi padre e figlio ereditano i token allo stesso livello di integrità. In altre parole, la progettazione di esecuzione improvvisa di un particolare metodo con privilegi di amministratore all’interno di un processo di UI non elevata non è possibile. Se ne hai bisogno, utilizzi un’unità di esecuzione diversa: un processo separato, un servizio, un’attività, COM elevato e così via.
Se ci pensi senza questa premessa, ti ritroverai con la richiesta di progettazione un po’ pietosa: “Voglio che diventi amministratore solo nel momento in cui si preme questo pulsante”. Windows non colma questa lacuna con la magia.
2.1 Fissare prima la corrispondenza dei livelli di integrità
Nel resto di questo articolo useremo l’espressione medium integrity / high integrity. Definiamone subito la corrispondenza.
| Livello di integrità | Significato in questo articolo | Esempio |
|---|---|---|
| medium | Processo che gira come utente standard | App UI con asInvoker |
| high | Processo elevato | Helper EXE con requireAdministrator |
Nel Mandatory Integrity Control di Windows sono definiti quattro livelli (low / medium / high / system) e un utente standard riceve medium, mentre un utente elevato riceve high. La progettazione di questo articolo consiste quindi nel far comunicare, con un confine esplicito, un processo UI a medium integrity e un processo helper a high integrity.
3. Quale modello di isolamento scegliere
Microsoft Learn elenca principalmente i seguenti quattro modi per isolare le app che necessitano di privilegi di amministratore.
| Modello | Forma ruvida | Buona vestibilità |
|---|---|---|
| Administrator Broker Model | App UI per utente standard + assistente amministratore EXE | Le operazioni amministrative sono sporadiche; mostrare UAC solo al momento necessario va bene |
| Modello di servizio del sistema operativo | UI standard + servizio residente | Funzioni amministrative sempre attive, monitoraggio in background, elaborazione automatica |
| Modello di compito elevato | UI standard + un’attività pianificata con privilegi di amministratore | Lavori brevi, a modulo fisso, che terminano ogni volta |
| Amministratore COM Modello a oggetti | UI standard + COM elevato | Esiste un progetto COM esistente e la funzionalità è piuttosto limitata |
Guida approssimativa per la scelta:
3.1 Il broker EXE è il primo candidato più semplice
Un broker EXE si adatta a operazioni come queste:
- Registrazione / annullamento della registrazione dell’integrazione Explorer
- Modifiche alla configurazione a livello di macchina in HKLM
- Registrazione / annullamento della registrazione del servizio dell’app
- Aggiunta / rimozione di regole firewall
- Operazioni dell’amministratore in Programmi
Questi tendono ad essere non necessari nell’uso normale e necessari solo quando viene premuto un pulsante specifico nella schermata delle impostazioni. In tal caso, invece di ricorrere a un servizio residente, la forma in cui un assistente amministratore EXE si avvia una volta ed esce è più naturale.
3.2 Scegli un servizio per “Sempre attivo”, “Non presidiato”, “Frequente”
Un servizio è il modello in cui l’app utente standard comunica tramite RPC e simili. Il vantaggio è ricevere lavoro amministrativo senza richiesta di elevazione, ma in cambio aumenta la responsabilità di gestire un processo residente.
Un servizio si adatta a usi come questi:
- Monitoraggio continuo
- Raccolta dei registri
- Aggiornamenti in background
- Integrazione sempre attiva con dispositivi o demoni
- Funzioni amministrative condivise da più sessioni dell’UI
3.3 Un compito è adatto al “lavoro breve e in forma fissa”
Il modello di attività elevata avvia un’attività pianificata che viene eseguita con privilegi di amministratore dall’app utente standard. È più leggero di un servizio e si chiude una volta terminato, quindi è adatto a lavori a formato fisso one-shot.
3.4 COM elevato è piuttosto limitato
Il soprannome di elevazione COM sembra utile, ma il suo ambito applicabile è ristretto. Microsoft Learn afferma inoltre che l’UI che controlla COM elevato deve essere presentata dal lato COM, quindi non è adatta a “lasciare che un’UI non elevata faccia quello che vuole con COM elevato.”
4. Raccomandazione di questo articolo: asInvoker UI + requireAdministrator Helper EXE
Da qui, realizziamo la forma più pratica in cemento.
[ MyApp.exe ] asInvoker
|
| ShellExecute / ProcessStartInfo + Verb=runas
v
[ MyApp.AdminBroker.exe ] requireAdministrator
|
| named pipe
v
[ Executes only fixed operations that need administrator privileges ]
Ci sono tre punti chiave.
- Il processo dell’UI rimane non elevato fino alla fine
- L’assistente amministratore ha vita breve
- L’aiutante accetta solo una lista consentita fissa di operazioni
Il solo fatto di attenersi a questi tre ripulisce notevolmente il design.
5. Regole da non perdere nell’implementazione
È meglio deciderli prima di scrivere il codice.
5.1 Non trasformare l’aiutante in una “scatola da fare qualsiasi cosa”
Cattivi esempi:
- L’UI passa all’helper un’intera stringa
reg add ... - L’UI passa all’helper un’intera stringa
sc.exe ... - L’UI trasmette all’helper percorsi di registro arbitrari o percorsi EXE arbitrari
Esegui questa operazione e se l’UI viene compromessa, l’aiutante cade con essa. L’assistente amministratore si trova all’interno del limite di elevazione. Creare un’“apertura in cui tutto può funzionare” è piuttosto pericoloso.
La buona forma è simile a questa:
set-explorer-context-menuinstall-serviceadd-firewall-rule
Correggi le operazioni stesse e mantieni gli argomenti richiesti su bool / enum / numeri / stringhe vincolate.
5.2 I percorsi passati all’helper sono assoluti e l’UI non dovrebbe decidere troppo
L’helper EXE lanciato con runas è esso stesso specificato dal percorso assoluto.
Evita di fare affidamento sulla ricerca PATH o sui percorsi relativi.
Inoltre, ciò su cui opera l’aiutante dovrebbe essere risolto e fissato il più possibile dal lato dell’aiutante.
In questo esempio, la destinazione EXE registrata nel menu contestuale di Explorer è fissata su MyApp.exe nella stessa cartella dell’helper.
5.3 Se usi Verb=\"runas\", imposta esplicitamente UseShellExecute=true
In .NET, ProcessStartInfo.Verb ha effetto solo quando UseShellExecute=true.
Inoltre, l’impostazione predefinita di UseShellExecute differisce tra .NET Framework e .NET Core / .NET.
Lascialo all’impostazione predefinita e in seguito otterrai l’incidente silenziosamente esasperante di “funziona in alcuni ambienti e non in altri”.
Quindi impostalo sempre esplicitamente.
5.4 runas e il reindirizzamento I / O standard non vanno insieme
Con UseShellExecute=true, la comunicazione basata sul reindirizzamento input / output standard diventa difficile da utilizzare.
Pertanto, è più naturale utilizzare un diverso meccanismo IPC come una named pipe per lo scambio con l’helper.
5.5 Non fare affidamento sul valore predefinito ACL delle Named Pipe
Con il descrittore di sicurezza predefinito, le pipe denominate garantiscono per impostazione predefinita l’accesso in lettura a Everyone e anonimo. Usarlo così com’è per IPC dell’assistente amministratore è piuttosto sciatto.
Imposta sempre un PipeSecurity esplicito.
5.6 PipeOptions.CurrentUserOnly non viene utilizzato in questo scenario
A prima vista sembra conveniente.
Ma il Windows, CurrentUserOnly controlla non solo l’account utente ma anche il livello di elevazione.
Ciò significa che non è adatto alla comunicazione tra un’UI non elevata e un helper elevato.
Inoltre, negli ambienti per utenti standard, UAC diventa una richiesta di credenziali e l’assistente può essere eseguito con un account amministratore diverso.
In tal caso, se l’helper crea ACL direttamente da WindowsIdentity.GetCurrent(), l’utente dell’UI originale potrebbe non essere più in grado di connettersi.
Quindi in questo articolo usiamo questa forma:
- Il lato dell’UI ottiene il proprio SID e lo passa all’helper
- L’helper concede i diritti di connessione della tubazione solo all’utente SID dell’UI
- Inoltre, l’aiutante controlla la connessione PID con
GetNamedPipeClientProcessId
5.7 La verifica PID è una difesa aggiuntiva per ridurre il “salto della coda”
Un nome di pipe casuale da solo aiuta molto, ma la possibilità che un altro processo in esecuzione con lo stesso utente si connetta per primo non è zero.
Quindi, dal lato dell’helper, utilizza GetNamedPipeClientProcessId e verifica che corrisponda al processo di UI previsto PID.
Naturalmente, un PID corrispondente non significa che ci si possa fidare di tutto. Se l’UI viene compromessa, anche le richieste pericolose raggiungeranno l’helper. Questo è esattamente il motivo per cui sono necessarie la lista consentita delle operazioni lato helper e la convalida degli argomenti.
6. Lo scenario campione
In questo articolo viene utilizzato l’esempio di registrazione / annullamento della registrazione di una voce del menu di scelta rapida di Explorer a livello di computer.
Le ragioni sono semplici:
- Richiede privilegi di amministratore
- Il confine dell’operazione è chiaro
- Non è necessario passare stringhe di comando arbitrarie all’helper
- Si verifica realmente nel lavoro del mondo reale
Gli obiettivi di registrazione sono chiavi fisse come queste:
HKLM\SOFTWARE\Classes\*\shell\MyApp.OpenHKLM\SOFTWARE\Classes\*\shell\MyApp.Open\command
L’UI ha solo una casella di controllo “Registra nel menu di scelta rapida di Explorer”; le operazioni effettive del registro avvengono dal lato dell’helper.
7. Struttura della soluzione
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
Mantenere il contratto condiviso in un progetto separato semplifica l’allineamento tra l’UI e l’helper:
- nomi delle operazioni
- tipologie di richiesta / risposta
- il formato del messaggio pipe
8. Manifesta
8.1 Lato 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 Lato aiutante (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 rimane asInvoker per tutto il tempo.
Solo l’aiutante è requireAdministrator.
Invertiteli e il punto di dividerli scompare.
9. Codice contrattuale condiviso
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;
}
}
Il punto è: non limitarti a trasmettere in streaming JSON lungo il tubo in modo approssimativo: invialo con il prefisso di lunghezza. Mantenere il protocollo semplice – una richiesta, una risposta – lo rende meno soggetto a incidenti.
10. Lato UI: avvio e comunicazione con l’aiutante
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("\"", "\\\"") + "\"";
}
}
Ciò che viene passato all’helper qui è solo il nome della pipe e le informazioni minime necessarie per verificare la parte che si connette.
L’operazione stessa dell’amministratore è limitata alla richiesta digitata inviata tramite la pipe.
Questa QuoteArgument è un’implementazione minima che presuppone i valori semplici passati in questo esempio: un nome di pipe, PID, SID. Se passi percorsi Windows arbitrari o stringhe in formato libero come argomenti della riga di comando, sostituiscili con un escape dedicato che segua le regole di analisi argv Windows.
11. Lato helper: analisi degli argomenti di lancio
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];
}
}
Il lato helper esce in errore nel momento in cui mancano argomenti o sono presenti argomenti aggiuntivi. All’interno del limite di elevazione, “interpretalo nel miglior modo possibile per ora” è qualcosa che non dovresti fare.
12. Lato helper: creazione della tubazione, verifica del cliente PID, invio
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);
}
Cosa sta facendo il lavoro qui:
- Il tubo ACL è assemblato esplicitamente
- Il ACL è concesso non solo all’utente corrente dell’aiutante SID ma anche all’utente dell’UI chiamante SID
- Dopo la connessione, il client PID viene verificato
- Anche dopo aver ricevuto la richiesta, questa viene inviata per nome dell’operazione
Mantenere la forma in cui switch (request.Operation) lascia passare solo operazioni fisse rende meno probabile che l’aiutante diventi “una scatola di qualsiasi cosa elevata”.
13. L’operazione dell’amministratore stessa: registrazione del menu di scelta rapida di Explorer
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;
}
}
Il punto cruciale di questo codice risiede in ciò che non riceve dall’UI.
- Non riceve percorsi di registro arbitrari dall’UI
- Non riceve stringhe di comando arbitrarie dall’UI
- Il EXE registrato è risolto e corretto dal lato dell’helper
- Il contenuto della richiesta è solo
Enabled
In altre parole, l’helper è vincolato ad avere esattamente un significato: “attiva / disattiva lo stato di registrazione del menu di scelta rapida di Explorer”.
14. Chiamandolo dall’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;
}
}
}
Il lato dell’UI è ordinario.
- Leggere lo stato della casella di controllo
- Chiama il cliente del broker
- Ripristina l’UI in caso di errore
Questo è tutto. Non tocca direttamente il registro. Questo è ciò che significa isolamento.
15. A cosa si riferisce questa implementazione
Le linee effettivamente difese da questo campione sono queste.
15.1 Separazione delle responsabilità tra UI e Helper
- L’UI riceve solo le azioni dell’utente
- L’helper esegue solo le operazioni di amministratore fisse
15.2 Nessuna “Apertura di esecuzione arbitraria” nell’Helper
- Non accetta percorsi di registro arbitrari
- Non accetta righe di comando arbitrarie
- Non accetta percorsi EXE arbitrari
15.3 Il percorso di lancio è fisso
- L’helper EXE è un percorso assoluto
runasè esplicitoUseShellExecute = trueè esplicito
15.4 La parte che si connette IPC è limitata
- La pipe ACL è limitata al SID dell’utente dell’UI
- Il client PID viene verificato dopo la connessione
15.5 Anche gli obiettivi dell’operazione dell’amministratore sono fissi
- L’hive / percorso del registro è stato corretto
- Anche il EXE in fase di registrazione viene risolto come fisso
Vai così lontano e sei abbastanza distante dallo stato di “se l’UI è compromessa, tutto può essere fatto tramite l’helper”.
15.6 Verificare che la separazione funzioni effettivamente
Finora abbiamo parlato di progettazione. Che il progetto separi davvero i privilegi non si può sapere senza eseguirlo e verificarlo. Anche “tutta l’UI si è insinuata ad amministratore senza che me ne accorgessi” è un incidente difficile da notare leggendo il codice.
La verifica procede in questi 4 passaggi.
1. Il processo UI rimane non elevato
Il punto più importante. Avvia l’UI e verifica dopo aver eseguito un’operazione di amministratore.
- Task Manager: nella scheda “Dettagli”, fai clic con il pulsante destro sull’intestazione e mostra la colonna “Elevazione”. Se
MyApp.exeè “No” e soloMyApp.AdminBroker.exeè “Sì”, è corretto. - Process Explorer: mostra la colonna Integrity. Se l’UI è
Mediume l’helper èHigh, è corretto (come descritto in 2.1, l’integrità standard dell’utente è medium, mentre quella elevata è high).
Se vuoi verificarlo dal codice, puoi farlo una volta subito dopo l’avvio dell’UI.
using System.Security.Principal;
using WindowsIdentity identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
// Nell'EXE UI dovrebbe essere false
bool isElevatedAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator);
2. La richiesta UAC appare solo all’avvio dell’helper
- Se appare all’avvio dell’UI -> il manifest lato UI non è impostato su
asInvoker - Se appare nel momento in cui premi la casella di controllo delle impostazioni -> è il comportamento atteso
- Se le impostazioni cambiano senza che appaia mai una richiesta -> l’helper potrebbe essere elevato permanentemente attraverso un altro percorso
Per un account amministratore verrà mostrato il prompt di consenso, per un utente standard verrà richiesto il prompt delle credenziali (vedi la tabella in 5.6). Prova entrambi per verificare anche che il passaggio del SID funzioni correttamente.
3. L’operazione di amministratore ha effettivamente funzionato
Nel caso della registrazione del menu contestuale di Explorer, il modo più rapido è controllare direttamente il registro.
reg query "HKLM\SOFTWARE\Classes\*\shell\MyApp.Open" /s
Verifica anche il lato della rimozione allo stesso modo. Se provi solo la registrazione e non la rimozione, potresti lasciare un bug lato DeleteSubKeyTree.
4. “Fallisce quando dovrebbe fallire”
Senza questa verifica, non si può sapere se la separazione è reale.
- Annullare il prompt di elevazione -> le impostazioni non devono cambiare e la casella di controllo dell’UI deve tornare indietro (gestione di
ERROR_CANCELLED= 1223, vedi capitolo 10) - Avviare l’helper direttamente -> anche se si avvia manualmente con qualcosa del tipo
MyApp.AdminBroker.exe --pipe x --client-pid 1 --client-sid S-1-5-18, la verifica del PID del chiamante e il timeout devono impedire che l’elaborazione proceda - Inviare un’operation non presente nell’allowlist -> deve essere rifiutata con
unsupported_operation(vediDispatchAsyncnel capitolo 12)
I comandi concreti per la verifica sono riassunti nel README del campione in “Procedura di verifica su Windows” nello stesso flusso.
16. Anti-modelli comuni
16.1 Realizzazione dell’intera UI requireAdministrator
Solo un pulsante nella schermata delle impostazioni richiede i privilegi di amministratore, ma tutto viene avviato con privilegi elevati. Ciò schiaccia con noncuranza il confine dei privilegi.
16.2 Passaggio dei comandi di stringa grezza dell’helper
Ad esempio, questo disegno:
UI -> l'helper riceve "reg add HKLM\\.... /v ... /d ..."
Questo trasforma l’aiutante in un esecutore di comandi. Meglio di no.
16.3 Utilizzo del ACL predefinito delle pipe con nome così com’è
“È locale IPC, quindi dovrebbe andare bene” è un po’ pericoloso. I tubi sono soggetti alla sicurezza Windows, quindi costruisci un ACL adeguato.
16.4 Salto a CurrentUserOnly
Sembra conveniente, ma non si adatta al caso di questo articolo di un’UI a media integrità che comunica con un aiutante ad alta integrità. I ACLs espliciti sono più facili da gestire qui.
16.5 L’aiutante accetta percorsi arbitrari su cui operare
Ad esempio:
- Copia di file arbitrari in Programmi
- Scrittura di chiavi arbitrarie in HKLM
- Eliminazione di un servizio arbitrario per nome
- Aggiunta di regole firewall da comandi arbitrari
Se l’helper li accetta, l’helper stesso diventa un’apertura di esecuzione generica con privilegi di amministratore. Le operazioni dovrebbero essere sempre fisse.
17. Riepilogo
“Solo una parte dell’elaborazione richiede privilegi di amministratore” non è una situazione insolita nelle app Windows.
Ma il modo per risolverlo non è “fare tutto requireAdministrator” — è tagliare un limite di esecuzione.
La forma più semplice da adottare per prima è questa:
- L’UI è
asInvoker - Il lavoro dell’amministratore è isolato in un aiutante EXE
- L’aiutante è
requireAdministrator - Il lancio avviene tramite
runas - La comunicazione avviene tramite una pipe denominata
- L’aiutante accetta solo operazioni fisse
- La parte che si connette è limitata tramite la pipe ACL e il client PID
- L’aiutante riconvalida gli argomenti
Con questa forma in atto, anche la migrazione a un servizio in un secondo momento è più semplice. Se il contratto operativo è nettamente separato, il confine tra l’UI e il lavoro dell’amministratore diventa di per sé una risorsa di progettazione.
In termini di sicurezza, non lasciare confini approssimativi è meglio dell’aggiunta di funzionalità appariscenti. I privilegi di amministratore sono gli stessi. Non consegnarli all’ingrosso: concedili solo dove necessario, nel modo più limitato possibile. Questo tipo di disciplina poco affascinante ripaga in seguito.
18. Riferimenti
- Il codice di esempio completo per questo articolo (libreria di contratto condivisa, demo, test unitari) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
- Articolo originale: una lista di controllo della sicurezza minima per Windows Application Development https://comcomponent.com/it/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- Administrator Broker Model - Applicazioni Win32 https://learn.microsoft.com/en-us/windows/win32/secauthz/administrator-broker-model
- Sviluppo di applicazioni che richiedono privilegi di amministratore https://learn.microsoft.com/en-us/windows/win32/secauthz/developing-applications-that-require-administrator-privilege
- Modello di servizio del sistema operativo - App Win32 https://learn.microsoft.com/en-us/windows/win32/secauthz/operating-system-service-model
- Modello di attività elevato - App Win32 https://learn.microsoft.com/en-us/windows/win32/secauthz/elevated-task-model
- Modello a oggetti COM amministratore - App Win32 https://learn.microsoft.com/en-us/windows/win32/secauthz/administrator-com-object-model
- Il soprannome di elevazione COM https://learn.microsoft.com/en-us/windows/win32/com/the-com-elevation-moniker
- Come funziona il controllo dell’account utente 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
- Sicurezza dei tubi nominati e diritti di accesso 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
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Memorizzazione di segreti nelle app Windows: evitare la configurazione in testo normale con DPAPI
Per evitare di archiviare credenziali di connessione e token API in file di configurazione di testo normale nelle app Windows, esaminerem...
Una lista di controllo minima sulla sicurezza per lo sviluppo di app Windows
Una guida in stile elenco di controllo alle nozioni di base sulla sicurezza per le app aziendali WPF / WinForms / WinUI / C++ / C#: privi...
Dove tracciare il confine tra unit test e integration test
Organizziamo il confine tra unit test e integration test lungo gli assi logica pura, formati, cablaggio, differenze ambiente e dipendenza...
Icone nella system tray e notifiche toast nelle app Windows — le insidie di NotifyIcon e come scegliere l'AppNotification giusta
Una guida pratica per mantenere un'applicazione Windows aziendale residente nella system tray (area di notifica) e avvisare l'utente tram...
Fino a quando funzioneranno le applicazioni VB6? — Lo stato del supporto al runtime e un percorso pratico verso la migrazione a .NET
Fino a quando continueranno a funzionare le applicazioni VB6? Questo articolo chiarisce l'asimmetria tra la politica di supporto del runt...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Questo argomento tocca la progettazione dei privilegi di un'intera app Windows (UAC, helper EXEs, la decisione su quando utilizzare un servizio e le modifiche alla configurazione a livello di macchina), quindi si adatta bene al nostro servizio di sviluppo di applicazioni Windows.
Consulenza tecnica e revisione del progetto
Se desideri allontanarti dal `requireAdministrator` abituale in un'app esistente e rielaborare la progettazione del broker e i limiti del IPC, questo argomento funziona bene come consulenza tecnica / revisione del progetto.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- Posso elevare un solo metodo ad amministratore all'interno della mia app Windows in esecuzione?
- No. L'elevazione UAC è controllata dal token e dal livello di integrità con cui viene eseguito il processo, non dalla funzione, quindi non è possibile eseguire improvvisamente un metodo con privilegi di amministratore all'interno di un processo dell'UI non elevato. Se hai bisogno dell'elevazione, utilizzi un'unità di esecuzione diversa: un broker separato EXE, un servizio Windows, un'attività pianificata o COM elevato. La forma più pratica per operazioni amministrative sporadiche è mantenere l'UI su asInvoker e inserire il lavoro con privilegi elevati in un helper requireAdministrator separato EXE avviato con il verbo runas.
- In che modo il processo dell'UI dovrebbe comunicare con un helper con privilegi elevati EXE?
- Utilizza IPC come pipe denominate, non input / output standard: runas richiede UseShellExecute=true in .NET, il che rende inutilizzabile il reindirizzamento stdio. Non fare affidamento sulla pipe denominata ACL predefinita, che garantisce l'accesso in lettura in modo ampio; creare un PipeSecurity esplicito che limiti le connessioni al SID dell'utente dell'UI chiamante e verificare il PID del processo di connessione con GetNamedPipeClientProcessId. Tieni presente che PipeOptions.CurrentUserOnly non è adatto qui perché su Windows controlla il livello di elevazione e l'utente, bloccando esattamente il caso di supporto da UI non elevata a elevato di cui hai bisogno.
- Quali operazioni dovrebbe accettare l'aiutante elevato?
- Solo una lista consentita fissa di operazioni digitate, mai stringhe di comandi non elaborate. Se l'UI passa all'helper l'intera riga di comando "reg add" o "sc.exe", percorsi di registro arbitrari o percorsi EXE arbitrari, l'helper diventa un'apertura di esecuzione generica con privilegi di amministratore e un'UI compromessa porta con sé l'helper. La buona forma risolve le operazioni stesse (come 'set-explorer-context-menu' o 'install-service'), vincola gli argomenti a booleani, enumerazioni, numeri e stringhe convalidate e riconvalida ogni richiesta sul lato helper.
- Quando dovrei utilizzare un servizio Windows invece di un broker EXE per lavori elevati?
- Scegli un broker EXE per operazioni che non sono necessarie nell'uso normale e che si verificano solo quando l'utente preme un pulsante specifico: l'helper si avvia una volta con un prompt UAC, fa il suo lavoro ed esce. Scegli un servizio per attività amministrative sempre attive, non presidiate o frequenti: monitoraggio continuo, raccolta di registri, aggiornamenti in background o funzioni condivise da più sessioni dell'UI. Un servizio evita la richiesta di elevazione ma aggiunge la responsabilità operativa di un processo residente. Le attività pianificate con livello elevato si adattano a lavori brevi in forma fissa e il COM elevato ha un ambito di applicazione piuttosto ristretto.
Profilo dell’autore
Pagina di presentazione dell’autore dell’articolo.
Go Komura
Rappresentante di KomuraSoft LLC
Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.