Как в Windows-приложении вынести только операции, которым нужны права администратора

· Обновлено: · · Разработка Windows, Безопасность, UAC, C# / .NET, Win32

История изменений (2 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Переведены названия источников в списке литературы. Утверждения статьи не менялись.
Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619699)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Как в Windows-приложении вынести только операции, которым нужны права администратора. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619699 https://comcomponent.com/ru/blog/2026/03/16/001-windows-admin-broker-deep-dive/

DOI (последняя версия)
10.5281/zenodo.21619699
DOI (эта версия)
10.5281/zenodo.21619700

В более ранней статье «Минимальный чек-лист безопасности при разработке Windows-приложений» мы зафиксировали подход: по умолчанию оставлять asInvoker и выносить только те операции, которым нужны права администратора.

Здесь эту часть разбираем до конца: как это писать на практике.

В Windows-приложении нельзя взять и выполнить «от имени администратора» только часть операций внутри того же процесса. Повышение прав — это граница процесса, поэтому нужна схема, которая выносит именно эту операцию в отдельную единицу выполнения.

Повышение прав — это граница процессаНельзя выполнить от имени администратора только часть операций внутри того же процесса; повышение прав — граница процесса, поэтому нужна схема, которая выносит эту операцию в отдельную единицу выполнения.внутри того же процесса нельзядоступен этот путьНужны права администратора только для части операцийПовышение внутри того же процессаВынести операцию в другую единицу выполнения

Рис. 1: Повышение прав — не на уровне функции, а на уровне процесса, поэтому работает только схема с вынесением.

Статья идёт в таком порядке.

  1. Сначала предпосылки
  2. Какую модель разделения выбрать
  3. Самая удобная на практике форма: asInvoker + административный helper EXE
  4. Ловушки, которые не хочется пропустить при реализации
  5. Конкретные примеры кода

Примеры кода рассчитаны на .NET 8 / десктопное приложение Windows. UI-фреймворк может быть любым — WPF / WinForms / WinUI; различия почти только в обработчиках событий на стороне UI.

Код из этой статьи опубликован на GitHub как полный набор примеров, который можно собрать и запустить (общая библиотека контрактов, демо UI / административного helper, модульные тесты, которые работают и на Linux).

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

Как читать эту статью

Статья длинная, поэтому сначала карта маршрута.

Что нужно Куда смотреть
Только выбор модели разделения главы 1–4 (вывод, сравнение четырёх моделей, рекомендуемая форма)
Проектные решения и их причины глава 5 (allowlist, фиксированные пути, runas, ACL канала, проверка PID)
Код реализации главы 7–14 (структура, манифесты, общий контракт, сторона UI, сторона helper)
Как проверить, что разделение действительно получилось 15.6
Чего делать не стоит глава 16

Тот же код целиком лежит в образце на GitHub. Если нужны только проектные решения — главы 1–5 и 15–16; если нужна реализация — читайте подряд.

Что подготовить, прежде чем пробовать

Чтобы реально запустить этот образец, нужно следующее.

  • Машина Windows (приглашение повышения UAC, явный ACL через PipeSecurity, GetNamedPipeClientProcessId и запись в HKLM — всё это только для Windows)
  • .NET 8 SDK или новее
  • Учётная запись, которая может одобрить повышение. Для учётной записи администратора это consent prompt, для стандартного пользователя — credential prompt. Если хотите проверить оба пути, подготовьте оба типа учётных записей
  • Машина, на которой можно менять HKLM. Образец создаёт HKLM\SOFTWARE\Classes\*\shell\MyApp.Open на уровне компьютера. Безопаснее пробовать на оценочной VM, а не на повседневной машине разработки

Конкретные команды сборки и запуска собраны в README образца. Один момент лучше запомнить сразу: UI и helper нужно опубликовать в одну папку и уже оттуда запускать (helper жёстко решает MyApp.exe в своей же папке).

1. Сначала вывод

