引用本文(DOI: 10.5281/zenodo.21615470)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《在 Windows 应用中把“只需要管理员权限的处理”分离出来的具体写法》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615470 https://comcomponent.com/zh-CN/blog/2026/03/16/001-windows-admin-broker-deep-dive/
- DOI(最新版本)
- 10.5281/zenodo.21615470
- DOI(此版本)
- 10.5281/zenodo.22281988
在之前写的《Windows 应用开发中为了守住最低限度安全底线的检查清单》里,给出的思路是以 asInvoker 为基础,只把需要管理员权限的处理分离出来。
这次要更进一步,深入到具体该怎么写代码。
在 Windows 应用中,没办法只让同一个进程里的一部分处理随心所欲地“以管理员身份运行”。 权限提升是进程边界层面的事情,所以真正需要的是“只把那部分处理拆分到另一个执行单元”的设计。
flowchart TB
accTitle: 权限提升是进程边界层面的事情
accDescr: 说明无法只让同一个进程里的一部分处理以管理员身份运行,权限提升是进程边界层面的事情,因此需要把那部分处理拆分到另一个执行单元的设计。
wish1["只想让一部分处理拥有管理员权限"] -.->|"同一进程内无法做到"| in1["在同一个进程中提升权限"]
wish1 -->|"能做到的是这条"| cut1["把处理拆分到另一个执行单元"]
图1:权限提升的单位是进程而不是函数,因此只有拆分出去的设计才成立。
本文按以下顺序展开:
- 先说前提
- 该选择哪种分离模型
- 实务中最好用的
asInvoker+ 管理员 helper EXE 形态 - 实现时不想踩中的坑
- 具体的代码示例
代码示例以 .NET 8 / Windows 桌面应用为前提。 UI 框架用 WPF / WinForms / WinUI 都可以,差异大致只体现在 UI 端的事件处理程序上。
另外,本文中出现的代码,已作为可构建、可运行的完整示例(公共契约库、UI / 管理员 helper 演示、以及可在 Linux 上运行的单元测试)发布在 GitHub 上。
windows-admin-broker-deep-dive - komurasoft-blog-samples (GitHub)
本文的阅读方式
文章比较长,先把导览放在前面。
| 想了解的内容 | 该看哪里 |
|---|---|
| 只想知道分离模型怎么选 | 第 1〜4 章(结论、4 种模型的比较、推荐形态) |
| 设计上的判断及其理由 | 第 5 章(allowlist、路径固定、runas、pipe 的 ACL、PID 验证) |
| 实现的代码 | 第 7〜14 章(结构、清单文件、公共契约、UI 端、helper 端) |
| 如何确认确实分离成功 | 15.6 |
| 不该采用的形态 | 第 16 章 |
代码全文在 GitHub 的示例里也是同一份。只想了解设计思路的话看第 1〜5 章和第 15〜16 章,想连实现一起看的话就通读全文,可以这样取舍。
动手试之前需要准备的东西
要实际跑起来这个示例,需要下面这些:
- Windows 机器(UAC 的提升提示、通过
PipeSecurity设置显式 ACL、GetNamedPipeClientProcessId、写入 HKLM,全都是 Windows 专有的) - .NET 8 SDK 及以上
- 能够批准权限提升的账户。管理员账户会出现 consent prompt,标准用户会出现 credential prompt。如果两条路径都想确认,就把两种账户都准备好
- 允许改写 HKLM 的机器。示例会以 machine-wide 的方式创建
HKLM\SOFTWARE\Classes\*\shell\MyApp.Open。比起日常使用的开发机,在评估用的虚拟机上试更安全
构建与运行的具体命令,整理在示例的 README 里。只有一点请先记住:要把 UI 与 helper publish 到同一个文件夹之后再运行(因为 helper 会固定解析自己所在文件夹下的 MyApp.exe)。
1. 先说结论
先把实务中的落脚点列出来。
- 普通的 UI 应用保持
asInvoker运行 - 需要管理员权限的处理拆分到另一个 EXE
- 该 helper EXE 设为
requireAdministrator - 启动方式使用
runas - 与 helper 的通信不使用与
runas兼容性差的标准输入输出,而使用命名管道等 IPC - 传给 helper 的不是“原始命令字符串”,只传带类型的请求
- helper 端再次校验请求内容
- IPC 的连接方通过调用方用户 SID 与预期 PID 加以限制
“以管理员身份运行比较省事”只在第一次成立。 之后在 UAC、拖放、日志设计、外部输入、支持运维、DLL 加载、配置保存位置等方面,大概都会招来白眼。
flowchart TB
accTitle: 实务落脚点的骨架
accDescr: 说明 UI 保持 asInvoker 运行,把需要管理员权限的处理拆分到 requireAdministrator 的独立 EXE 并用 runas 启动,再通过命名管道只传递带类型的请求,这样的骨架。
uix1["UI 保持 asInvoker"] -->|"用 runas 启动"| hx1["helper EXE(requireAdministrator)"]
uix1 -->|"用命名管道传带类型的请求"| hx1
hx1 --> vfy1["helper 端再次校验请求"]
图2:骨架由“未提升的 UI + 已提升的 helper + 带类型请求的 IPC”这三点决定。
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 21 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. 前提梳理:无法只让同一进程的一部分变成管理员
Windows 的 UAC 不是按“函数粒度进行权限提升”,而是由“进程当前以哪种令牌 / 完整性级别运行”来控制的。 需要管理员访问令牌的应用会成为提升提示的对象,而且父子进程会以相同的完整性级别继承令牌。 也就是说,在未提升的 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 | 以标准用户身份运行的进程 | asInvoker 的 UI 应用 |
| high | 已提升权限的进程 | requireAdministrator 的 helper EXE |
Windows 的强制完整性控制(Mandatory Integrity Control)定义了 low / medium / high / system 四个级别,标准用户拿到的是 medium,提升后的用户拿到的是 high。 也就是说,本文这套设计讲的是:把“medium 的 UI 进程”和“high 的 helper 进程”明确划出一条界线,再让它们对话。
只要脑子里装着这个对应关系,5.6 里 CurrentUserOnly 的讨论和 16.4 就都能顺畅读下去。
flowchart TB
accTitle: medium 与 high 的界线
accDescr: 说明以标准用户身份运行的 asInvoker UI 拿到 medium 完整性级别,提升后的 requireAdministrator helper 拿到 high 完整性级别,本文的设计就是在这两者之间明确划线并让它们对话。
med1["medium:asInvoker 的 UI 进程"] -->|"划出界线再对话"| hg1["high:已提升的 helper 进程"]
med1 -.-> sd2["标准用户的令牌"]
hg1 -.-> ad2["提升后用户的令牌"]
图4:这套设计的实质,是在 medium 的 UI 与 high 的 helper 之间划出一条明确的边界。
3. 该选择哪种分离模型
Microsoft Learn 中,把需要管理员权限的应用的分离方式主要列为以下 4 种。
| 模型 | 大致形态 | 适合的场景 |
|---|---|---|
| Administrator Broker Model | 标准用户的 UI 应用 + 管理员 helper EXE | 管理员操作是偶发性的,只需要在必要的瞬间弹出 UAC |
| Operating System Service Model | 标准用户 UI + 常驻 service | 常时运行的管理功能、后台监控、无人值守处理 |
| Elevated Task Model | 标准用户 UI + 管理员权限的计划任务 | 每次都能很快结束的定型处理 |
| Administrator COM Object Model | 标准用户 UI + 提升后的 COM | 已有 COM 设计,且功能相当有限的场景 |
选择的大致标准如下。
3.1 最先容易考虑的是 broker EXE
broker EXE 比较容易适用于下列操作:
- Explorer 集成的注册 / 解除
- HKLM 下的 machine-wide 配置变更
- 自身应用的 service 注册 / 解除
- 防火墙规则的添加 / 删除
- Program Files 下的管理员操作
这些操作往往是平时不需要,只有在按下设置界面某个特定按钮时才需要。 这种情况下,与其搬出常驻 service,不如采用只启动一次管理员 helper EXE 就结束的形态更自然。
flowchart TB
accTitle: broker EXE 适用的形态
accDescr: 说明管理员操作平时不需要、只有按下设置界面某个特定按钮时才需要的场合,比起常驻 service,只启动一次管理员 helper EXE 就结束的形态更自然。
btn1["按下设置界面的特定按钮"] --> once2["只启动一次 helper EXE"]
once2 --> done1["操作结束后 helper 消失"]
btn1 -.->|"这个频率不需要"| svc2["搬出常驻 service"]
图5:对偶发性的管理员操作来说,只在需要的瞬间活着的 helper EXE 最自然。
3.2 选择 service 是为了“常时”“无人值守”“频繁”
service 是一种标准用户应用通过 RPC 等方式与之通信的模型。 优点是能在不弹出提升提示的情况下接受管理侧处理,但代价是增加了运维常驻进程的责任。
flowchart TB
accTitle: service 模型的取舍
accDescr: 说明 service 模型的优点是能在不弹出提升提示的情况下接受管理侧处理,代价则是增加了运维常驻进程的责任,这是一种取舍。
svm1["Operating System Service Model"] -->|"优点"| npr1["无需提示即可接受请求"]
svm1 -->|"代价"| res2["运维常驻进程的责任"]
svm1 -.-> fitx["适合常时、无人值守、频繁的用途"]
图6:service 是用背上常驻运维的代价,换来不弹提示的一种选择。
适合 service 的用途包括:
- 常时监控
- 日志收集
- 后台更新
- 与设备或守护进程的常时联动
- 由多个 UI session 共享的管理功能
3.3 task 适合“短小的定型处理”
Elevated Task Model 是从标准用户应用启动以管理员权限运行的计划任务的形态。 比 service 更轻量,结束后就会关闭,因此适合每次执行一遍的定型 job。
3.4 提升后的 COM 适用范围相当有限
COM elevation moniker 看起来很方便,但适用场合有限。 Microsoft Learn 中也说明,能控制提升后 COM 的 UI 必须由 COM 端提供,因此并不适合朝“让未提升 UI 随意指挥提升后 COM”这个方向使用。
flowchart TB
accTitle: 提升后 COM 的适用范围之窄
accDescr: 说明提升后的 COM 看起来很方便,但能控制它的 UI 必须由 COM 端提供,因此并不适合朝让未提升 UI 随意指挥提升后 COM 的方向使用。
look1["提升后的 COM 看起来很方便"] -.-> free1["从未提升 UI 随意操作"]
free1 -.->|"不适合这个方向"| ngc1["适用场合被收窄"]
ui2["能控制它的 UI 由 COM 端提供"] --> ngc1
图7:提升后的 COM 以控制界面由 COM 端持有为前提,不能当成通用的逃生通道。
4. 本次推荐:asInvoker UI + requireAdministrator helper EXE
接下来把实务中最好用的形态具体化。
flowchart TB
subgraph MED["medium integrity ── 自始至终不提升"]
UI["MyApp.exe(asInvoker)<br/>只负责接收用户操作并组装请求"]
end
subgraph HIGH["high integrity ── 短生命周期的提升进程"]
BR["MyApp.AdminBroker.exe<br/>(requireAdministrator)"]
PIPE["命名管道的接收端<br/>只允许 UI 用户的 SID 连接<br/>还要核对连接方 PID"]
DISP["按 operation 的 allowlist 分发<br/>参数在 helper 端再校验一次"]
end
TGT["需要管理员权限的固定目标<br/>HKLM 下的键 / service 注册 / 防火墙规则"]
UI -->|"用绝对路径 + Verb=runas 启动<br/>(UAC 提示在这里弹出)"| BR
BR --> PIPE
UI -->|"带类型的请求<br/>(不传原始命令字符串)"| PIPE
PIPE --> DISP
DISP --> TGT
图8:让提升的边界与进程边界重合。UI 端始终是 medium,只有 helper 以 high 短时间运行
要点有 3 个:
- UI 进程自始至终保持未提升状态
- 管理员 helper 是短生命周期的
- helper 接受的操作仅限固定的 allowlist
只要守住这 3 点,设计就会清晰很多。
flowchart TB
accTitle: 应当守住的三个要点
accDescr: 说明只要守住 UI 进程自始至终不提升、管理员 helper 生命周期短、helper 接受的操作仅限固定 allowlist 这三点,设计就会清晰很多。
p1["UI 自始至终不提升"] --> tidy1["设计会清晰很多"]
p2["helper 生命周期短"] --> tidy1
p3["接受的操作仅限 allowlist"] --> tidy1
图9:只要守住未提升、短生命周期、allowlist 这三点,权限边界的形状就定下来了。
5. 实现时不想遗漏的规则
这些是写代码之前最好先定好的事项。
5.1 helper 不做成“什么都能干的万事屋”
不好的例子如下:
- UI 把
reg add ...整个当作字符串传给 helper - UI 把
sc.exe ...整个当作字符串传给 helper - UI 把任意的注册表路径或任意的 EXE 路径传给 helper
这样做的话,一旦 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 的 path 要用绝对路径,而且不要让 UI 决定太多
用 runas 启动的 helper EXE 本身要用绝对路径指定。
避免依赖 PATH 搜索或相对路径。
进一步地,helper 要操作的对象也尽量由 helper 端固定解析。
本次示例中,注册到 Explorer 右键菜单的目标 EXE,被固定为与 helper 位于同一文件夹下的 MyApp.exe。
5.3 使用 Verb="runas" 时要显式指定 UseShellExecute=true
在 .NET 中,ProcessStartInfo.Verb 只有在 UseShellExecute=true 时才生效。
而且 UseShellExecute 的默认值在 .NET Framework 与 .NET Core / .NET 之间是不同的。
如果这里依赖默认值,后面就会出现“有的环境能跑、有的环境跑不起来”这种让人隐隐不爽的问题。
所以这里一定要显式指定。
flowchart TB
accTitle: 用 runas 启动时必须显式指定的设置
accDescr: 说明 ProcessStartInfo 的 Verb 只有在 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 之间的通信,改用 named pipe 之类的其他 IPC 会更自然。
5.5 命名管道不要依赖默认 ACL
命名管道在默认安全描述符下,默认会把读取权限授予 Everyone 或匿名用户。 管理员 helper 的 IPC 如果直接照用这个默认设置,实在太粗糙了。
最好一定要显式设置 PipeSecurity。
5.6 本次用途不使用 PipeOptions.CurrentUserOnly
这个选项乍看之下很方便。
不过在 Windows 上,CurrentUserOnly 不仅会确认用户账户,还会确认提升级别。
也就是说,它并不适合未提升 UI 与已提升 helper 之间的通信。
更进一步,谁会用哪种令牌来连接管道,会随 UAC 提示的类型而变化。把这一点用表格固定下来,就更容易看清为什么必须使用显式 ACL。
| UI 的运行账户 | 弹出的 UAC 提示 | helper 运行所用的账户 | 创建 pipe 的 helper 中的 WindowsIdentity.GetCurrent() |
前来连接的 UI 端 SID |
|---|---|---|---|---|
| 管理员账户(未提升) | consent prompt(只需点“是”) | 同一个用户的提升令牌 | 与 UI 相同的用户 | UI 用户 |
| 标准用户 | credential prompt(输入另一个账户的凭据) | 所输入的另一个管理员账户 | 与 UI 不同的用户 | UI 用户 |
读法如下:
- 如果是上面那一行,“helper 的当前用户 = UI 用户”,所以即便 helper 端只看自己的 SID 来构建 ACL,也会碰巧能连上
- 在下面那一行里,helper 的当前用户与 UI 用户是不同的人。此时若只看
WindowsIdentity.GetCurrent()来构建 ACL,原来的 UI 用户就发不出自己的请求了 - 无论哪一行,
CurrentUserOnly都会因为“medium 的 UI”与“high 的 helper”之间的提升级别差异而被挡下
也就是说,两行都成立的唯一形态,就是“从 UI 端拿到 SID,再把连接权限授予该 SID”。
因此这次采用以下做法:
- UI 端获取自己的 SID 并传给 helper
- helper 端只把管道连接权限授予 UI 用户 SID
- 再通过
GetNamedPipeClientProcessId确认连接方 PID
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:两种提示路径都能成立的,只有用 UI 传来的 SID 组装 ACL 这一种形态。
5.7 PID 验证是为了减少“随意插队”的额外防线
仅靠随机的管道名称已经好很多,但同一用户下运行的其他进程仍有可能抢先连上来。
因此在 helper 端使用 GetNamedPipeClientProcessId,确认是否与预期的 UI 进程 PID 一致。
当然,PID 对上了并不代表什么都可以信任。 如果 UI 已被攻陷,危险的请求同样会送到 helper。 正因如此,helper 端的 operation allowlist 与参数校验才必不可少。
flowchart TB
accTitle: 层层叠加的纵深防御
accDescr: 说明通过随机管道名、限定 SID 的 ACL、核对连接方 PID、operation allowlist 与参数校验这几层叠加,同时减少随意插队和危险请求两方面的风险。
l1["随机的 pipe 名称"] --> l2["限定 SID 的 ACL"]
l2 --> l3["核对连接方 PID"]
l3 --> l4["allowlist 与参数的再校验"]
l4 -.-> why3["PID 对上也不代表请求可信"]
图13:任何单独一层都不完备,所以要在连接和请求两侧都叠加防线来收窄范围。
把到此为止的规则按从启动到结束的顺序排列,就是下面这样。只要能读出“UI 端的每一步都对应着 helper 端的一次确认”这个形态就够了。
sequenceDiagram
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: 用只允许该 SID 的 ACL 创建管道
UI->>BR: 连接到管道
BR->>BR: 核对连接方 PID
UI->>BR: 带类型的请求(operation 名称与参数)
BR->>BR: 拒绝 allowlist 之外与不符预期的参数
BR->>TGT: 只对固定的目标执行操作
BR-->>UI: 返回结果
BR->>BR: 结束进程,不留下提升状态
图14:启动、连接、请求各自都对应着 helper 端的一次确认。少做其中任何一项,那一段就会被直通
6. 示例的题材
本次以把 Explorer 的右键菜单以 machine-wide 方式注册 / 解除注册为例。
理由很简单:
- 需要管理员权限
- 操作的边界很清晰
- 不需要向 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
把公共契约放在独立项目中,可以让
- operation 名称
- request / response 类型
- 管道的消息格式
在 UI 与 helper 之间更容易保持一致。
8. 清单文件(Manifest)
8.1 UI 端 (MyApp/app.manifest)
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApp.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
8.2 helper 端 (MyApp.AdminBroker/app.manifest)
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApp.AdminBroker.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
UI 始终是 asInvoker。
只有 helper 是 requireAdministrator。
如果这里弄反了,好不容易拆分开的意义就没有了。
9. 公共契约代码
9.1 MyApp.BrokerProtocol/BrokerProtocol.cs
using System.Buffers.Binary;
using System.Text.Json;
namespace MyApp.BrokerProtocol;
public static class BrokerJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
public static class BrokerOperations
{
public const string SetExplorerContextMenu = "set-explorer-context-menu";
}
public sealed record BrokerRequest(string Operation, JsonElement Payload);
public sealed record BrokerResponse(bool Success, string? ErrorCode, string? Message)
{
public static BrokerResponse Ok(string? message = null) => new(true, null, message);
public static BrokerResponse Fail(string errorCode, string message) =>
new(false, errorCode, message);
}
public sealed record SetExplorerContextMenuRequest(bool Enabled);
public static class PipeMessageSerializer
{
private const int MaxPayloadBytes = 256 * 1024;
public static async Task WriteAsync<T>(Stream stream, T value, CancellationToken cancellationToken)
{
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(value, BrokerJson.Options);
if (payload.Length > MaxPayloadBytes)
{
throw new InvalidDataException($"Payload is too large: {payload.Length} bytes.");
}
byte[] header = new byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length);
await stream.WriteAsync(header.AsMemory(0, header.Length), cancellationToken);
await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancellationToken);
await stream.FlushAsync(cancellationToken);
}
public static async Task<T> ReadAsync<T>(Stream stream, CancellationToken cancellationToken)
{
byte[] header = await ReadExactAsync(stream, sizeof(int), cancellationToken);
int payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header);
if (payloadLength <= 0 || payloadLength > MaxPayloadBytes)
{
throw new InvalidDataException($"Invalid payload length: {payloadLength}");
}
byte[] payload = await ReadExactAsync(stream, payloadLength, cancellationToken);
return JsonSerializer.Deserialize<T>(payload, BrokerJson.Options)
?? throw new InvalidDataException($"Failed to deserialize {typeof(T).FullName}.");
}
private static async Task<byte[]> ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
{
byte[] buffer = new byte[length];
int offset = 0;
while (offset < length)
{
int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken);
if (read == 0)
{
throw new EndOfStreamException("Pipe was closed before the expected number of bytes was read.");
}
offset += read;
}
return buffer;
}
}
要点在于:不要把 JSON 原样一股脑地灌进管道,而要带上长度信息再发送。 把协议做成“一次请求、一次响应”这种简单形式,就不容易出问题。
flowchart TB
accTitle: 带长度信息的简单协议
accDescr: 说明不要把 JSON 原样灌进管道,而要带上长度头再发送,并把协议做成只有一次请求和一次响应的简单形式,这样不容易出问题。
m1["先写入长度头"] --> m2["再写入正文 JSON"]
m2 --> m3["对方按长度确切地读取"]
m3 -.-> simple1["一请求一响应的简单性很管用"]
图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: 启动参数与 pipe 的职责划分
accDescr: 说明 helper 的启动参数只传管道名称和确认连接方所需的最少信息,管理员操作本身则封闭在通过管道发送的带类型 request 中,这样划分职责。
argx["启动参数"] -->|"传的是"| minx["pipe 名称、PID、SID 等最少信息"]
pipx["pipe 内部"] -->|"封闭的是"| reqx["带类型 request 表示的管理员操作"]
minx -.-> nolx["操作内容不放进参数里"]
图16:参数只用于连接的前期准备,操作的实体作为带类型 request 封闭在 pipe 里。
这个 QuoteArgument 是以本示例所传递的管道名称、PID、SID 这类简单值为前提的最小实现。如果要把任意的 Windows 路径或自由输入的字符串作为命令行参数传递,请替换成符合 Windows argv 解析规则的专用转义处理。
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 端:创建 pipe、验证连接方 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);
}
这里起作用的是下面这几点:
- 显式地构建 pipe 的 ACL
- ACL 不只授予 helper 的当前用户 SID,也授予调用方 UI 用户 SID
- 连接之后验证 client PID
- 收到 request 之后仍然按 operation 名称进行 dispatch
用 switch (request.Operation) 做成只放行固定操作的形态,helper 就不容易变成“提升了权限的什么都装的箱子”。
flowchart TB
accTitle: helper 端起作用的校验顺序
accDescr: 说明 helper 用显式 ACL 创建管道,连接后验证客户端 PID,收到 request 后按 operation 名称 dispatch,只放行固定操作,这就是 helper 端起作用的校验顺序。
hs1["用显式 ACL 创建管道"] --> hs2["验证连接方 PID"]
hs2 --> hs3["按 operation 名称 dispatch"]
hs3 -->|"在 allowlist 内"| hs4["只执行固定的操作"]
hs3 -->|"在 allowlist 外"| hs5["拒绝并返回响应"]
图17:只有通过 ACL、PID、dispatch 三道关卡的 request,才能到达固定的操作。
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 的连接方
- 把 pipe ACL 限定为 UI 用户 SID
- 连接后确认 client PID
15.5 管理员操作的对象也是固定的
- 注册表的 hive / path 是固定的
- 注册目标 EXE 也是固定解析的
做到这个程度,就已经离“UI 一旦被攻破,helper 就什么都能干”的状态相当远了。
15.6 确认是否真的分离成功
到这里为止讲的都是设计。写出来的东西是否真的分离开了,不跑一遍是不知道的。 “UI 整体不知不觉间被提升了权限”这种问题,光读代码很难察觉。
flowchart TB
accTitle: 确认是否分离成功的四个阶段
accDescr: 说明依次确认 UI 进程是否保持未提升、是否只在启动 helper 时弹出 UAC 提示、管理员操作是否真的生效、以及该失败时是否会失败这四点的流程。
v1["UI 是否保持未提升"] --> v2["是否只在启动 helper 时弹出提示"]
v2 --> v3["操作是否真的生效"]
v3 --> v4["该失败时是否会失败"]
v4 -.-> whyv["不看第 4 点就不算确认了分离"]
图18:跑起来依次看这四个阶段,就能抓住光看代码发现不了的权限提升泄漏。
确认时依次看下面 4 点。
1. UI 进程是否保持未提升
这是最重要的一点。启动 UI,并在执行过一次管理员操作之后再确认。
- 任务管理器:在“详细信息”选项卡里右键点击列标题,显示“已提升”列。如果
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. 是否只在启动 helper 时弹出 UAC 提示
- 启动 UI 时就弹出提示 -> UI 端的清单文件没有设成
asInvoker - 按下设置复选框的那一刻才弹出 -> 符合预期
- 一次提示都没弹出,配置却变了 -> helper 有可能通过别的路径一直处于提升状态
管理员账户会出现 consent prompt,标准用户会出现 credential prompt(见 5.6 的表)。两种都试一遍,还能确认 SID 的传递是否正确。
3. 管理员操作是否真的生效
如果是 Explorer 的菜单注册,直接查看注册表最快。
reg query "HKLM\SOFTWARE\Classes\*\shell\MyApp.Open" /s
解除注册那一侧也要同样确认。只试注册而不试解除的话,DeleteSubKeyTree 那一侧就会留下 bug。
4. 是否“该失败时就会失败”
不确认这一点,就无法判断到底有没有分离开。
- 取消提升提示 -> 配置不发生变化,UI 的复选框也会还原(对
ERROR_CANCELLED= 1223 的处理。见第 10 章) - 直接启动 helper -> 即使像
MyApp.AdminBroker.exe --pipe x --client-pid 1 --client-sid S-1-5-18这样手工敲命令,也会因为连接方 PID 的验证和超时而无法继续执行 - 发送 allowlist 之外的 operation -> 会以
unsupported_operation被拒绝(第 12 章的DispatchAsync)
具体的操作命令,在示例的 README 的“在 Windows 上的确认步骤” 里按同样的流程做了整理。
16. 常见的 NG 做法
16.1 把整个 UI 设为 requireAdministrator
设置界面里明明只有一个按钮需要管理员权限,却让整个应用都以提升方式启动。 这是把权限边界粗暴抹平的方向。
16.2 把原始字符串命令传给 helper
比如下面这种设计:
UI -> 向 helper 传入 "reg add HKLM\\.... /v ... /d ..."
这会让 helper 变成 command executor。 最好不要这样做。
16.3 直接使用命名管道的默认 ACL
“反正是本机 IPC,应该没问题吧”这种想法有点危险。 管道本身也是 Windows 安全机制的管控对象,最好认真构建 ACL。
16.4 一头扑向 CurrentUserOnly
看起来很方便,但并不适合本次这种 medium integrity 的 UI ↔ high integrity 的 helper 场景。 这里用 explicit ACL 会更容易掌控。
16.5 helper 接收任意 path 后进行操作
比如下面这些:
- 把任意文件复制到 Program Files
- 把任意键写入 HKLM
- 删除任意 service 名称
- 用任意命令添加 firewall rule
helper 一旦接受这些,helper 本身就会变成管理员权限下的通用执行入口。 操作一定要固定化。
17. 总结
在 Windows 应用中,“只有部分处理需要管理员权限”并不是罕见的情况。
但它的解法不是“把整个应用都设为 requireAdministrator”,而是划分出执行边界。
最容易上手的是下面这种形态:
- UI 使用
asInvoker - 管理员处理分离到 helper EXE
- helper 使用
requireAdministrator - 启动方式使用
runas - 通信使用 named pipe
- helper 只接受固定的 operation
- 通过 pipe ACL 与 client PID 收窄连接方
- helper 端再次校验参数
保持这种形态,以后想改成 service 化时也容易迁移。 只要把 operation 契约划分清楚,UI 与管理员处理之间的边界本身就会成为设计资产。
flowchart TB
accTitle: 边界会成为设计资产
accDescr: 说明只要把 operation 契约划分清楚,UI 与管理员处理之间的边界就会成为设计资产,以后想改成 service 化时也更容易迁移。
ctr1["把 operation 契约划分开"] --> bd1["UI 与管理员处理的边界清晰"]
bd1 --> asset1["边界本身成为设计资产"]
asset1 -.-> future1["迁移到 service 化也更容易"]
图19:用 broker 形态切出的边界,将来改成 service 化时也能原样当作资产使用。
安全方面的事情,与其去添加华丽的功能,不如不留下粗糙的边界更管用。 管理员权限也是同样的道理。 不要把权限整体交出去,而是只把必要的部分,尽量狭窄地交出去。 这种程度的朴实,之后才会真正发挥作用。
18. 参考资料
另外,下面部分链接的 URL 里带有 view=net-10.0 这样的版本指定。这是指定 Microsoft Learn 端显示哪个 .NET 版本的文档,并不意味着与本文前提的 .NET 8 相冲突。这里用到的 PipeOptions / NamedPipeServerStreamAcl / RegistryView 在 .NET 8 中都可以使用。如果想把显示切换到 .NET 8,请用页面上方的版本选择器切换。
- 本文示例代码的完整版本(公共契约库、演示、单元测试) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
- 原文:Windows 应用开发中为了守住最低限度安全底线的检查清单 /zh-CN/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 应用程序开发的安全最低限度检查清单
面向 WPF / WinForms / WinUI / C++ / C# 的业务应用程序,以检查清单的形式梳理权限、签名、更新、机密信息、HTTPS、输入验证、DLL 加载、日志这些方面的基本要点。
Windows 什么时候需要管理员权限 - UAC、保护区域与设计上的辨别方式
从边界与存储位置的角度,整理 Windows 什么时候真正需要管理员权限:UAC、保护区域、HKLM、服务、驱动、防火墙。同时说明 per-user 与 per-machine 的差异,以及把管理员处理拆成独立 EXE、服务或任务的设计取舍,帮读者判断该不该提升权限。
Windows 应用的敏感信息存储 - 用 DPAPI 摆脱明文配置
为了不把连接凭据和 API 令牌以明文形式存进 Windows 应用的配置文件,本文梳理 DPAPI / ProtectedData 的思路、CurrentUser 与 LocalMachine 的差别,以及实现时的注意事项。
命名管道实务 ── 从设计到安全,吃透 Windows 进程间通信的标准方案
以实务视角讲解 Windows 进程间通信的标准方案——命名管道。依据一手资料梳理字节模式与消息模式的取舍、同时接纳多个客户端的服务器结构、ACL 与模拟的安全设计,以及 .NET 的命名管道流。
Windows I/O 底层原理(第 6 篇·完结篇)——迷你过滤器的机制与用 Procmon 排查延迟
讲解迷你过滤器监视和控制文件 I/O 的机制。梳理 FltMgr、高度、pre/post 回调与 fltmc 的读法,汇总用 Procmon 定位慢操作的步骤,以及排除项设置和 Dev Drive 的注意事项。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
这个主题涉及 UAC、helper EXE、是否改用服务的判断,以及 machine-wide 配置变更,关系到 Windows 应用整体的权限设计,与 Windows 应用开发服务契合度很高。
技术咨询 & 设计评审
如果想重新审视现有应用长期使用 requireAdministrator 的做法,重新梳理 broker 设计与 IPC 边界,这类课题很适合作为技术咨询与设计评审来推进。
常见问题
汇总了咨询这一主题时常见的问题。
- 能不能只让同一个进程内的某部分处理以管理员权限运行?
- 不能。Windows 的 UAC 并不是按函数粒度进行权限提升,而是由进程当前以哪种令牌 / 完整性级别运行来控制的。父子进程会以相同的完整性级别继承令牌,因此在未提升的 UI 进程内让某个特定方法单独以管理员权限运行,这种设计是不可能实现的。需要管理员权限的处理,必须拆分到另一个进程、服务、计划任务、提升后的 COM 等其他执行单元中。
- 分离需要管理员权限的处理有哪些方式?
- Microsoft Learn 主要列出了 4 种模型:把标准用户 UI 与管理员 helper EXE 组合起来的 Administrator Broker Model;使用常驻服务的 Operating System Service Model;使用管理员权限计划任务的 Elevated Task Model;以及使用提升后 COM 的 Administrator COM Object Model。如果管理员操作是偶发性的,只需要在必要的瞬间弹出 UAC,适合用 broker EXE;如果是常时、无人值守、频繁执行,适合用服务;如果是每次都能很快结束的定型处理,适合用计划任务。
- 与 runas 启动的 helper EXE 通信,可以使用标准输入输出吗?
- 不太好用,最好避免。在 .NET 中,ProcessStartInfo.Verb 只有在 UseShellExecute=true 时才生效,而设置 UseShellExecute=true 之后,依赖标准输入输出重定向的通信方式就无法使用了。因此与 helper 之间的通信,采用命名管道之类的 IPC 会更自然。管道不要依赖默认 ACL,而要显式设置 PipeSecurity,将连接权限限定给调用方用户 SID,并通过 GetNamedPipeClientProcessId 再验证一次连接方的 PID。
- 命名管道使用 PipeOptions.CurrentUserOnly 不就安全了吗?
- 并不适合未提升 UI 与已提升 helper 之间的通信。Windows 的 CurrentUserOnly 不仅会确认用户账户,还会确认提升级别,因此完整性级别不同的进程之间无法建立连接。而且在标准用户环境中,UAC 可能变成 credential prompt,helper 也可能以另一个管理员账户运行。做法是让 UI 端获取自己的 SID 并传给 helper,helper 端只把管道连接权限授予该 SID,这种显式 ACL 的方式会更容易掌控。