在 Windows 应用中把「仅需管理员权限的处理」分离出来的具体写法
· 小村 豪 · Windows 开发, 安全, UAC, C# / .NET, Win32
之前写过的《Windows 应用开发中为了守住最低限度安全底线的检查清单》一文中,提到过「以 asInvoker 为基础,只把需要管理员权限的处理单独分离出来」这条思路。
这次要更进一步,深入到具体该怎么写代码。
在 Windows 应用中,没办法只让同一个进程里的部分处理随心所欲地「以管理员身份执行」。 权限提升是进程边界层面的问题,所以真正需要的是「把那部分处理拆分到另一个执行单元」的设计。
本文将按以下顺序展开:
- 先说前提
- 该选择哪种分离模型
- 实务中最好用的
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. 先说结论
实务落地大致如下:
- 普通的 UI 应用保持
asInvoker运行 - 需要管理员权限的处理拆分到另一个 EXE
- 该 helper EXE 设为
requireAdministrator - 启动方式使用
runas - 与 helper 的通信不使用与
runas兼容性差的标准输入输出,而使用命名管道等 IPC - 传给 helper 的不是「原始命令字符串」,只传带类型的请求
- helper 端再次校验请求内容
- IPC 的连接方通过调用方用户 SID 与预期 PID 进行限制
「以管理员身份运行比较省事」只在第一次成立。 之后在 UAC、拖放、日志设计、外部输入、支持运维、DLL 加载、配置保存位置等方面,大概都会让人皱眉头。
2. 前提梳理:无法只让同一进程的一部分变成管理员
Windows 的 UAC 不是「按函数粒度进行权限提升」,而是由「进程当前以哪种令牌 / 完整性级别运行」来控制的。 需要管理员访问令牌的应用会成为提升提示的对象,而且父子进程会以相同的完整性级别继承令牌。 也就是说,在未提升的 UI 进程内,让某个方法突然以管理员权限运行,这种设计是做不到的。 需要的话,应使用另一个进程、服务、任务、提升后的 COM 等其他执行单元。
如果撇开这个前提去想,就会变成「只想在按下这个按钮的那一瞬间变成管理员」这种有点让人为难的设计诉求。 Windows 在这一点上不会用魔法帮你填坑。
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 便结束的形态更自然。
3.2 选择 service 是为了「常时」「无人值守」「频繁」
service 是一种标准用户应用通过 RPC 等方式与之通信的模型。 优点是能在不弹出提升提示的情况下接受管理侧处理,但代价是增加了运维常驻进程的责任。
适合 service 的用途包括:
- 常时监控
- 日志收集
- 后台更新
- 与设备或守护进程的常时联动
- 由多个 UI session 共享的管理功能
3.3 task 适合「短小的定型处理」
Elevated Task Model 是从标准用户应用启动以管理员权限运行的计划任务的形态。 比 service 更轻量,结束后就会关闭,适合每次执行一次的定型 job。
3.4 提升后的 COM 适用范围相当有限
COM elevation moniker 看起来很方便,但适用场合有限。 Microsoft Learn 中也说明,能控制提升后 COM 的 UI 必须由 COM 端提供,因此并不适合朝「让未提升 UI 随意指挥提升后 COM」这个方向使用。
4. 本次推荐:asInvoker UI + requireAdministrator helper EXE
接下来把实务中最好用的形态具体化。
[ MyApp.exe ] asInvoker
|
| ShellExecute / ProcessStartInfo + Verb=runas
v
[ MyApp.AdminBroker.exe ] requireAdministrator
|
| named pipe
v
[ 只执行需要管理员权限的固定处理 ]
要点有 3 个:
- UI 进程始终保持未提升状态
- 管理员 helper 是短生命周期的
- helper 接受的操作仅限固定的 allowlist
只要守住这 3 点,设计就会清晰很多。
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 / 数值 / 受限字符串。
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 之间是不同的。
如果这里依赖默认值,后面就会出现「有的环境能跑、有的环境跑不起来」这种让人隐隐不爽的事故。
所以这里一定要显式指定。
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 可能变成 credential prompt,helper 也可能以另一个管理员账户运行。
这种情况下,如果 helper 端直接用 WindowsIdentity.GetCurrent() 来构建 ACL,原来的 UI 用户可能就连接不上了。
因此这次采用以下做法:
- UI 端获取自己的 SID 并传给 helper
- helper 端只把管道连接权限授予 UI 用户 SID
- 再通过
GetNamedPipeClientProcessId确认连接方 PID
5.7 PID 验证是为了减少「随意插队」的额外防线
仅靠随机的管道名称已经好很多,但同一用户下运行的其他进程仍有可能先建立连接。
因此在 helper 端使用 GetNamedPipeClientProcessId,确认是否与预期的 UI 进程 PID 一致。
当然,PID 对上了并不代表什么都可以信任。 如果 UI 已被攻陷,危险的请求同样会传到 helper。 正因如此,helper 端的 operation allowlist 与参数校验才必不可少。
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 原样不断地灌进管道,而要带上长度信息再发送。 采用「一次请求、一次响应」这种简单协议,会更不容易出事故。
10. UI 端:helper 的启动与通信
10.1 MyApp/ElevationBrokerClient.cs
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO.Pipes;
using System.Security.Principal;
using System.Text.Json;
using MyApp.BrokerProtocol;
namespace MyApp;
public sealed class ElevationBrokerClient
{
private readonly string _helperExePath;
public ElevationBrokerClient(string helperExePath)
{
_helperExePath = Path.GetFullPath(helperExePath);
if (!Path.IsPathRooted(_helperExePath))
{
throw new ArgumentException("Helper executable path must be absolute.", nameof(helperExePath));
}
if (!File.Exists(_helperExePath))
{
throw new FileNotFoundException("Helper executable was not found.", _helperExePath);
}
}
public async Task SetExplorerContextMenuEnabledAsync(bool enabled, CancellationToken cancellationToken = default)
{
string pipeName = $"myapp-broker-{Guid.NewGuid():N}";
int clientPid = Environment.ProcessId;
string clientSid = GetCurrentUserSid();
StartHelper(pipeName, clientPid, clientSid);
using var pipe = new NamedPipeClientStream(
serverName: ".",
pipeName: pipeName,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(TimeSpan.FromSeconds(30));
await pipe.ConnectAsync(connectCts.Token);
BrokerRequest request = new(
BrokerOperations.SetExplorerContextMenu,
JsonSerializer.SerializeToElement(
new SetExplorerContextMenuRequest(enabled),
BrokerJson.Options));
await PipeMessageSerializer.WriteAsync(pipe, request, cancellationToken);
BrokerResponse response = await PipeMessageSerializer.ReadAsync<BrokerResponse>(pipe, cancellationToken);
if (!response.Success)
{
throw new InvalidOperationException(
$"Admin broker returned an error. Code={response.ErrorCode}, Message={response.Message}");
}
}
private void StartHelper(string pipeName, int clientPid, string clientSid)
{
string workingDirectory = Path.GetDirectoryName(_helperExePath)
?? throw new InvalidOperationException("Helper executable directory could not be resolved.");
var startInfo = new ProcessStartInfo
{
FileName = _helperExePath,
Arguments = BuildArguments(pipeName, clientPid, clientSid),
WorkingDirectory = workingDirectory,
UseShellExecute = true,
Verb = "runas"
};
try
{
Process.Start(startInfo)
?? throw new InvalidOperationException("The helper process could not be started.");
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
throw new OperationCanceledException("管理员权限的批准已被取消。", ex);
}
}
private static string GetCurrentUserSid()
{
using WindowsIdentity identity = WindowsIdentity.GetCurrent();
return identity.User?.Value
?? throw new InvalidOperationException("Current user SID could not be resolved.");
}
private static string BuildArguments(string pipeName, int clientPid, string clientSid)
{
return string.Join(
" ",
"--pipe",
QuoteArgument(pipeName),
"--client-pid",
clientPid.ToString(CultureInfo.InvariantCulture),
"--client-sid",
QuoteArgument(clientSid));
}
private static string QuoteArgument(string value)
{
return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
}
这里传给 helper 的,只有管道名称,以及验证连接方所需的最少信息。
管理员操作本身,被封装在通过管道发送的带类型的 request 中。
这里的 QuoteArgument 是一个最小实现,只针对本示例中传递的管道名称、PID、SID 这类简单值。如果要把任意 Windows 路径或自由输入的字符串传入命令行参数,请替换为遵循 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 端:创建管道、验证连接方 PID、分发
12.1 MyApp.AdminBroker/Program.cs
using System.ComponentModel;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using MyApp.BrokerProtocol;
namespace MyApp.AdminBroker;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
BrokerLaunchOptions options = BrokerLaunchOptions.Parse(args);
using var brokerCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using NamedPipeServerStream pipe = CreatePipeServer(options);
await pipe.WaitForConnectionAsync(brokerCts.Token);
VerifyClientProcessId(pipe, options.ExpectedClientProcessId);
BrokerRequest request = await PipeMessageSerializer.ReadAsync<BrokerRequest>(pipe, brokerCts.Token);
BrokerResponse response = await DispatchAsync(request);
await PipeMessageSerializer.WriteAsync(pipe, response, brokerCts.Token);
return response.Success ? 0 : 2;
}
private static Task<BrokerResponse> DispatchAsync(BrokerRequest request)
{
try
{
return request.Operation switch
{
BrokerOperations.SetExplorerContextMenu => HandleSetExplorerContextMenuAsync(request.Payload),
_ => Task.FromResult(
BrokerResponse.Fail(
"unsupported_operation",
$"Unsupported operation: {request.Operation}"))
};
}
catch (JsonException ex)
{
return Task.FromResult(BrokerResponse.Fail("invalid_payload", ex.Message));
}
catch (Exception ex)
{
return Task.FromResult(BrokerResponse.Fail("broker_failure", ex.Message));
}
}
private static NamedPipeServerStream CreatePipeServer(BrokerLaunchOptions options)
{
var pipeSecurity = new PipeSecurity();
var clientSid = new SecurityIdentifier(options.ClientUserSid);
SecurityIdentifier helperSid = WindowsIdentity.GetCurrent().User
?? throw new InvalidOperationException("Helper user SID could not be resolved.");
pipeSecurity.AddAccessRule(new PipeAccessRule(
clientSid,
PipeAccessRights.ReadWrite,
AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(
helperSid,
PipeAccessRights.FullControl,
AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
PipeAccessRights.FullControl,
AccessControlType.Allow));
return NamedPipeServerStreamAcl.Create(
options.PipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 1,
transmissionMode: PipeTransmissionMode.Byte,
options: PipeOptions.Asynchronous | PipeOptions.WriteThrough,
inBufferSize: 0,
outBufferSize: 0,
pipeSecurity: pipeSecurity);
}
private static void VerifyClientProcessId(NamedPipeServerStream pipe, int expectedClientProcessId)
{
if (!GetNamedPipeClientProcessId(
pipe.SafePipeHandle.DangerousGetHandle(),
out uint actualClientProcessId))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (actualClientProcessId != (uint)expectedClientProcessId)
{
throw new InvalidOperationException(
$"Unexpected pipe client PID. Expected={expectedClientProcessId}, Actual={actualClientProcessId}");
}
}
private static Task<BrokerResponse> HandleSetExplorerContextMenuAsync(JsonElement payload)
{
SetExplorerContextMenuRequest request = payload.Deserialize<SetExplorerContextMenuRequest>(BrokerJson.Options)
?? throw new JsonException("Payload could not be parsed.");
ExplorerContextMenuRegistration.Apply(request.Enabled);
return Task.FromResult(BrokerResponse.Ok("Explorer context menu setting was updated."));
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeClientProcessId(
IntPtr pipe,
out uint clientProcessId);
}
这里起作用的要点如下:
- 显式构建管道的 ACL
- ACL 不只授予 helper 当前用户 SID,也授予调用方 UI 用户 SID
- 连接建立后验证 client PID
- 接收到 request 之后,也按 operation 名称进行分发
用 switch (request.Operation) 只放行固定的操作,helper 就不容易变成「提升后的万能箱」。
13. 管理员操作的本体:注册 Explorer 右键菜单
13.1 MyApp.AdminBroker/ExplorerContextMenuRegistration.cs
using System;
using System.IO;
using Microsoft.Win32;
namespace MyApp.AdminBroker;
internal static class ExplorerContextMenuRegistration
{
private const string MenuKeyPath = @"SOFTWARE\Classes\*\shell\MyApp.Open";
private const string CommandKeyPath = @"SOFTWARE\Classes\*\shell\MyApp.Open\command";
private const string MenuText = "Open with MyApp";
private const string ClientExecutableName = "MyApp.exe";
public static void Apply(bool enabled)
{
string clientExePath = ResolveClientExecutablePath();
using RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, GetRegistryView());
if (enabled)
{
using RegistryKey menuKey = hklm.CreateSubKey(MenuKeyPath)
?? throw new InvalidOperationException($"Failed to create registry key: {MenuKeyPath}");
menuKey.SetValue(null, MenuText, RegistryValueKind.String);
menuKey.SetValue("Icon", $"\"{clientExePath}\",0", RegistryValueKind.String);
using RegistryKey commandKey = hklm.CreateSubKey(CommandKeyPath)
?? throw new InvalidOperationException($"Failed to create registry key: {CommandKeyPath}");
commandKey.SetValue(null, $"\"{clientExePath}\" \"%1\"", RegistryValueKind.String);
}
else
{
hklm.DeleteSubKeyTree(@"SOFTWARE\Classes\*\shell\MyApp.Open", throwOnMissingSubKey: false);
}
}
private static string ResolveClientExecutablePath()
{
string clientExePath = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, ClientExecutableName));
if (!File.Exists(clientExePath))
{
throw new FileNotFoundException("Client executable was not found.", clientExePath);
}
return clientExePath;
}
private static RegistryView GetRegistryView()
{
return Environment.Is64BitOperatingSystem
? RegistryView.Registry64
: RegistryView.Registry32;
}
}
这段代码的关键,在于它没有从 UI 接收哪些东西。
- 没有从 UI 接收任意的注册表路径
- 没有从 UI 接收任意的命令字符串
- 注册目标 EXE 由 helper 端固定解析
- request 的内容只有
Enabled
也就是说,helper 被设计成只具有「切换 Explorer 右键菜单的注册状态」这唯一一种含义。
14. 来自 UI 的调用示例
14.1 MyApp/SettingsPage.xaml.cs
using System.Windows;
namespace MyApp;
public partial class SettingsPage
{
private readonly ElevationBrokerClient _broker = new(
Path.Combine(AppContext.BaseDirectory, "MyApp.AdminBroker.exe"));
private async void ExplorerMenuCheckBox_Click(object sender, RoutedEventArgs e)
{
bool enabled = ExplorerMenuCheckBox.IsChecked == true;
try
{
await _broker.SetExplorerContextMenuEnabledAsync(enabled);
MessageBox.Show("Setting has been updated.", "MyApp");
}
catch (OperationCanceledException)
{
MessageBox.Show("The administrator approval prompt was canceled.", "MyApp");
ExplorerMenuCheckBox.IsChecked = !enabled;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "Failed to update the setting.");
ExplorerMenuCheckBox.IsChecked = !enabled;
}
}
}
UI 端很普通:
- 读取复选框的状态
- 调用 broker client
- 失败时把 UI 状态还原
仅此而已。 不直接接触注册表。 这就是分离。
15. 这个实现中把握住的要点
这个示例中实际守住的原则如下。
15.1 UI 与 helper 的职责分离
- UI 只负责接收用户的操作
- helper 只执行固定的管理员操作
15.2 helper 没有开设「任意执行入口」
- 不接收任意的注册表路径
- 不接收任意的命令行
- 不接收任意的 EXE 路径
15.3 启动路径是固定的
- helper EXE 使用绝对路径
- 显式指定
runas - 显式指定
UseShellExecute = true
15.4 收窄了 IPC 的连接方
- 将管道 ACL 限定为 UI 用户 SID
- 连接后确认 client PID
15.5 管理员操作的对象也是固定的
- 注册表的 hive / path 是固定的
- 注册目标 EXE 也是固定解析的
做到这个程度,就已经离「UI 一旦出问题 helper 就能做任何事」的状态相当远了。
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 与管理员处理之间的边界本身就会成为设计资产。
安全方面的事情,与添加华丽功能相比,不留下粗糙的边界更管用。 管理员权限也是同样的道理。 不要把权限整体交出去,而是只把必要的部分,尽量狭窄地传递出去。 这种程度的朴实,之后会真正发挥作用。
18. 参考资料
- 此文示例代码的完整版本(公共契约库、演示、单元测试) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
- 原文:Windows 应用开发中为了守住最低限度安全底线的检查清单 https://comcomponent.com/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
- 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 应用程序不要把敏感信息以明文存进配置文件的最佳实践
本文整理 Windows 桌面应用保存连接凭证或 API Token 时的实务做法,说明为什么 DPAPI 与 ProtectedData 比明文或自制加密更能切断「文件外泄即等于敏感信息外泄」这条链条,并对比 CurrentUser 与 LocalMachine 的适用场...
意外异常发生时,应用该终止还是继续的判断表
本文从状态破坏、外部副作用、线程与原生边界等角度,整理发生意外异常时应用应该终止还是继续运行的判断依据,并给出常见场景下的判断表与建议。
不只是 appsettings.json ── Windows 业务应用的配置管理实务(环境差异化设置・敏感信息・写入位置)
本文从实务角度整理 Windows 业务应用的配置管理,涵盖 appsettings.json 的分层、IConfiguration 与 IOptions/IOptionsSnapshot/IOptionsMonitor 的选用方式、环境差异化设置、可写入配置的存放位置、敏...
常见问题
汇总了咨询这一主题时常见的问题。
- 能不能只让同一个进程内的某部分处理以管理员权限运行?
- 不能。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 的方式会更容易掌控。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。