Сначала практические решения, к которым стоит прийти.

  • обычное UI-приложение работает как asInvoker
  • операции, которым нужны права администратора, выносятся в отдельный EXE
  • этот helper EXE делается requireAdministrator
  • запуск идёт через runas
  • для обмена с helper используется IPC вроде именованного канала, а не стандартный ввод-вывод, который плохо сочетается с runas
  • helper получает не «сырую строку команды», а только типизированный запрос
  • на стороне helper содержимое запроса проверяется ещё раз
  • источник подключения IPC сужается SID вызывающего пользователя и ожидаемым PID

«Проще работать от администратора» — это верно только в первый раз. Потом почти неизбежно всплывают претензии из‑за UAC, drag-and-drop, схемы логов, внешнего ввода, сопровождения, загрузки DLL и места хранения настроек.

Каркас практических решенийUI остаётся asInvoker; операции с правами администратора выносятся в отдельный EXE с requireAdministrator, запускаются через runas и обмениваются с UI только типизированными запросами по именованному каналу.запуск через runasтипизированный запрос по именованному каналуUI остаётся asInvokerhelper EXE (requireAdministrator)helper ещё раз проверяет запрос

Рис. 2: Каркас задают три точки: UI без повышения, повышенный helper и IPC с типизированным запросом.

Карта знаний этой статьи

Статья разбирает конкретную реализацию, как в Windows-приложении вынести только операции, которым нужны права администратора. UAC управляет процессом через уровень целостности, и дочерний процесс наследует тот же уровень токена, поэтому повысить права только у части операций внутри одного процесса нельзя. Базовая схема — Administrator Broker Model: UI стандартного пользователя плюс helper EXE с правами администратора. Helper запускают через runas, UI и helper общаются по именованному каналу с явным PipeSecurity вместо ACL по умолчанию, а проверкой PID подключившейся стороны и allowlist, который на стороне helper принимает только фиксированные operation, закрывают точку произвольного выполнения команд. PipeOptions.CurrentUserOnly проверяет ещё и разницу уровней целостности, поэтому в этом сценарии его использовать нельзя.

Карта знаний: как вынести операции с правами администратораРисунок показывает, почему при ограничении уровнем целостности UAC модель Administrator Broker Model связывает UI стандартного пользователя и административный helper EXE именованным каналом и держит границу через ACL, проверку PID и allowlist операций, и как это сочетается с другими моделями разделения.используетиспользуеттребуеттребуетиспользуетиспользуетнастраиваетсяможет вызватьснижаетснижаетиспользуетиспользуетпредотвращаетрекомендуется длярекомендуется длярекомендуется длярекомендуется длянесовместимо стребуеттребуеттребуетAdministrator Broker ModelUAC (контроль учётных записей)уровень целостностиправа администратораrequestedExecutionLevel (уровень запуска)Verb=runas (ShellExecute)именованный каналявный ACL через PipeSecurityнесанкционированное подключение к именованному каналупроверка PID клиентаallowlist операций helperточка выполнения произвольных командредкие операции с правами администратораOperating System Service Modelпостоянные, частые и unattended операции администратораElevated Task Modelкороткая типовая задача администратораAdministrator COM Object Modelинтеграция с существующим COMPipeOptions.CurrentUserOnly

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Предпосылка: сделать администратором только часть того же процесса нельзя

UAC в Windows управляет не «повышением на уровне функций», а тем, с каким токеном / на каком уровне целостности работает процесс. Приложение, которому нужен токен доступа администратора, попадает под запрос повышения, а дочерний процесс наследует токен родителя на том же уровне целостности. То есть схема, в которой внутри UI-процесса без повышения вдруг один метод выполняется с правами администратора, невозможна. Если это нужно, берут другую единицу выполнения: отдельный процесс, службу, задачу, повышенный COM и т. п.

Если эту предпосылку отбросить, получается довольно неловкий запрос к проектированию: «хочу стать администратором только в момент нажатия этой кнопки». Windows этот разрыв волшебством не закрывает.

