Как на практике выделить в Windows-приложении «только те операции, для которых нужны права администратора»
· Го Комура · Разработка Windows, Безопасность, UAC, C# / .NET, Win32
В более ранней статье «Чек-лист минимальной безопасности при разработке Windows-приложений» мы обозначили линию: по умолчанию использовать asInvoker и выделять в отдельную единицу только операции, требующие прав администратора.
В этот раз мы разберём эту часть до конца — как это писать на практике.
В Windows-приложении нельзя удобно «выполнить как администратор» лишь часть операций внутри одного и того же процесса. Повышение — это вопрос границы процесса, поэтому нужен «дизайн, который выносит именно эту операцию в отдельную единицу выполнения».
Статья построена в следующем порядке.
- Сначала — предпосылки
- Какую модель разделения выбрать
- Наиболее удобная на практике форма:
asInvoker+ административный helper EXE - Ловушки, которые не хочется упустить при реализации
- Конкретные примеры кода
Примеры кода рассчитаны на .NET 8 / десктопное приложение Windows. UI-фреймворк может быть любым — WPF / WinForms / WinUI, различия проявляются разве что в обработчиках событий на стороне UI.
Код, встречающийся в этой статье, опубликован на GitHub в виде полного собираемого и запускаемого набора примеров (общая библиотека контрактов, демо UI / административного helper, модульные тесты, которые работают и на Linux).
windows-admin-broker-deep-dive - komurasoft-blog-samples (GitHub)
1. Сначала — вывод
Сначала перечислим практические итоговые решения.
- обычное UI-приложение работает как
asInvoker - операции, требующие прав администратора, выносятся в отдельный EXE
- этот helper EXE делается
requireAdministrator - запуск выполняется через
runas - для общения с helper используется IPC вроде именованных каналов, а не стандартный ввод-вывод, который плохо сочетается с
runas - helper получает не «сырую командную строку», а только типизированный запрос
- на стороне helper содержимое запроса проверяется повторно
- источник подключения IPC ограничивается SID вызывающего пользователя и ожидаемым PID
«Работать от администратора проще» — это верно только в первый раз. Позже вас неизбежно ждут недовольные лица из-за UAC, drag & drop, дизайна логов, внешнего ввода, эксплуатации поддержки, загрузки DLL и места хранения настроек.
2. Проясняем предпосылку: сделать администратором только часть одного и того же процесса нельзя
UAC в Windows контролируется не «повышением на уровне функций», а тем, с каким токеном / уровнем целостности работает процесс. Приложение, которому нужен токен доступа администратора, становится объектом запроса на повышение, а дочерний процесс наследует токен родительского на том же уровне целостности. То есть дизайн, при котором внутри неповышенного UI-процесса внезапно один конкретный метод выполняется с правами администратора, невозможен. При необходимости используется отдельная единица выполнения: отдельный процесс, служба, задача, повышенный COM и т. п.
Если упустить эту предпосылку, получается немного печальный запрос на дизайн: «хочу стать администратором только в момент нажатия этой кнопки». Windows не заполняет этот пробел волшебством.
3. Какую модель разделения выбрать
В Microsoft Learn перечислены главным образом следующие четыре способа изоляции для приложений, которым нужны права администратора.
| Модель | Приблизительная форма | Подходящий сценарий |
|---|---|---|
| Administrator Broker Model | UI со стандартными правами пользователя + административный helper EXE | Административные операции спорадичны, UAC нужно показывать только в нужный момент |
| Operating System Service Model | UI со стандартными правами + резидентная служба | Постоянно работающая административная функция, фоновый мониторинг, необслуживаемая обработка |
| Elevated Task Model | UI со стандартными правами + задача планировщика с правами администратора | Короткая типовая операция, завершающаяся каждый раз за один запуск |
| Administrator COM Object Model | UI со стандартными правами + повышенный COM | Существует дизайн на базе COM, функциональность довольно ограничена |
Ориентир для выбора такой.
3.1 Проще всего начать рассматривать broker EXE
broker EXE хорошо подходит для таких операций:
- регистрация / отмена регистрации интеграции с Explorer
- изменение настроек на уровне машины под
HKLM - регистрация / отмена регистрации собственной службы
- добавление / удаление правил брандмауэра
- административные операции под
Program Files
Обычно они не нужны и требуются лишь в момент нажатия конкретной кнопки на экране настроек. В этом случае вместо того чтобы прибегать к резидентной службе, естественнее использовать форму, при которой административный helper EXE запускается один раз и завершает работу.
3.2 Службу выбирают для «постоянной», «необслуживаемой», «частой» работы
Служба — это модель, при которой приложение со стандартными правами общается с ней через RPC и подобные механизмы. Преимущество в том, что административная обработка выполняется без запроса на повышение, но взамен растёт ответственность за эксплуатацию резидентного процесса.
Служба подходит для таких сценариев:
- постоянный мониторинг
- сбор логов
- фоновые обновления
- постоянная интеграция с устройствами или демонами
- административные функции, общие для нескольких UI-сессий
3.3 Задача подходит для «короткой типовой операции»
Elevated Task Model — это форма, при которой приложение со стандартными правами запускает задачу планировщика, работающую с правами администратора. Она легче, чем служба, и закрывается по завершении, поэтому подходит для разовых типовых заданий.
3.4 Повышенный COM довольно ограничен
COM elevation moniker выглядит удобным, но область применения довольно узкая. В Microsoft Learn указано, что UI, способный управлять повышенным COM, должен быть представлен со стороны COM, — то есть этот подход не годится для того, чтобы «позволить неповышенному UI творить что угодно с повышенным COM».
4. Рекомендация этой статьи: UI asInvoker + helper EXE requireAdministrator
Далее конкретизируем наиболее удобную на практике форму.
[ MyApp.exe ] asInvoker
|
| ShellExecute / ProcessStartInfo + Verb=runas
v
[ MyApp.AdminBroker.exe ] requireAdministrator
|
| named pipe
v
[ Выполняются только фиксированные операции, требующие прав администратора ]
Ключевых момента три.
- UI-процесс до конца остаётся неповышенным
- административный helper короткоживущий
- helper принимает только операции из фиксированного allowlist
Одно только соблюдение этих трёх пунктов заметно упорядочивает дизайн.
5. Правила, которые не хочется упустить при реализации
Это лучше решить до того, как писать код.
5.1 Не превращать helper в «мастера на все руки»
Плохие примеры:
- UI передаёт helper целиком строку вида
reg add ... - UI передаёт helper целиком строку вида
sc.exe ... - UI передаёт helper произвольный путь в реестре или произвольный путь к EXE
Сделав так, вы получаете, что если скомпрометирован UI, вместе с ним падает и helper. Административный helper находится внутри границы повышения. Создать здесь «отверстие, через которое можно выполнить что угодно» — довольно опасно.
Хорошая форма выглядит так: фиксируем сами операции, например
set-explorer-context-menuinstall-serviceadd-firewall-rule
а необходимые аргументы сводим к bool / enum / числам / ограниченным строкам.
5.2 Путь, передаваемый helper, — абсолютный, и UI не должен решать слишком многое
Сам helper EXE, запускаемый через runas, указывается абсолютным путём. Полагаться на поиск по PATH или относительные пути не стоит.
Более того, объект, на который воздействует helper, тоже по возможности разрешается фиксированно на стороне самого helper. В примере этой статьи EXE, регистрируемый в контекстном меню Explorer, фиксирован как MyApp.exe в той же папке, что и helper.
5.3 Если используете Verb="runas", явно задавайте UseShellExecute=true
В .NET свойство ProcessStartInfo.Verb действует только при UseShellExecute=true. При этом значение по умолчанию для UseShellExecute различается между .NET Framework и .NET Core / .NET. Если положиться здесь на значение по умолчанию, позже возникнет незаметно раздражающая проблема: «в одних окружениях работает, в других — нет».
Поэтому здесь обязательно задавайте значение явно.
5.4 runas плохо сочетается с перенаправлением стандартного ввода-вывода
При UseShellExecute=true обмен, опирающийся на перенаправление стандартного ввода-вывода, становится неудобным. Поэтому для взаимодействия с helper естественнее использовать другой IPC-механизм, например named pipe.
5.5 Не полагайтесь на ACL по умолчанию для именованного канала
У именованного канала при дескрипторе безопасности по умолчанию право на чтение по умолчанию получают Everyone и анонимные пользователи. Использовать это как есть для IPC административного helper — довольно небрежно.
Обязательно задавайте явный PipeSecurity.
5.6 PipeOptions.CurrentUserOnly в этом сценарии не используем
На первый взгляд это выглядит удобным. Однако в Windows CurrentUserOnly проверяет не только учётную запись пользователя, но и уровень повышения. То есть он не подходит для общения между неповышенным UI и повышенным helper.
Более того, в среде со стандартным пользователем UAC может превращаться в credential prompt, и helper иногда запускается под другой учётной записью администратора. В этом случае, если построить ACL на стороне helper напрямую через WindowsIdentity.GetCurrent(), исходный пользователь UI может потерять возможность подключиться.
Поэтому здесь используется такая форма:
- UI сам получает свой SID и передаёт его helper
- helper выдаёт право подключения к каналу только SID пользователя UI
- дополнительно helper проверяет PID подключившейся стороны через
GetNamedPipeClientProcessId
5.7 Проверка PID — дополнительная защита от «небрежного перехвата»
Одно лишь случайное имя канала уже заметно повышает безопасность, но вероятность того, что другой процесс, работающий под тем же пользователем, подключится первым, не равна нулю. Поэтому на стороне helper используется GetNamedPipeClientProcessId, чтобы проверить, совпадает ли подключившийся PID с ожидаемым PID UI-процесса.
Разумеется, совпадение PID не означает, что можно доверять чему угодно. Если UI скомпрометирован, опасные запросы дойдут и до helper. Именно поэтому необходимы allowlist операций и проверка аргументов на стороне helper.
6. Тема примера
В качестве примера возьмём регистрацию / отмену регистрации пункта контекстного меню Explorer на уровне машины.
Причина проста:
- требует прав администратора
- у операции чёткие границы
- helper не приходится передавать произвольную командную строку
- это вполне обычный сценарий на практике
Куда регистрируется — фиксированные ключи вроде следующих.
HKLM\SOFTWARE\Classes\*\shell\MyApp.OpenHKLM\SOFTWARE\Classes\*\shell\MyApp.Open\command
UI содержит только флажок «Зарегистрировать в контекстном меню Explorer», а фактические операции с реестром выполняются на стороне helper.
7. Структура решения
MyApp/
MyApp/ UI-приложение (asInvoker)
app.manifest
ElevationBrokerClient.cs
SettingsPage.xaml.cs
MyApp.AdminBroker/ Административный helper (requireAdministrator)
app.manifest
Program.cs
BrokerLaunchOptions.cs
ExplorerContextMenuRegistration.cs
MyApp.BrokerProtocol/ Общий контракт
BrokerProtocol.cs
Если вынести общий контракт в отдельный проект, становится проще согласовать между UI и helper:
- имена operation
- типы request / response
- формат сообщений канала
8. Манифесты
8.1 Сторона 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 Сторона 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>
UI всегда остаётся asInvoker.
Только helper — requireAdministrator.
Поменяйте это местами — и весь смысл разделения исчезнет.
9. Код общего контракта
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;
}
}
Важный момент: не лить JSON в канал бесконтрольным потоком, а отправлять его с префиксом длины. Простой протокол «один запрос — один ответ» реже приводит к проблемам.
10. Сторона UI: запуск 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("Подтверждение прав администратора было отменено.", 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("\"", "\\\"") + "\"";
}
}
Здесь helper получает только имя канала и минимум информации, необходимой для проверки подключившейся стороны.
Сама административная операция заключена в типизированный request, передаваемый через канал.
QuoteArgument — минимальная реализация, рассчитанная на простые значения, передаваемые в этом примере: имя канала, PID, SID. Если вы передаёте произвольные пути Windows или свободные строки как аргументы командной строки, замените её на специализированную функцию экранирования, следующую правилам разбора argv в Windows.
11. Сторона helper: разбор аргументов запуска
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];
}
}
Helper выдаёт ошибку сразу, как только аргументов недостаточно или присутствуют лишние аргументы. Внутри границы повышения лучше не пытаться «как-нибудь да разобрать».
12. Сторона helper: создание канала, проверка PID подключившейся стороны, диспетчеризация
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);
}
Здесь работают следующие моменты:
- ACL канала собирается явно
- ACL выдаётся не только текущему SID пользователя helper, но и SID пользователя UI, который его вызвал
- после подключения проверяется client PID
- даже после получения request диспетчеризация происходит по имени operation
Если оставить форму, при которой switch (request.Operation) пропускает только фиксированные операции, helper с меньшей вероятностью превратится в «повышенную коробку, делающую что угодно».
13. Суть административной операции: регистрация пункта контекстного меню 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;
}
}
Суть этого кода — в том, чего он не получает от UI.
- не получает от UI произвольный путь в реестре
- не получает от UI произвольную командную строку
- регистрируемый EXE разрешается фиксированно на стороне helper
- содержимое request — только
Enabled
То есть helper устроен так, что имеет только одно значение: «переключить состояние регистрации пункта контекстного меню Explorer».
14. Пример вызова со стороны 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;
}
}
}
Сторона UI обычная.
- считать состояние флажка
- вызвать broker client
- при неудаче откатить UI
Вот и всё. Реестр напрямую не затрагивается. В этом и есть разделение.
15. Что удерживает эта реализация
Линии, которые этот пример действительно соблюдает, таковы.
15.1 Разделение ответственности между UI и helper
- UI лишь принимает действия пользователя
- helper выполняет только фиксированные административные операции
15.2 В helper не создано «отверстие для произвольного выполнения»
- не принимает произвольный путь в реестре
- не принимает произвольную командную строку
- не принимает произвольный путь к EXE
15.3 Путь запуска фиксирован
- helper EXE задаётся абсолютным путём
runasуказывается явноUseShellExecute = trueуказывается явно
15.4 Источник подключения IPC ограничен
- ACL канала ограничен SID пользователя UI
- после подключения проверяется client PID
15.5 Объект административной операции тоже фиксирован
- hive / путь реестра фиксированы
- регистрируемый EXE тоже разрешается фиксированно
Если проделать всё это, вы оказываетесь довольно далеко от состояния «если UI скомпрометирован, через helper можно сделать что угодно».
16. Частые антипаттерны
16.1 Делать requireAdministrator весь UI целиком
Только одна кнопка на экране настроек требует прав администратора, а всё приложение запускается с повышением целиком. Это направление, которое небрежно стирает границу прав.
16.2 Передавать helper сырую строковую команду
Например, такой дизайн:
UI -> helper получает "reg add HKLM\\.... /v ... /d ..."
При этом helper превращается в исполнитель произвольных команд. Лучше так не делать.
16.3 Использовать ACL именованного канала по умолчанию как есть
«Это же локальный IPC, значит, всё в порядке» — немного опасное рассуждение. Канал подпадает под систему безопасности Windows, поэтому лучше как следует выстроить ACL.
16.4 Хвататься за CurrentUserOnly
Выглядит удобным, но не подходит для нашего случая — UI со средним уровнем целостности (medium integrity) взаимодействует с helper с высоким уровнем целостности (high integrity). Здесь удобнее явный ACL.
16.5 Helper принимает произвольный путь и работает с ним
Например, вот такое:
- копировать произвольный файл в
Program Files - записывать произвольный ключ в
HKLM - удалять службу по произвольному имени
- добавлять правило firewall произвольной командой
Если helper принимает такое, он сам превращается в универсальное отверстие для выполнения с правами администратора. Операции обязательно нужно фиксировать.
17. Итог
«Только части операций в Windows-приложении нужны права администратора» — вовсе не редкая ситуация.
Однако решение — не «сделать всё requireAdministrator», а провести границу выполнения.
Проще всего начать с такой формы.
- UI —
asInvoker - административная работа выделена в helper EXE
- helper —
requireAdministrator - запуск через
runas - обмен данными через named pipe
- helper принимает только фиксированные operation
- источник подключения ограничен через ACL канала и client PID
- на стороне helper аргументы проверяются повторно
Если придерживаться такой формы, миграция окажется проще и в случае, если позже захочется перейти на службу. Если чётко разделить контракт operation, граница между UI и административной работой сама становится дизайн-активом.
В вопросах безопасности эффективнее не добавлять эффектные функции, а не оставлять небрежных границ. То же самое касается прав администратора. Не выдавать их все скопом, а передавать только туда, где действительно нужно, и как можно уже. Именно такая незаметная скрупулёзность окупается позже.
18. Справочные материалы
- Полный набор примеров кода к этой статье (общая библиотека контрактов, демо, модульные тесты) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
- Исходная статья: Чек-лист минимальной безопасности при разработке Windows-приложений https://comcomponent.com/ru/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- Administrator Broker Model - Win32 apps https://learn.microsoft.com/ja-jp/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/ja-jp/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/ja-jp/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/ja-jp/dotnet/fundamentals/runtime-libraries/system-diagnostics-processstartinfo-useshellexecute
- Named Pipe Security and Access Rights https://learn.microsoft.com/ja-jp/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/ja-jp/windows/win32/api/winbase/nf-winbase-getnamedpipeclientprocessid
- RegistryView Enum https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.win32.registryview?view=net-8.0
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Хранение секретов в Windows-приложениях - избегаем открытых настроек с помощью DPAPI
Чтобы не хранить учётные данные и API-токены в конфигурационных файлах Windows-приложений в открытом виде, разбираем принципы DPAPI / Pro...
Минимальный чек-лист безопасности при разработке Windows-приложений
Систематизируем в виде чек-листа базовые меры безопасности для бизнес-приложений на WPF / WinForms / WinUI / C++ / C#: права доступа, под...
Когда на Windows действительно требуются права администратора — UAC, защищённые области и как это определить на этапе проектирования
Разбираем на практических примерах, когда в Windows требуются права администратора — с точки зрения UAC, защищённых областей, служб, драй...
Если ваше Windows-приложение приняли за вирус — как реагировать на ложные срабатывания Microsoft Defender и жить с влиянием на производительность
Разбираем правильный порядок действий, если Microsoft Defender ложно определяет ваше Windows-приложение как вредоносное: как устроена сов...
MAX_PATH и подводные камни путей/имён файлов в Windows — лимит 260 символов, зарезервированные имена, конечная точка, регистр
Разбираем ограничения путей и имён файлов, которые часто стоят за классической ошибкой «файл не найден». Рассматриваем состав лимита MAX_...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Тема затрагивает дизайн прав во всём Windows-приложении — UAC, helper EXE, выбор между службой и обычным подходом, изменение настроек на уровне машины, — поэтому хорошо сочетается с разработкой Windows-приложений.
Технические консультации и ревью дизайна
Если хочется пересмотреть постоянное использование requireAdministrator в существующем приложении и заново продумать дизайн broker и границы IPC, эта тема хорошо подходит для технической консультации и ревью архитектуры.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Можно ли выполнить с правами администратора только часть операций внутри одного и того же процесса?
- Нет, нельзя. UAC в Windows контролирует не повышение на уровне отдельных функций, а то, с каким токеном и уровнем целостности работает процесс. Дочерний процесс наследует токен на том же уровне целостности, что и родительский, поэтому дизайн, при котором отдельный метод внутри неповышенного UI-процесса вдруг выполняется с правами администратора, невозможен. Нужную операцию выносят в отдельную единицу выполнения: отдельный процесс, службу, задачу планировщика или повышенный COM.
- Какие есть варианты разделения для операций, требующих прав администратора?
- В Microsoft Learn перечислены главным образом четыре модели: Administrator Broker Model, сочетающая UI со стандартными правами и административный helper EXE; Operating System Service Model на основе резидентной службы; Elevated Task Model на основе задачи планировщика с правами администратора; и Administrator COM Object Model на основе повышенного COM. Если административные операции происходят спорадически и UAC нужно показывать только в нужный момент, подходит broker EXE; если работа постоянная, без участия человека и частая — служба; если это короткая типовая операция, завершающаяся за один раз, — задача.
- Можно ли использовать стандартный ввод-вывод для общения с helper EXE, запущенным через runas?
- Использовать неудобно, поэтому лучше избегать. В .NET свойство ProcessStartInfo.Verb действует только при UseShellExecute=true, а при UseShellExecute=true становится невозможен обмен, опирающийся на перенаправление стандартного ввода-вывода. Поэтому для общения с helper естественно использовать другой IPC-механизм, например именованные каналы (named pipe). Для канала не стоит полагаться на ACL по умолчанию — нужно явно настроить PipeSecurity, ограничить право подключения SID вызывающего пользователя, а также проверить PID подключившейся стороны через GetNamedPipeClientProcessId.
- Разве использование PipeOptions.CurrentUserOnly для именованного канала не безопасно само по себе?
- Для общения между неповышенным UI и повышенным helper он не подходит. CurrentUserOnly в Windows проверяет не только учётную запись, но и уровень повышения, поэтому подключение между процессами с разными уровнями целостности становится невозможным. Более того, в среде со стандартным пользователем UAC может превращаться в credential prompt, и helper иногда запускается под другой учётной записью администратора. Проще и надёжнее, когда UI сам получает свой SID и передаёт его helper, а helper явно настраивает ACL, выдавая право подключения к каналу только этому SID.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки