Как в 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-приложении нельзя взять и выполнить «от имени администратора» только часть операций внутри того же процесса. Повышение прав — это граница процесса, поэтому нужна схема, которая выносит именно эту операцию в отдельную единицу выполнения.
flowchart TB
accTitle: Повышение прав — это граница процесса
accDescr: Нельзя выполнить от имени администратора только часть операций внутри того же процесса; повышение прав — граница процесса, поэтому нужна схема, которая выносит эту операцию в отдельную единицу выполнения.
wish1["Нужны права администратора только для части операций"] -.->|"внутри того же процесса нельзя"| in1["Повышение внутри того же процесса"]
wish1 -->|"доступен этот путь"| cut1["Вынести операцию в другую единицу выполнения"]
Рис. 1: Повышение прав — не на уровне функции, а на уровне процесса, поэтому работает только схема с вынесением.
Статья идёт в таком порядке.
- Сначала предпосылки
- Какую модель разделения выбрать
- Самая удобная на практике форма:
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–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 и места хранения настроек.
flowchart TB
accTitle: Каркас практических решений
accDescr: UI остаётся asInvoker; операции с правами администратора выносятся в отдельный EXE с requireAdministrator, запускаются через runas и обмениваются с UI только типизированными запросами по именованному каналу.
uix1["UI остаётся asInvoker"] -->|"запуск через runas"| hx1["helper EXE (requireAdministrator)"]
uix1 -->|"типизированный запрос по именованному каналу"| hx1
hx1 --> vfy1["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 проверяет ещё и разницу уровней целостности, поэтому в этом сценарии его использовать нельзя.
flowchart LR
accTitle: Карта знаний: как вынести операции с правами администратора
accDescr: Рисунок показывает, почему при ограничении уровнем целостности UAC модель Administrator Broker Model связывает UI стандартного пользователя и административный helper EXE именованным каналом и держит границу через ACL, проверку PID и allowlist операций, и как это сочетается с другими моделями разделения.
administrator_broker_model["Administrator Broker Model"]
uac["UAC (контроль учётных записей)"]
integrity_level["уровень целостности"]
admin_rights["права администратора"]
requested_execution_level["requestedExecutionLevel (уровень запуска)"]
runas_verb["Verb=runas (ShellExecute)"]
named_pipe["именованный канал"]
pipe_security_acl["явный ACL через PipeSecurity"]
unauthorized_pipe_access["несанкционированное подключение к именованному каналу"]
client_pid_verification["проверка PID клиента"]
helper_operation_allowlist["allowlist операций helper"]
arbitrary_command_execution["точка выполнения произвольных команд"]
sporadic_admin_operation["редкие операции с правами администратора"]
os_service_model["Operating System Service Model"]
continuous_unattended_admin_operation["постоянные, частые и unattended операции администратора"]
elevated_task_model["Elevated Task Model"]
short_scheduled_admin_task["короткая типовая задача администратора"]
admin_com_object_model["Administrator COM Object Model"]
existing_com_integration["интеграция с существующим COM"]
currentuseronly_pipeoption["PipeOptions.CurrentUserOnly"]
administrator_broker_model -->|"использует"| uac
uac -->|"использует"| integrity_level
uac -.->|"требует"| admin_rights
administrator_broker_model -->|"требует"| requested_execution_level
runas_verb -->|"использует"| uac
administrator_broker_model -.->|"использует"| named_pipe
named_pipe -->|"настраивается"| pipe_security_acl
named_pipe -.->|"может вызвать"| unauthorized_pipe_access
pipe_security_acl -->|"снижает"| unauthorized_pipe_access
client_pid_verification -->|"снижает"| unauthorized_pipe_access
administrator_broker_model -.->|"использует"| client_pid_verification
administrator_broker_model -->|"использует"| helper_operation_allowlist
helper_operation_allowlist -->|"предотвращает"| arbitrary_command_execution
administrator_broker_model -->|"рекомендуется для"| sporadic_admin_operation
os_service_model -->|"рекомендуется для"| continuous_unattended_admin_operation
elevated_task_model -->|"рекомендуется для"| short_scheduled_admin_task
admin_com_object_model -->|"рекомендуется для"| existing_com_integration
currentuseronly_pipeoption -->|"несовместимо с"| administrator_broker_model
os_service_model -->|"требует"| admin_rights
elevated_task_model -->|"требует"| admin_rights
admin_com_object_model -->|"требует"| admin_rights
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. Предпосылка: сделать администратором только часть того же процесса нельзя
UAC в Windows управляет не «повышением на уровне функций», а тем, с каким токеном / на каком уровне целостности работает процесс. Приложение, которому нужен токен доступа администратора, попадает под запрос повышения, а дочерний процесс наследует токен родителя на том же уровне целостности. То есть схема, в которой внутри UI-процесса без повышения вдруг один метод выполняется с правами администратора, невозможна. Если это нужно, берут другую единицу выполнения: отдельный процесс, службу, задачу, повышенный COM и т. п.
Если эту предпосылку отбросить, получается довольно неловкий запрос к проектированию: «хочу стать администратором только в момент нажатия этой кнопки». Windows этот разрыв волшебством не закрывает.
flowchart TB
accTitle: UAC управляет токеном и уровнем целостности
accDescr: UAC управляет не повышением на уровне функций, а тем, с каким токеном и на каком уровне целостности работает процесс; родитель и потомок наследуют один уровень, поэтому повышение на уровне метода невозможно и нужна другая единица выполнения.
uac1["Единица управления UAC"] --> tk1["Токен процесса и уровень целостности"]
tk1 --> inh1["Родитель и потомок наследуют один уровень"]
inh1 --> no1["Повышение на уровне метода невозможно"]
no1 --> alt2["Другой процесс, служба, задача, повышенный 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 читаются без сопротивления.
flowchart TB
accTitle: Граница между medium и high
accDescr: UI с asInvoker, который работает как стандартный пользователь, получает medium; повышенный helper с requireAdministrator получает high; эта статья — о том, как явно провести границу между ними и дать им общаться.
med1["medium: UI-процесс asInvoker"] -->|"явно провести границу и общаться"| hg1["high: повышенный helper-процесс"]
med1 -.-> sd2["Токен стандартного пользователя"]
hg1 -.-> ad2["Токен повышенного пользователя"]
Рис. 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 и завершить его.
flowchart TB
accTitle: Когда broker EXE ложится хорошо
accDescr: Если административная операция в обычное время не нужна и требуется только по конкретной кнопке на экране настроек, естественнее один раз запустить helper EXE и завершить его, чем выносить постоянно работающую службу.
btn1["Нажать конкретную кнопку на экране настроек"] --> once2["Один раз запустить helper EXE"]
once2 --> done1["Операция закончена, helper завершается"]
btn1 -.->|"при такой частоте не нужно"| svc2["Выносить постоянно работающую службу"]
Рис. 5: Для эпизодических административных операций естественнее всего helper EXE, который живёт только в нужный момент.
3.2 Службу выбирают для «постоянной», «без участия человека», «частой» работы
Служба — модель, в которой приложение стандартного пользователя общается с ней через RPC и подобные механизмы. Плюс в том, что административную обработку можно принимать без запроса повышения, но взамен растёт ответственность за эксплуатацию постоянно живущего процесса.
flowchart TB
accTitle: Компромисс модели со службой
accDescr: Модель со службой позволяет принимать административную обработку без запроса повышения, но взамен добавляет ответственность за эксплуатацию постоянно живущего процесса.
svm1["Operating System Service Model"] -->|"плюс"| npr1["Можно принимать без запроса повышения"]
svm1 -->|"цена"| res2["Ответственность за постоянно живущий процесс"]
svm1 -.-> fitx["Подходит для постоянной, необслуживаемой, частой работы"]
Рис. 6: Служба — выбор «без приглашения», за который приходится нести постоянную эксплуатацию.
Служба подходит для таких сценариев:
- постоянный мониторинг
- сбор логов
- фоновые обновления
- постоянная связь с устройством или демоном
- административные функции, общие для нескольких UI-сессий
3.3 Задача подходит для «короткой типовой операции»
Elevated Task Model — форма, в которой приложение стандартного пользователя запускает задачу планировщика, работающую с правами администратора. Она легче службы и закрывается по завершении, поэтому подходит для разовых типовых заданий.
3.4 Повышенный COM довольно узкий
COM elevation moniker выглядит удобным, но область применения узкая. В Microsoft Learn сказано, что UI, которым можно управлять повышенным COM, должен представлять сторона COM. То есть этот путь не подходит для схемы «дать UI без повышения делать с повышенным COM что угодно».
flowchart TB
accTitle: Узкая область применения повышенного COM
accDescr: Повышенный COM выглядит удобным, но UI, которым им можно управлять, должна представлять сторона COM; схема «дать UI без повышения делать с ним что угодно» не подходит.
look1["Повышенный COM выглядит удобным"] -.-> free1["Неограниченно управлять из UI без повышения"]
free1 -.->|"этот путь не подходит"| ngc1["Область применения сужается"]
ui2["Управляемый UI представляет сторона COM"] --> ngc1
Рис. 7: У повышенного COM управляющий UI держит сторона COM, поэтому это не универсальный обходной путь.
4. Рекомендация этой статьи: UI asInvoker + helper EXE requireAdministrator
Дальше конкретизируем форму, которая на практике удобнее всего.
flowchart TB
accTitle: Границу повышения совместить с границей процесса
accDescr: UI остаётся на medium, короткоживущий helper работает на high; запуск идёт по абсолютному пути с Verb=runas, обмен — типизированными запросами по именованному каналу с ACL по SID и проверкой PID.
subgraph MED["medium integrity — до конца без повышения"]
UI["MyApp.exe(asInvoker)<br/>только принимает действия пользователя и собирает запрос"]
end
subgraph HIGH["high integrity — короткоживущий повышенный процесс"]
BR["MyApp.AdminBroker.exe<br/>(requireAdministrator)"]
PIPE["Приём именованного канала<br/>подключение разрешено только SID пользователя UI<br/>PID подключившейся стороны тоже сверяется"]
DISP["Разбор по allowlist operation<br/>аргументы helper проверяет ещё раз"]
end
TGT["Фиксированные объекты, которым нужны права администратора<br/>ключи под HKLM / регистрация службы / правила брандмауэра"]
UI -->|"запуск по абсолютному пути + Verb=runas<br/>(здесь появляется приглашение UAC)"| BR
BR --> PIPE
UI -->|"типизированный запрос<br/>(сырую строку команды не передаём)"| PIPE
PIPE --> DISP
DISP --> TGT
Рис. 8: Границу повышения совмещаем с границей процесса. UI остаётся на medium, на high коротко работает только helper.
Ключевых пунктов три.
- UI-процесс до конца остаётся без повышения
- административный helper короткоживущий
- helper принимает только операции из фиксированного allowlist
Одного соблюдения этих трёх пунктов уже достаточно, чтобы проектирование заметно упорядочилось.
flowchart TB
accTitle: Три пункта, которые нужно соблюдать
accDescr: UI-процесс до конца остаётся без повышения, административный helper короткоживущий, helper принимает только операции из фиксированного allowlist — этих трёх пунктов достаточно, чтобы проектирование заметно упорядочилось.
p1["UI до конца без повышения"] --> tidy1["Проектирование заметно упорядочивается"]
p2["helper короткоживущий"] --> tidy1
p3["Принимаются только операции из allowlist"] --> tidy1
Рис. 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-menuinstall-serviceadd-firewall-rule
то есть сами операции фиксируются, а нужные аргументы сводятся к bool / enum / числам / ограниченным строкам.
flowchart TB
accTitle: Как не сделать универсальный исполнитель
accDescr: Если передать helper сырую строку команды или произвольный путь, появляется дыра для произвольного выполнения, и поломка UI сразу ломает helper; поэтому операции фиксируют, а аргументы сводят к ограниченным типам.
raw1["Передать сырую строку команды"] --> hole1["Появляется дыра для произвольного выполнения"]
hole1 --> both1["Поломка UI ломает и helper"]
fix2["Зафиксировать операции и ограничить типы аргументов"] --> narrow1["Смысл 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.
Если положиться на значение по умолчанию, позже всплывает неприятный сбой: «в одних окружениях работает, в других — нет».
Поэтому здесь значение задают явно.
flowchart TB
accTitle: Что явно задать при запуске через runas
accDescr: Verb у ProcessStartInfo действует только при UseShellExecute=true, а значение по умолчанию различается между .NET Framework и .NET; если положиться на умолчание, в одних окружениях запуск сработает, в других нет, поэтому значение задают явно.
vb1["Нужен Verb=runas"] --> req2["Нужен UseShellExecute=true"]
req2 -.-> defd1["Умолчание разное в Framework и в .NET"]
defd1 -->|"если положиться"| envd1["В одних окружениях работает, в других нет"]
req2 -->|"поэтому"| exp2["Задать явно"]
Рис. 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
flowchart TB
accTitle: Передача SID закрывает оба пути приглашения
accDescr: Единственная форма, которая работает и для consent prompt, и для credential prompt: UI берёт свой SID и передаёт его helper, helper выдаёт право подключения только этому SID и дополнительно проверяет PID подключившейся стороны.
sid1["UI берёт свой SID и передаёт его"] --> aclx["helper выдаёт право подключения только этому SID"]
aclx --> pidx["PID подключившейся стороны тоже проверяется"]
cuo1["Использовать CurrentUserOnly"] -.->|"отсекается разницей уровней повышения"| ngz1["В этом сценарии не работает"]
Рис. 12: На обоих путях приглашения работает только ACL, собранный по SID, который передал UI.
5.7 Проверка PID — дополнительная защита от небрежного чужого подключения
Случайное имя канала само по себе уже заметно поднимает планку, но вероятность, что другой процесс того же пользователя подключится первым, не равна нулю.
Поэтому helper использует GetNamedPipeClientProcessId и проверяет, совпадает ли PID подключившейся стороны с ожидаемым PID UI-процесса.
Разумеется, совпадение PID не значит, что запросу можно верить во всём. Если UI скомпрометирован, до helper дойдут и опасные запросы. Именно поэтому на стороне helper нужны allowlist операций и проверка аргументов.
flowchart TB
accTitle: Дополнительная защита слоями
accDescr: Случайное имя канала, ACL только для нужного SID, сверка PID подключившейся стороны, allowlist операций и повторная проверка аргументов накладываются слоями, чтобы снизить и чужие подключения, и опасные запросы.
l1["Случайное имя канала"] --> l2["ACL только для нужного SID"]
l2 --> l3["Сверка PID подключившейся стороны"]
l3 --> l4["allowlist и повторная проверка аргументов"]
l4 -.-> why3["Даже при совпадении PID запросу не верят"]
Рис. 13: Ни один слой не полон сам по себе, поэтому сужают и подключение, и сам запрос.
Если выстроить эти правила от запуска до завершения, получается такая последовательность. Достаточно увидеть, что каждому шагу UI соответствует проверка на стороне helper.
sequenceDiagram
accTitle: Запуску, подключению и запросу соответствует проверка helper
accDescr: Каждому шагу UI соответствует проверка на стороне helper; если убрать любой из них, этот этап проходит без контроля.
participant UI as MyApp.exe(medium)
participant OS as Windows / UAC
participant BR as AdminBroker.exe(high)
participant TGT as Объект, которому нужны права администратора
UI->>UI: Выбрать имя канала, подготовить свой SID и PID
UI->>OS: Запуск по абсолютному пути + Verb=runas
OS->>BR: После одобрения запуск с повышенным токеном
BR->>BR: Создать канал с ACL только для этого SID
UI->>BR: Подключиться к каналу
BR->>BR: Сверить PID подключившейся стороны
UI->>BR: Типизированный запрос (имя operation и аргументы)
BR->>BR: Отклонить operation вне allowlist и неожиданные аргументы
BR->>TGT: Действовать только на фиксированный объект
BR-->>UI: Вернуть результат
BR->>BR: Завершиться и не оставлять повышенное состояние
Рис. 14: Запуску, подключению и запросу соответствует проверка на стороне 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.
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 в канал сплошным потоком, а отправлять его с префиксом длины. Простой протокол «один запрос — один ответ» реже ломается.
flowchart TB
accTitle: Простой протокол с префиксом длины
accDescr: JSON не льют в канал как есть, а отправляют с заголовком длины; простой протокол из одного запроса и одного ответа реже ломается.
m1["Записать заголовок длины"] --> m2["Записать JSON тела"]
m2 --> m3["Собеседник читает ровно указанную длину"]
m3 -.-> simple1["Работает простота: 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, который идёт по каналу.
flowchart TB
accTitle: Разделение ролей между аргументами запуска и каналом
accDescr: В аргументах запуска helper передают только имя канала и минимум сведений для проверки подключившейся стороны, а саму административную операцию закрывают в типизированный request внутри канала.
argx["Аргументы запуска"] -->|"передаём"| minx["минимум: имя канала, PID, SID"]
pipx["Внутри канала"] -->|"закрываем"| reqx["административную операцию в типизированном request"]
minx -.-> nolx["Содержимое операции в аргументы не кладём"]
Рис. 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 с меньшей вероятностью станет «повышенным ящиком, который делает что угодно».
flowchart TB
accTitle: Порядок проверок, которые работают на стороне helper
accDescr: Helper явно собирает ACL канала, после подключения проверяет PID клиента и разбирает request по имени operation, пропуская только фиксированные операции.
hs1["Создать канал с явным ACL"] --> hs2["Проверить PID подключившейся стороны"]
hs2 --> hs3["dispatch по имени operation"]
hs3 -->|"внутри allowlist"| hs4["Выполнить только фиксированную операцию"]
hs3 -->|"вне allowlist"| hs5["Отклонить и ответить"]
Рис. 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 незаметно оказался повышенным» — сбой, который по коду легко не заметить.
flowchart TB
accTitle: Четыре шага проверки, что разделение получилось
accDescr: По очереди проверяют, остался ли UI-процесс без повышения, появляется ли приглашение UAC только при запуске helper, сработала ли административная операция и отказывает ли то, что должно отказывать.
v1["UI остался без повышения?"] --> v2["Приглашение появляется только при запуске helper?"]
v2 --> v3["Операция действительно сработала?"]
v3 --> v4["Отказывает ли то, что должно отказывать?"]
v4 -.-> whyv["Без четвёртого шага разделение ещё не подтверждено"]
Рис. 18: Если пройти четыре шага на живой системе, ловятся повышения, которые по коду не видны.
Проверку смотрят по четырём пунктам по порядку.
1. UI-процесс остался без повышения?
Это самый важный пункт. UI запускают и проверяют после того, как административную операцию уже один раз выполнили.
- Диспетчер задач: на вкладке «Подробности» щёлкните правой кнопкой по заголовкам столбцов и включите столбец «С повышенными правами» (Elevated). Если у
MyApp.exeстоит «Нет», а «Да» только уMyApp.AdminBroker.exe, это ожидаемое поведение - Process Explorer: включите столбец Integrity. Если у UI
Medium, а у helperHigh, это верно (уровни целостности 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 и административной работой сама становится активом проектирования.
flowchart TB
accTitle: Граница становится активом проектирования
accDescr: Если контракт operation разделён явно, граница между UI и административной работой становится активом проектирования и позже проще переносится на службу.
ctr1["Разделить контракт operation"] --> bd1["Граница UI и административной работы ясна"]
bd1 --> asset1["Граница сама становится активом проектирования"]
asset1 -.-> future1["Проще перейти на службу"]
Рис. 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
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Хранение секретов в Windows-приложениях — избегаем настроек в открытом виде с помощью DPAPI
Чтобы не хранить сведения о подключении и API-токены в конфигурационных файлах Windows-приложений открытым текстом, разбираем подход DPAP...
Минимальный чек-лист безопасности при разработке Windows-приложений
Чек-лист базовых мер безопасности для бизнес-приложений на WPF / WinForms / WinUI / C++ / C#: права, подпись кода, обновления, секреты, H...
Когда в Windows нужны права администратора — UAC, защищённые области и как это отличить на этапе проектирования
Разбираем на практике, когда в Windows нужны права администратора: UAC, защищённые области, службы, драйверы и проектирование per-user / ...
Глубины ввода-вывода Windows (часть 6, финал) — фильтры и минифильтры: почему Procmon и антивирусное сканирование могут перехватывать I/O
Финал серии со схемами драйверов фильтров и минифильтров Windows. Диспетчер фильтров и высота (altitude), обратные вызовы pre/post, как P...
Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke (DllImport / LibraryImport / CsWin32)
Разбираем, как на практике вызывать Win32 API из C# через P/Invoke: чем DllImport отличается от LibraryImport, как CsWin32 генерирует сиг...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы 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 он не подходит. В Windows CurrentUserOnly проверяет не только учётную запись, но и уровень повышения, поэтому процессы с разными уровнями целостности подключиться не смогут. Кроме того, у стандартного пользователя UAC может стать credential prompt, и helper иногда запускается под другой учётной записью администратора. Проще, когда UI сам берёт свой SID, передаёт его helper, а helper явно выдаёт право подключения к каналу только этому SID.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.