UAC управляет токеном и уровнем целостностиUAC управляет не повышением на уровне функций, а тем, с каким токеном и на каком уровне целостности работает процесс; родитель и потомок наследуют один уровень, поэтому повышение на уровне метода невозможно и нужна другая единица выполнения.Единица управления UACТокен процесса и уровень целостностиРодитель и потомок наследуют один уровеньПовышение на уровне метода невозможноДругой процесс, служба, задача, повышенный COM

Рис. 3: Раз единица управления — процесс, операцию, которую нужно повысить, остаётся только вынести в другую единицу выполнения.

2.1 Сначала зафиксировать соответствие integrity level (уровня целостности)

Дальше в статье появляются формулировки medium integrity / high integrity. Сразу зафиксируем, что они здесь значат.

Уровень целостности Смысл в этой статье Пример
medium процесс, который работает как стандартный пользователь UI-приложение с asInvoker
high повышенный процесс helper EXE с requireAdministrator

В Mandatory Integrity Control у Windows определены четыре ступени: low / medium / high / system. Стандартный пользователь получает medium, повышенный пользователь — high. То есть проектирование в этой статье — это разговор о том, как явно провести границу и дать общаться «UI-процессу на medium» и «helper-процессу на high».

Если это соответствие уже в голове, и рассказ про CurrentUserOnly в 5.6, и раздел 16.4 читаются без сопротивления.

Граница между medium и highUI с asInvoker, который работает как стандартный пользователь, получает medium; повышенный helper с requireAdministrator получает high; эта статья — о том, как явно провести границу между ними и дать им общаться.явно провести границу и общатьсяmedium: UI-процесс asInvokerhigh: повышенный helper-процессТокен стандартного пользователяТокен повышенного пользователя

Рис. 4: Эта схема — про явную границу между UI на medium и helper на high.

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 и завершить его.

Когда broker EXE ложится хорошоЕсли административная операция в обычное время не нужна и требуется только по конкретной кнопке на экране настроек, естественнее один раз запустить helper EXE и завершить его, чем выносить постоянно работающую службу.при такой частоте не нужноНажать конкретную кнопку на экране настроекОдин раз запустить helper EXEОперация закончена, helper завершаетсяВыносить постоянно работающую службу

Рис. 5: Для эпизодических административных операций естественнее всего helper EXE, который живёт только в нужный момент.

3.2 Службу выбирают для «постоянной», «без участия человека», «частой» работы

Служба — модель, в которой приложение стандартного пользователя общается с ней через RPC и подобные механизмы. Плюс в том, что административную обработку можно принимать без запроса повышения, но взамен растёт ответственность за эксплуатацию постоянно живущего процесса.

Компромисс модели со службойМодель со службой позволяет принимать административную обработку без запроса повышения, но взамен добавляет ответственность за эксплуатацию постоянно живущего процесса.плюсценаOperating System Service ModelМожно принимать без запроса повышенияОтветственность за постоянно живущий процессПодходит для постоянной, необслуживаемой, частой работы

Рис. 6: Служба — выбор «без приглашения», за который приходится нести постоянную эксплуатацию.

Служба подходит для таких сценариев:

  • постоянный мониторинг
  • сбор логов
  • фоновые обновления
  • постоянная связь с устройством или демоном
  • административные функции, общие для нескольких UI-сессий

3.3 Задача подходит для «короткой типовой операции»

Elevated Task Model — форма, в которой приложение стандартного пользователя запускает задачу планировщика, работающую с правами администратора. Она легче службы и закрывается по завершении, поэтому подходит для разовых типовых заданий.

3.4 Повышенный COM довольно узкий

COM elevation moniker выглядит удобным, но область применения узкая. В Microsoft Learn сказано, что UI, которым можно управлять повышенным COM, должен представлять сторона COM. То есть этот путь не подходит для схемы «дать UI без повышения делать с повышенным COM что угодно».

Узкая область применения повышенного COMПовышенный COM выглядит удобным, но UI, которым им можно управлять, должна представлять сторона COM; схема «дать UI без повышения делать с ним что угодно» не подходит.этот путь не подходитПовышенный COM выглядит удобнымНеограниченно управлять из UI без повышенияОбласть применения сужаетсяУправляемый UI представляет сторона COM

Рис. 7: У повышенного COM управляющий UI держит сторона COM, поэтому это не универсальный обходной путь.

4. Рекомендация этой статьи: UI asInvoker + helper EXE requireAdministrator

Дальше конкретизируем форму, которая на практике удобнее всего.

Границу повышения совместить с границей процессаUI остаётся на medium, короткоживущий helper работает на high; запуск идёт по абсолютному пути с Verb=runas, обмен — типизированными запросами по именованному каналу с ACL по SID и проверкой PID.high integrity — короткоживущий повышенный процессmedium integrity — до конца без повышениязапуск по абсолютному пути + Verb=runas(здесь появляется приглашение UAC)типизированный запрос(сырую строку команды не передаём)MyApp.AdminBroker.exe(requireAdministrator)Приём именованного каналаподключение разрешено только SID пользователя UIPID подключившейся стороны тоже сверяетсяРазбор по allowlist operationаргументы helper проверяет ещё разMyApp.exe(asInvoker)только принимает действия пользователя и собирает запросФиксированные объекты, которым нужны права администратораключи под HKLM / регистрация службы / правила брандмауэра

Рис. 8: Границу повышения совмещаем с границей процесса. UI остаётся на medium, на high коротко работает только helper.

Ключевых пунктов три.

  1. UI-процесс до конца остаётся без повышения
  2. административный helper короткоживущий
  3. helper принимает только операции из фиксированного allowlist

Одного соблюдения этих трёх пунктов уже достаточно, чтобы проектирование заметно упорядочилось.

Три пункта, которые нужно соблюдатьUI-процесс до конца остаётся без повышения, административный helper короткоживущий, helper принимает только операции из фиксированного allowlist — этих трёх пунктов достаточно, чтобы проектирование заметно упорядочилось.UI до конца без повышенияПроектирование заметно упорядочиваетсяhelper короткоживущийПринимаются только операции из allowlist

Рис. 9: UI без повышения, короткоживущий helper и allowlist сами задают форму границы прав.

5. Правила, которые не хочется пропустить при реализации

Это лучше решить до того, как писать код.

5.1 Не превращать helper в универсальный исполнитель

Плохие примеры такие.

  • UI целиком передаёт helper строку вида reg add ...
  • UI целиком передаёт helper строку вида sc.exe ...
  • UI передаёт helper произвольный путь в реестре или произвольный путь к EXE

Если так сделать, поломка UI сразу утащит за собой helper. Административный helper стоит внутри границы повышения. Сделать здесь «дыру, через которую можно выполнить что угодно», довольно опасно.

Хорошая форма выглядит так.

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

то есть сами операции фиксируются, а нужные аргументы сводятся к bool / enum / числам / ограниченным строкам.

Как не сделать универсальный исполнительЕсли передать helper сырую строку команды или произвольный путь, появляется дыра для произвольного выполнения, и поломка UI сразу ломает helper; поэтому операции фиксируют, а аргументы сводят к ограниченным типам.Передать сырую строку командыПоявляется дыра для произвольного выполненияПоломка UI ломает и helperЗафиксировать операции и ограничить типы аргументовСмысл helper сужается

Рис. 10: Внутрь границы повышения передают только фиксированные операции и ограниченные аргументы.

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. Если положиться на значение по умолчанию, позже всплывает неприятный сбой: «в одних окружениях работает, в других — нет».

Поэтому здесь значение задают явно.

Что явно задать при запуске через runasVerb у ProcessStartInfo действует только при UseShellExecute=true, а значение по умолчанию различается между .NET Framework и .NET; если положиться на умолчание, в одних окружениях запуск сработает, в других нет, поэтому значение задают явно.если положитьсяпоэтомуНужен Verb=runasНужен UseShellExecute=trueУмолчание разное в Framework и в .NETВ одних окружениях работает, в других нетЗадать явно

Рис. 11: У Verb есть условие срабатывания и разные умолчания, поэтому UseShellExecute фиксируют явно.

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. Если свести это в таблицу, проще увидеть, зачем нужен явный ACL.

Учётная запись, под которой работает UI Какое приглашение UAC появляется Под какой учётной записью работает helper WindowsIdentity.GetCurrent() helper, который создаёт канал SID стороны UI, которая подключается
Учётная запись администратора (без повышения) consent prompt (достаточно нажать «Да») повышенный токен того же пользователя тот же пользователь, что и UI пользователь UI
Стандартный пользователь credential prompt (ввод учётных данных другой учётной записи) другая введённая учётная запись администратора другой пользователь, не UI пользователь UI

Читать таблицу так.

  • В верхней строке «текущий пользователь helper = пользователь UI», поэтому если helper соберёт ACL только по своему SID, канал случайно всё равно откроется
  • В нижней строке текущий пользователь helper и пользователь UI — разные люди. Если собрать ACL только по WindowsIdentity.GetCurrent(), исходный пользователь UI не сможет отправить свой запрос
  • В обеих строках CurrentUserOnly отсекает подключение из‑за разницы уровней повышения: «UI на medium» и «helper на high»

То есть единственная форма, которая работает в обеих строках, — взять SID со стороны UI и выдать право подключения именно этому SID.

Поэтому здесь делается так:

  • UI сам получает свой SID и передаёт его helper
  • helper выдаёт право подключения к каналу только SID пользователя UI
  • дополнительно helper проверяет PID подключившейся стороны через GetNamedPipeClientProcessId
Передача SID закрывает оба пути приглашенияЕдинственная форма, которая работает и для consent prompt, и для credential prompt: UI берёт свой SID и передаёт его helper, helper выдаёт право подключения только этому SID и дополнительно проверяет PID подключившейся стороны.отсекается разницей уровней повышенияUI берёт свой SID и передаёт егоhelper выдаёт право подключения только этому SIDPID подключившейся стороны тоже проверяетсяИспользовать CurrentUserOnlyВ этом сценарии не работает

Рис. 12: На обоих путях приглашения работает только ACL, собранный по SID, который передал UI.

5.7 Проверка PID — дополнительная защита от небрежного чужого подключения

Случайное имя канала само по себе уже заметно поднимает планку, но вероятность, что другой процесс того же пользователя подключится первым, не равна нулю. Поэтому helper использует GetNamedPipeClientProcessId и проверяет, совпадает ли PID подключившейся стороны с ожидаемым PID UI-процесса.

Разумеется, совпадение PID не значит, что запросу можно верить во всём. Если UI скомпрометирован, до helper дойдут и опасные запросы. Именно поэтому на стороне helper нужны allowlist операций и проверка аргументов.

Дополнительная защита слоямиСлучайное имя канала, ACL только для нужного SID, сверка PID подключившейся стороны, allowlist операций и повторная проверка аргументов накладываются слоями, чтобы снизить и чужие подключения, и опасные запросы.Случайное имя каналаACL только для нужного SIDСверка PID подключившейся стороныallowlist и повторная проверка аргументовДаже при совпадении PID запросу не верят

Рис. 13: Ни один слой не полон сам по себе, поэтому сужают и подключение, и сам запрос.

Если выстроить эти правила от запуска до завершения, получается такая последовательность. Достаточно увидеть, что каждому шагу UI соответствует проверка на стороне helper.

Запуску, подключению и запросу соответствует проверка helperКаждому шагу UI соответствует проверка на стороне helper; если убрать любой из них, этот этап проходит без контроля.Объект, которому нужны права администратораAdminBroker.exe(high)Windows / UACMyApp.exe(medium)Объект, которому нужны права администратораAdminBroker.exe(high)Windows / UACMyApp.exe(medium)Выбрать имя канала, подготовить свой SID и PIDЗапуск по абсолютному пути + Verb=runasПосле одобрения запуск с повышенным токеномСоздать канал с ACL только для этого SIDПодключиться к каналуСверить PID подключившейся стороныТипизированный запрос (имя operation и аргументы)Отклонить operation вне allowlist и неожиданные аргументыДействовать только на фиксированный объектВернуть результатЗавершиться и не оставлять повышенное состояние

Рис. 14: Запуску, подключению и запросу соответствует проверка на стороне helper. Если убрать любой шаг, этот этап проходит без контроля.

6. Тема образца

В качестве примера берём регистрацию / снятие пункта контекстного меню Explorer на уровне компьютера.

Причина простая:

  • нужны права администратора
  • граница операции чёткая
  • helper не приходится передавать произвольную строку команды
  • на практике такой сценарий вполне обычен

Куда регистрируется — фиксированные ключи вроде следующих.

  • HKLM\SOFTWARE\Classes\*\shell\MyApp.Open
  • HKLM\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. requireAdministrator — только у helper. Если поменять их местами, смысл разделения пропадает.

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 в канал сплошным потоком, а отправлять его с префиксом длины. Простой протокол «один запрос — один ответ» реже ломается.

Простой протокол с префиксом длиныJSON не льют в канал как есть, а отправляют с заголовком длины; простой протокол из одного запроса и одного ответа реже ломается.Записать заголовок длиныЗаписать JSON телаСобеседник читает ровно указанную длинуРаботает простота: 1 запрос, 1 ответ

Рис. 15: Сообщение отправляют с длиной и оставляют один запрос на один ответ — так меньше сбоев.

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, который идёт по каналу.

Разделение ролей между аргументами запуска и каналомВ аргументах запуска helper передают только имя канала и минимум сведений для проверки подключившейся стороны, а саму административную операцию закрывают в типизированный request внутри канала.передаёмзакрываемАргументы запускаминимум: имя канала, PID, SIDВнутри каналаадминистративную операцию в типизированном requestСодержимое операции в аргументы не кладём

Рис. 16: Аргументы — только подготовка подключения; содержимое операции закрывают в типизированный 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 подключившейся стороны, 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);
}

Здесь работают такие моменты:

  • ACL канала собирается явно
  • ACL выдаётся не только текущему SID пользователя helper, но и SID пользователя UI, который его вызвал
  • после подключения проверяется client PID
  • даже после получения request dispatch идёт по имени operation

Если оставить форму, в которой switch (request.Operation) пропускает только фиксированные операции, helper с меньшей вероятностью станет «повышенным ящиком, который делает что угодно».

Порядок проверок, которые работают на стороне helperHelper явно собирает ACL канала, после подключения проверяет PID клиента и разбирает request по имени operation, пропуская только фиксированные операции.внутри allowlistвне allowlistСоздать канал с явным ACLПроверить PID подключившейся стороныdispatch по имени operationВыполнить только фиксированную операциюОтклонить и ответить

Рис. 17: До фиксированной операции доходит только request, прошедший ACL, PID и dispatch.

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 можно сделать что угодно».

15.6 Проверить, что разделение действительно получилось

До сих пор это был разговор про проектирование. Действительно ли написанное разделено, без запуска не понять. «Весь UI незаметно оказался повышенным» — сбой, который по коду легко не заметить.

Четыре шага проверки, что разделение получилосьПо очереди проверяют, остался ли UI-процесс без повышения, появляется ли приглашение UAC только при запуске helper, сработала ли административная операция и отказывает ли то, что должно отказывать.UI остался без повышения?Приглашение появляется только при запуске helper?Операция действительно сработала?Отказывает ли то, что должно отказывать?Без четвёртого шага разделение ещё не подтверждено

Рис. 18: Если пройти четыре шага на живой системе, ловятся повышения, которые по коду не видны.

Проверку смотрят по четырём пунктам по порядку.

1. UI-процесс остался без повышения?

Это самый важный пункт. UI запускают и проверяют после того, как административную операцию уже один раз выполнили.

  • Диспетчер задач: на вкладке «Подробности» щёлкните правой кнопкой по заголовкам столбцов и включите столбец «С повышенными правами» (Elevated). Если у MyApp.exe стоит «Нет», а «Да» только у MyApp.AdminBroker.exe, это ожидаемое поведение
  • Process Explorer: включите столбец Integrity. Если у UI Medium, а у helper High, это верно (уровни целостности Windows — как в 2.1: стандартный пользователь = medium, повышенный = high)

Если смотреть из кода, достаточно один раз проверить сразу после запуска UI.

using System.Security.Principal;

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

// в UI-процессе должно быть false
bool isElevatedAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator);

2. Приглашение UAC появляется только при запуске helper?

  • Приглашение при запуске UI -> манифест стороны UI не asInvoker
  • Появляется в момент нажатия флажка на экране настроек -> ожидаемое поведение
  • Приглашения ни разу нет, а настройка меняется -> helper, возможно, постоянно повышен по другому пути

Для учётной записи администратора это consent prompt, для стандартного пользователя — credential prompt (таблица в 5.6). Попробуйте оба, и станет видно, правильно ли передаётся SID.

3. Административная операция действительно сработала?

Для регистрации меню Explorer быстрее всего смотреть реестр напрямую.

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

Снятие проверяют так же. Если попробовать только регистрацию и не трогать снятие, ошибка на стороне DeleteSubKeyTree останется незамеченной.

4. Отказывает ли то, что должно отказывать?

Пока этого не проверить, нельзя сказать, получилось ли разделение.

  • Отменить приглашение повышения -> настройка не меняется, флажок UI возвращается назад (обработка ERROR_CANCELLED = 1223, глава 10)
  • Запустить helper напрямую -> даже если вручную вызвать MyApp.AdminBroker.exe --pipe x --client-pid 1 --client-sid S-1-5-18, проверка PID подключившейся стороны и тайм-аут не дают обработке пойти дальше
  • Отправить operation вне allowlist -> отказ с unsupported_operation (DispatchAsync в главе 12)

Конкретные команды той же последовательности собраны в README образца, в разделе «Порядок проверки в Windows».

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 и административной работой сама становится активом проектирования.

Граница становится активом проектированияЕсли контракт operation разделён явно, граница между UI и административной работой становится активом проектирования и позже проще переносится на службу.Разделить контракт operationГраница UI и административной работы яснаГраница сама становится активом проектированияПроще перейти на службу

Рис. 19: Граница, проведённая в схеме broker, остаётся активом и тогда, когда позже захочется перейти на службу.

В безопасности сильнее не добавлять эффектные функции, а не оставлять небрежных границ. С правами администратора то же самое. Не выдавать их все скопом, а передавать только туда, где они действительно нужны, и как можно уже. Именно такая неприметная аккуратность потом окупается.

18. Справочные материалы

Часть ссылок ниже содержит в URL указание версии вроде view=net-10.0. Это указание, какую версию документации .NET показать на стороне Microsoft Learn, а не знак того, что она расходится с предпосылкой этой статьи — .NET 8. Используемые здесь PipeOptions / NamedPipeServerStreamAcl / RegistryView доступны и в .NET 8. Если хотите смотреть страницу как .NET 8, переключите версию селектором вверху страницы.

  • Полный набор примеров кода к этой статье (общая библиотека контрактов, демо, модульные тесты) 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
  • Mandatory Integrity Control - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control
  • Process Explorer - Sysinternals https://learn.microsoft.com/en-us/sysinternals/downloads/process-explorer
  • WindowsPrincipal.IsInRole Method https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsprincipal.isinrole
  • ProcessStartInfo.UseShellExecute https://learn.microsoft.com/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

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Можно ли выполнить с правами администратора только часть операций внутри того же процесса?
Нет. 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 он не подходит. В Windows CurrentUserOnly проверяет не только учётную запись, но и уровень повышения, поэтому процессы с разными уровнями целостности подключиться не смогут. Кроме того, у стандартного пользователя UAC может стать credential prompt, и helper иногда запускается под другой учётной записью администратора. Проще, когда UI сам берёт свой SID, передаёт его helper, а helper явно выдаёт право подключения к каналу только этому SID.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог