正确处理 Windows 的模拟令牌 ── 线程级权限借用与安全的还原方式
· 小村 豪 · Windows, Security, AccessToken, Impersonation, Win32, .NET, C#, 运维, 现有资产利用
1. 首先要掌握的要点
在开发 Windows 应用程序或 Windows 服务时,经常会遇到「只想让这一部分处理以另一个用户身份来执行」的场景。
例如以下这些情况:
- 想让 Windows 服务以使用者本人的权限访问文件服务器
- 在管理用应用程序中,只想确认特定用户能看到的范围
- 想在 Named Pipe、RPC、COM、IIS、ASP.NET Core 等场景中,让一部分处理以调用者用户的权限执行
- 出于沿用现有资产的考虑,想按处理单位切换使用的 Windows 账户
这时就会用到模拟(impersonation)、访问令牌(access token)、模拟令牌(impersonation token)这些机制。
不过,这里首先要强调一点:
Windows 的模拟并不是「变成管理员的魔法」。它是一种主要以线程为单位,切换访问检查所使用的安全上下文的机制。
如果没有理解这一区别就直接实现,就会遇到这些问题:
- 明明以为已经模拟了,文件访问却变成
Access denied - 本地文件能读,但只有网络共享失败
- 在
Task.Run或async的过程中,不知不觉又变回了原来的用户 - 处理在模拟状态下一直延续到日志输出和后续流程,导致权限边界变得模糊
- 混淆了主令牌和模拟令牌,导致进程启动失败
- 卡在「用户明明在 Administrators 组里,为什么还是写不进去」这类问题上
本文整理的是在实务中安全处理 Windows 模拟令牌的思路。
这不是关于攻击手法或权限夺取的内容,而是关于如何在 Windows 应用程序、Windows 服务、.NET 应用程序中正确处理权限边界的内容。
另外,本文中出现的代码,已作为可以直接编译的示例(包含类库、在 Windows 上运行的演示程序,以及针对参数校验和保护行为的单元测试)发布在 GitHub 上。
windows-impersonation-token - komurasoft-blog-samples (GitHub)
2. 什么是访问令牌
在 Windows 中,访问令牌(access token)用于表示用户或进程的安全上下文。
访问令牌大致包含以下信息:
- 用户的 SID
- 所属的组
- 权限(Privilege)
- 默认的所有者
- 默认的 DACL
- 受限 SID
- 完整性级别
- 提升状态
- 模拟级别
- 令牌类型
文件、注册表、服务、命名管道、进程、线程、事件、互斥体等 Windows 上的很多对象都拥有安全描述符。
当某个线程试图打开受保护的对象时,Windows 会比对令牌信息与目标对象的 ACL。
执行操作的线程
↓
以哪种安全上下文进行访问
↓
查看令牌中的用户、组、权限
↓
与目标对象的 ACL 进行比对
↓
决定允许 / 拒绝
理解这个「以哪种安全上下文进行访问」的问题,是理解模拟令牌的入口。
3. 进程拥有主令牌
Windows 的每个进程通常都有一个主访问令牌(primary access token)。
例如,当某个用户从桌面启动一个应用程序时,该进程就会带有代表这个用户安全上下文的主令牌。
如果是 Windows 服务,则会带有服务运行账户的主令牌。
大致是这样的形式:
MyService.exe
Primary Token: DOMAIN\svc-app
如果该服务内的线程没有进行任何模拟,那么在访问文件或注册表时,使用的就是进程的主令牌。
也就是说,默认情况下是这样的:
Thread A
Impersonation Token: 无
↓
访问检查时使用 Process 的 Primary Token
在这种状态下打开 C:\Data\foo.txt,系统检查的是 DOMAIN\svc-app 是否拥有访问权限。
4. 模拟令牌附加在线程上
一旦开始模拟,线程上就会附加一个模拟令牌。这一点非常重要:与「整个进程变成另一个用户」相比,更准确的理解是这个线程会以另一个安全上下文接受访问检查。
MyService.exe
Primary Token: DOMAIN\svc-app
Thread A
Impersonation Token: DOMAIN\alice
Thread B
Impersonation Token: 无
此时,如果 Thread A 打开文件,就会以 DOMAIN\alice 的权限接受访问检查。
而 Thread B 没有进行模拟,因此会以 DOMAIN\svc-app 的权限接受访问检查。
如果不理解这一区别,就会出现这样的混乱:
// 以为在 Thread A 中已经模拟了
StartImpersonation(token);
// 但实际上把处理丢给了另一个线程
Task.Run(() =>
{
File.ReadAllText(path);
});
// 又立刻还原了
RevertToSelf();
在这种情况下,实际读取文件的线程未必会处于预期的模拟状态下运行。
模拟必须明确梳理与作用范围、线程、异步处理之间的关系后再使用。
5. 「模拟」不是权限提升
「模拟」这个词听起来有些强烈,但在实务中重要的是不要把它和「权限提升」混为一谈。
模拟能做到的,基本上就是这些事情:
以服务器进程的权限执行处理
↓
只让部分处理以客户端用户的权限接受访问检查
例如,如果想直接把文件服务器的 ACL 用作权限控制手段,而服务器应用程序始终以服务账户读取文件,就无法反映各个用户不同的 ACL。
因此,只让请求处理中的部分环节以调用者用户身份进行模拟,再执行文件访问。
HTTP / RPC / Named Pipe 请求
User: DOMAIN\alice
↓
服务器应用程序
Process: DOMAIN\svc-app
↓
仅文件访问部分以 DOMAIN\alice 身份进行模拟
↓
由文件服务器的 ACL 决定允许 / 拒绝
当你想利用 Windows 现有的 ACL,而不是自行实现应用层的权限判断时,这种做法会很有用。
不过,模拟虽然方便,但一旦设计不当,权限边界就会变得难以理解。
- 到底在以谁的身份执行哪些处理
- 模拟是从哪里开始的
- 在哪里确实地还原了
- 哪些日志是在哪种用户权限下输出的
- 出现异常时是否也能还原
- 异步处理直到结束时,模拟是否一直有效
把这些内容在代码中明确表达出来非常重要。
6. 把主令牌和模拟令牌分开来考虑
Windows 令牌中特别容易混淆的,是主令牌和模拟令牌。
大致可以这样理解:
| 令牌 | 主要用途 | 代表示例 |
|---|---|---|
| 主令牌 | 代表进程的安全上下文 | 进程启动、CreateProcessAsUser |
| 模拟令牌 | 用于让线程以另一个安全上下文运行 | ImpersonateLoggedOnUser、SetThreadToken、Named Pipe 的客户端模拟 |
特别重要的一点是:如果想启动进程,原则上需要主令牌。
即使拥有模拟令牌,也不一定能直接用它来启动另一个用户的进程。
典型的流程是这样的:
模拟客户端
↓
通过 OpenThreadToken 获取模拟令牌
↓
通过 DuplicateTokenEx 生成主令牌
↓
传给 CreateProcessAsUser 等 API
反过来,如果只是想「让这个线程以另一个用户身份进行文件访问」,那就属于使用模拟令牌的场景,而不是启动进程。
如果把这两者混在一起,就会在 API 参数看起来都没问题的情况下,被 Access denied 或 The parameter is incorrect 之类的错误卡住。
7. 理解模拟级别
模拟令牌具有模拟级别(impersonation level)。
代表性的有 4 个级别:
| 模拟级别 | 大致含义 |
|---|---|
| Anonymous | 服务器无法获得客户端的识别信息 |
| Identification | 服务器可以识别客户端,但不能用于以该权限访问对象 |
| Impersonation | 服务器可以在本机系统上以客户端的权限运行 |
| Delegation | 服务器还可以将客户端的权限委派给远程系统 |
实务中经常卡壳的地方,是 Identification 和 Impersonation 的区别。
Identification 顾名思义,是用来知道对方是谁的级别。
它不足以用于以该用户权限打开文件之类的用途。
因此会出现这样的情况:
WindowsIdentity.GetCurrent().Name 看起来是期望的用户名
↓
但文件访问却是 Access denied
在这种情况下,不能只看名称,还需要确认模拟级别。
在 .NET 中,可以通过 WindowsIdentity.ImpersonationLevel 获得线索。
using System.Security.Principal;
WindowsIdentity identity = WindowsIdentity.GetCurrent();
Console.WriteLine(identity.Name);
Console.WriteLine(identity.ImpersonationLevel);
在跨网络访问时,还需要更加小心。
在「Web 服务器模拟用户,再以该用户身份访问另一台文件服务器或数据库服务器」这样的架构中,有可能遇到所谓的双跳(double hop)问题。
在这种情况下,单靠在应用程序代码中进行模拟未必能解决问题。 需要把 Kerberos、SPN、委派、约束委派、服务账户、目标服务器的认证方式都纳入设计考虑。
8. 模拟的基本形式
在 Win32 API 中处理模拟时,概念上的基本形式是这样的:
1. 获取用于模拟的令牌
2. 用该令牌对当前线程进行模拟
3. 只执行需要的处理
4. 务必还原到原来的安全上下文
5. 关闭令牌句柄
代码结构上,必须使用 try / finally。
if (!ImpersonateLoggedOnUser(tokenHandle))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
try
{
// 只有这部分以模拟用户身份执行
DoWorkAsImpersonatedUser();
}
finally
{
if (!RevertToSelf())
{
// 无法还原的状态很危险,至少不能继续处理
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
重要的不是模拟的开始,而是确保能可靠地还原。如果忘记还原,该线程上的后续处理会一直以模拟用户的身份运行。
尤其是在使用线程池的应用程序中,本来只想处理一次的模拟,可能会影响到其他请求或其他处理。
因此,不应把模拟理解为「开始了就要还原」,而应理解为把它封闭在一个尽量小的范围内。
9. 在 .NET 中使用 WindowsIdentity.RunImpersonated
在 .NET 中,如果条件允许,使用 WindowsIdentity.RunImpersonated 能更容易地在代码层面表达出模拟的作用范围。
如果已经拥有 SafeAccessTokenHandle,可以这样写:
using Microsoft.Win32.SafeHandles;
using System.Security.Principal;
static string ReadFileAsUser(SafeAccessTokenHandle token, string path)
{
return WindowsIdentity.RunImpersonated(token, () =>
{
return File.ReadAllText(path);
});
}
这种写法的好处是,模拟所覆盖的范围被封闭在 lambda 表达式内部。
RunImpersonated(token, () =>
{
// 只有这部分处于模拟状态
});
// 从这里往外就是原来的上下文
如果只想模拟文件访问、注册表访问、调用现有类库等特定处理,这种写法读起来清晰,也更安全。
在异步处理中,则使用 RunImpersonatedAsync。
using Microsoft.Win32.SafeHandles;
using System.Security.Principal;
static Task WriteFileAsUserAsync(
SafeAccessTokenHandle token,
string path,
string text,
CancellationToken cancellationToken)
{
return WindowsIdentity.RunImpersonatedAsync(token, async () =>
{
await File.WriteAllTextAsync(path, text, cancellationToken);
});
}
应避免的做法,是从模拟范围内部抛出 fire-and-forget 的任务。
// 不好的示例
WindowsIdentity.RunImpersonated(token, () =>
{
_ = Task.Run(() =>
{
File.WriteAllText(path, text);
});
});
这段代码会让人搞不清楚「实际写文件的处理」到底在何时、以哪种执行上下文运行。
需要在模拟状态下执行的异步处理,应在 RunImpersonatedAsync 内部 await,等处理完成后再退出该范围。
10. 使用 LogonUser 获取令牌的情况
从另一个用户的凭据获取令牌的一个代表性 API 是 LogonUser,但这是一个需要谨慎处理的 API。
调用 LogonUser 时需要传入用户名、域名、密码。
也就是说,应用程序一侧要处理这些凭据。
在实务中,需要注意以下几点:
- 不要把密码以明文形式放在代码或配置文件中
- 尽可能使用操作系统认证、服务账户、委派、现有的 Windows 认证机制
- 敏感信息应通过合适的 Secret Store 或运维基础设施来管理
- 令牌句柄必须关闭
- 日志中不要输出用户名以外的敏感信息
- 把模拟的范围最小化
下面给出一个最小化的示例。
using Microsoft.Win32.SafeHandles;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Principal;
internal static class NativeMethods
{
private const int LOGON32_LOGON_INTERACTIVE = 2;
private const int LOGON32_PROVIDER_DEFAULT = 0;
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
internal static extern bool LogonUser(
string lpszUsername,
string? lpszDomain,
string lpszPassword,
int dwLogonType,
int dwLogonProvider,
out SafeAccessTokenHandle phToken);
public static SafeAccessTokenHandle Logon(
string userName,
string? domain,
string password)
{
bool ok = LogonUser(
userName,
domain,
password,
LOGON32_LOGON_INTERACTIVE,
LOGON32_PROVIDER_DEFAULT,
out SafeAccessTokenHandle token);
if (!ok)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return token;
}
}
public static string ReadFileWithExplicitCredential(
string userName,
string? domain,
string password,
string path)
{
using SafeAccessTokenHandle token = NativeMethods.Logon(userName, domain, password);
return WindowsIdentity.RunImpersonated(token, () =>
{
return File.ReadAllText(path);
});
}
这个示例只是为了展示 API 的形式。
在实务中,首先应该考虑「应用程序本来是否适合设计成接收密码」这一点。
多数情况下,考虑以下这些替代方案会更安全。
| 想实现的目标 | 替代方案 |
|---|---|
| 让整个服务访问特定资源 | 为专用服务账户赋予最小必要的 ACL |
| 以使用者本人的权限访问文件 | 使用 Windows 认证和委派设计 |
| 执行需要管理员权限的操作 | 在服务端设置明确的管理 API,由应用侧的授权来控制 |
| 只让部分处理使用另一个账户 | 把模拟范围封闭到方法级别,并留下审计日志 |
11. 注意 LogonUser 的登录类型
通过 LogonUser 获取的令牌的性质,会因登录类型(logon type)而不同。
尤其是如果不理解这一区别就直接照搬代码,就无法按预期工作。
| 登录类型 | 注意事项 |
|---|---|
| Interactive | 接近交互式登录的形式。便于本地操作,但依赖运行环境和权限 |
| Network | 面向网络登录。返回的令牌有时不能直接用于启动进程 |
| NewCredentials | 在本地接近当前凭据,常用于远程连接时使用指定凭据的场景 |
这里想说的重点,不是要死记某个特定的登录类型。
重要的是以下两点:
- 登录类型会影响本地访问、网络访问、进程启动时的行为
- 需要确认返回的令牌到底是主令牌,还是模拟令牌
例如,把通过 LOGON32_LOGON_NETWORK 获得的令牌直接传给 CreateProcessAsUser 导致失败,就是一种典型的混乱。
如果目的是启动进程,就需要主令牌。
必要时应通过 DuplicateTokenEx 生成主令牌。
12. 不要轻视 RevertToSelf
如果通过 Win32 API 开始模拟,就要通过 RevertToSelf 结束模拟。这个还原处理并不只是单纯的收尾工作,而是把安全边界恢复原状的重要环节。
下面是一个不好的示例。
ImpersonateLoggedOnUser(token);
DoWork();
RevertToSelf();
看起来似乎没有问题,但如果 DoWork() 抛出异常,RevertToSelf() 就不会被调用。
必须使用 finally。
if (!ImpersonateLoggedOnUser(token))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
try
{
DoWork();
}
finally
{
if (!RevertToSelf())
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
如果 RevertToSelf 失败,还继续执行处理也很危险。如果在尚未确认恢复到原来权限的状态下继续后续处理,就会导致处理以非预期的用户权限持续运行。至少应把该处理单元视为失败,倾向于安全的一侧处理。
.NET 的 RunImpersonated / RunImpersonatedAsync,作为避免忘记还原的作用范围表达方式,是很有用的。
13. 尽量缩小模拟范围
模拟中最重要的设计原则,是只模拟必要的处理。
下面是一个不好的示例。
WindowsIdentity.RunImpersonated(token, () =>
{
ValidateRequest();
LoadConfiguration();
WriteDebugLog();
ReadUserFile();
UpdateDatabase();
SendNotification();
});
像这样把范围放得很宽,就很难判断到底是哪个操作在哪种权限下执行的。
例如,日志输出目标的访问也会以模拟用户的权限进行,可能导致日志写入失败。 数据库连接也可能变成尝试以模拟用户的凭据而非服务账户来进行。 通知处理甚至临时文件的创建,也都可能受到不必要权限上下文的影响。
好的做法,是把需要模拟的操作单独拆分出来。
ValidateRequest();
LoadConfiguration();
string content = WindowsIdentity.RunImpersonated(token, () =>
{
return File.ReadAllText(userFilePath);
});
UpdateDatabase(content);
WriteAuditLog(userName, userFilePath, success: true);
这样一来就能清楚地看出,只有 File.ReadAllText 这一部分需要模拟。
模拟虽然方便,但范围越大,就越难阅读、越容易出事故。
14. 在异步处理中要关注「直到结束是否一直处于模拟状态」
在现代 .NET 应用程序中,文件、HTTP、数据库、队列、存储等许多处理都是异步的。
因此,模拟和 async / await 的组合需要特别注意。
基本方针只有一句话:
需要在模拟状态下执行的异步处理,应在 RunImpersonatedAsync 内部 await
下面是一个好的示例。
await WindowsIdentity.RunImpersonatedAsync(token, async () =>
{
await using FileStream stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
string text = await reader.ReadToEndAsync();
await ProcessTextAsync(text);
});
不过,即使采用这种写法,也仍有需要考虑的地方。
那就是:ProcessTextAsync 是否真的需要以模拟用户身份运行。
如果只是读取文件那部分需要模拟,那么这样拆分会更安全:
string text = await WindowsIdentity.RunImpersonatedAsync(token, async () =>
{
return await File.ReadAllTextAsync(path);
});
await ProcessTextAsync(text);
并不是说因为可以在模拟范围内 await,就可以把任何内容放进去。
在异步处理中,也同样应该把模拟范围最小化。
15. ASP.NET Core 与模拟
在 ASP.NET Core 中使用 Windows 认证时,同样需要注意模拟的处理方式。
如果想当然地认为「因为是通过 Windows 认证登录的,所以整个请求处理都会以该用户身份运行」,是很危险的。
一般来说,应用程序进程本身是以应用程序池标识或服务的运行账户来运行的。 虽然可以通过认证信息获得使用者的 Windows ID,但并不意味着所有处理都会自动变成使用者的权限。
如果需要以使用者的权限执行特定操作,就需要通过 RunImpersonated / RunImpersonatedAsync 显式创建一个作用范围。
代码大致是这样的:
app.MapGet("/download", async (HttpContext context) =>
{
if (context.User.Identity is not WindowsIdentity user)
{
return Results.Unauthorized();
}
string path = GetPathFromRequest(context);
byte[] bytes = await WindowsIdentity.RunImpersonatedAsync(
user.AccessToken,
async () => await File.ReadAllBytesAsync(path));
return Results.File(bytes, "application/octet-stream");
});
在这个示例中,被模拟的也只是文件读取这一部分。
至于响应生成、日志、应用程序侧的授权判断是否也需要全部纳入模拟范围,应当谨慎考虑。
16. 如何看待 Access denied
在使用模拟的实现中,经常出现的错误是 Access denied。
出现这个错误时,如果只想到「模拟失败了」,反而会走弯路。
需要分开来确认这些角度。
| 角度 | 确认内容 |
|---|---|
| 是否真的已经模拟 | 在模拟范围内确认 WindowsIdentity.GetCurrent().Name |
| 模拟级别是否足够 | 是不是 Identification,而不是所需要的级别 |
| 目标资源的 ACL 是否正确 | 模拟用户是否拥有读取 / 写入权限 |
| 是本地还是远程 | 本地文件成功,是否只有 UNC 失败 |
| 是不是双跳问题 | 是否想从 Web 服务器以使用者权限访问文件服务器 |
| 是否已经离开模拟范围 | 实际的 I/O 是否发生在范围外或另一个任务中 |
| 令牌类型是否正确 | 是否把模拟令牌传给了进程启动 |
| 是否受 UAC / 完整性级别影响 | 即使属于 Administrators,是否仍是非提升令牌 |
尤其重要的是,不要只确认名称就放心。
WindowsIdentity identity = WindowsIdentity.GetCurrent();
Console.WriteLine(identity.Name);
这条日志确实有用,但并不足够。
至少还应该查看这些内容。
Console.WriteLine(identity.ImpersonationLevel);
Console.WriteLine(identity.IsAuthenticated);
另外,如果目标是网络共享,除了应用程序代码之外,还要确认认证方式、委派设置、SPN、服务账户,以及文件服务器一侧的 ACL。
17. UAC 与「明明是管理员却失败」的问题
在 Windows 中,用户属于 Administrators 组,和当前令牌已经处于提升状态,是两件不同的事情。
在启用 UAC 的环境中,即使是管理员用户,通常也会以受限令牌运行进程,需要管理员权限的操作则要经过提升。
因此会出现这样的情况:
用户属于 Administrators 组
↓
但当前令牌处于非提升状态
↓
向 Program Files 或 HKLM 写入时出现 Access denied
模拟也是同样的道理。
不应认为「模拟目标用户是管理员,所以应该能写入」,而应该确认实际传入的令牌到底处于什么状态。
调试时,需要关注这些角度:
- 所属的组
- Privilege 的启用 / 禁用状态
- 完整性级别
- 提升状态
- 是否为受限令牌
- 是否存在关联的提升令牌
在 Win32 API 中,可以使用 GetTokenInformation 来确认 TokenType、TokenImpersonationLevel、TokenElevationType、TokenIntegrityLevel 等信息。
不过,从实务设计的角度来看,与「模拟管理员用户来做任何事」相比,把必要的最小操作封闭在专用服务或管理 API 中会更安全。
18. 网络共享与双跳问题
在使用模拟时,非常常见的咨询场景是访问网络共享。
客户端 PC
↓ Windows 认证
Web 服务器 / API 服务器
↓ 想要以模拟方式访问
文件服务器
在这种架构中,可能会出现「在 Web 服务器上已经能取得用户名,但访问文件服务器时却失败」的情况。
这其实是一个能否把使用者的凭据再委派给另一台服务器的问题。
本地服务器上的模拟,和向另一台服务器的委派,是两件不同的事情。
Impersonation 级别虽然可以用于本地操作,但用来在远程服务器上以客户端身份行事时,有时是不够的。
如果想跨网络使用使用者本人的权限,就需要设计 Kerberos 委派、约束委派、SPN、服务账户以及认证方式。
另一方面,根据业务需求,也存在不需要以使用者本人的 Windows 权限访问文件服务器的情况。
在这种情况下,下面的设计会更简单。
使用者的认证 / 授权由应用程序完成
↓
访问文件服务器时使用专用服务账户
↓
在操作日志中记录使用者 ID 与目标文件
这不是把「操作系统的 ACL 当作最终判断」,而是把「应用程序的授权当作最终判断」的设计。
哪一种方式正确,取决于业务需求。
不过,如果不明确到底采用了哪种方式,就会导致模拟、委派、ACL、应用授权混在一起,变得难以理解。
19. 令牌句柄的生命周期
令牌是内核对象的句柄,因此获取后一旦不再需要,就必须关闭。
在 .NET 中,基本做法是使用 SafeAccessTokenHandle,并通过 using 来管理其作用范围。
using SafeAccessTokenHandle token = NativeMethods.Logon(userName, domain, password);
string result = WindowsIdentity.RunImpersonated(token, () =>
{
return File.ReadAllText(path);
});
下面是一个不好的示例。
// 不好的示例:把令牌一直保存在全局变量中
private static SafeAccessTokenHandle? _cachedToken;
长期持有令牌,会带来这些问题:
- 句柄泄漏
- 分不清哪个处理在使用哪个令牌
- 与账户停用或权限变更之间的一致性变得难以把握
- 容易演变成长期持有凭据的设计
- 在审计上难以解释
原则是这样的:
需要时才获取
↓
在最小范围内使用
↓
务必关闭
当然,根据认证成本或运维需求,有时也会考虑做缓存。 但即使这样,也必须把有效期、销毁、账户变更、审计日志、权限变更时的处理方式纳入设计。
20. 应留在审计日志中的信息
在使用模拟的处理中,日志设计同样重要。
至少应保留下表中的信息,这样事后排查会更容易。
| 项目 | 示例 |
|---|---|
| 发起请求的使用者 | DOMAIN\alice |
| 执行进程的账户 | DOMAIN\svc-app |
| 被模拟的账户 | DOMAIN\alice 或专用账户 |
| 目标资源 | 文件路径、共享名、注册表键等 |
| 操作 | Read、Write、Delete、CreateProcess 等 |
| 结果 | Success、AccessDenied、Timeout、UnexpectedError |
| 错误码 | Win32 error code、HRESULT、异常类型 |
| 模拟范围 | 是在哪个方法、哪个操作单位下进行的模拟 |
不过,也有一些内容不应写入日志。
- 密码
- 访问令牌的值
- 认证头
- Kerberos 票据或凭据本身
- 包含个人信息的文件内容
日志的目的,是让人事后能够追溯「由谁的请求,以什么账户身份,尝试了什么操作,结果是失败还是成功」。
不需要记录凭据本身。
21. 常见的反模式
下面整理模拟相关中经常见到的危险实现。
21.1 对整个应用程序进行模拟
WindowsIdentity.RunImpersonated(token, () =>
{
RunEntireApplication();
});
如果对整个应用程序进行模拟,就无法判断到底哪个操作在哪种权限下执行。
模拟应限定在必要的 I/O 或特定 API 调用上。
21.2 没有 finally 就还原
ImpersonateLoggedOnUser(token);
DoWork();
RevertToSelf();
出现异常时不会还原,因此很危险。
必须使用 try / finally,或者使用 RunImpersonated。
21.3 在模拟中执行 fire-and-forget
WindowsIdentity.RunImpersonated(token, () =>
{
_ = Task.Run(DoWorkAsync);
});
由于会在处理完成前就离开模拟范围,可能导致以非预期的权限运行。
如果需要模拟,应在 RunImpersonatedAsync 内部 await。
21.4 只凭用户名判断是否成功
Console.WriteLine(WindowsIdentity.GetCurrent().Name);
即使用户名符合预期,模拟级别或权限仍可能不足。
还应确认 ImpersonationLevel、目标 ACL、登录类型、网络委派等信息。
21.5 把管理员账户当作「万能账户」来模拟
「这个处理不能失败,所以就用管理员账户来模拟」,这种设计是危险的。
准备一个拥有最小必要权限的专用账户,并限定操作范围,会更安全。
21.6 把密码放在配置文件中
{
"UserName": "DOMAIN\\admin",
"Password": "P@ssw0rd!"
}
应该避免这种做法。
如果需要处理凭据,应使用与所在环境相匹配的方案,例如 Secret Store、Windows Credential Manager、DPAPI、云端的 Key Vault、运维基础设施的密钥管理等。
21.7 把进程启动和文件访问当作同一个话题来处理
有时只做文件访问,用模拟令牌就足够了。
但如果想以另一个用户身份启动进程,就会涉及主令牌、用户配置文件、桌面、环境变量、会话、权限等其他议题。
如果使用 CreateProcessAsUser 或 CreateProcessWithTokenW,应把它当作与模拟不同的设计来对待。
22. 测试的角度
模拟相关处理,如果只在手头的管理员环境中确认,容易出现疏漏。
至少应准备以下这些测试用例。
| 用例 | 确认内容 |
|---|---|
| 有权限的用户 | 能读取 / 写入目标文件 |
| 无权限的用户 | 能正确地以 Access denied 失败 |
| 不存在的用户 | 能作为认证失败来处理 |
| 密码错误 | 不在日志中泄露敏感信息,且能正确失败 |
| 网络共享 | 确认本地与 UNC 之间行为差异 |
| 异步处理 | await 之后仍能在预期范围内运行 |
| 发生异常 | 模拟一定能被解除 |
| 并发请求 | 不会混入其他用户的模拟 |
| 服务运行 | 使用实际的服务账户运行,而不是开发者的交互式登录环境 |
尤其不能省略这两点:
应该成功的操作确实成功
应该失败的操作确实失败
对于模拟相关处理,不仅要测试成功的场景,还要测试没有权限的用户确实会被拒绝。
如果本应被拒绝的操作反而成功了,就说明模拟或授权的设计可能存在问题。
23. 实现检查清单
在实现前后,应确认下面这份检查清单。
| 角度 | 检查内容 |
|---|---|
| 目的 | 能否说明为什么需要模拟 |
| 替代方案 | 是否考虑过服务账户或应用授权是否足够 |
| 范围 | 模拟范围是否已经最小化 |
| 还原 | 出现异常时是否也能确实还原 |
| 异步 | 是否在 RunImpersonatedAsync 内部 await 到处理完成 |
| 令牌 | 是否混淆了主令牌和模拟令牌 |
| 模拟级别 | 是否已区分 Identification 与 Impersonation / Delegation 的差异 |
| 网络 | 是否确认了 UNC、双跳、Kerberos 委派的必要性 |
| UAC | 是否混淆了属于管理员组和已经提升 |
| 敏感信息 | 密码是否以明文保存 |
| 句柄 | 是否用 using 关闭了 SafeAccessTokenHandle |
| 日志 | 能否追溯使用者、被模拟对象、目标、结果 |
| 测试 | 是否确认了有权限 / 无权限 / 异常 / 并发处理 |
如果在这份检查清单上有很多项都无法通过,最好在动手写代码之前重新审视设计。
24. 判断使用场景
模拟令牌很强大,但并不总是应该首先选择的手段。
按用途分开来考虑,会更不容易迷失方向。
24.1 想直接使用操作系统的 ACL 时
如果文件服务器或共享文件夹的 ACL 是业务规则的核心,而应用程序也应遵循这个判断,那么以使用者本人身份进行模拟就是有意义的。
以使用者本人身份访问
↓
Windows 的 ACL 作为最终判断
在这种情况下,需要把 Windows 认证、模拟级别、委派、网络结构都纳入设计。
24.2 想在应用程序侧进行授权时
如果业务规则位于应用程序侧,而文件服务器或数据库都处于应用程序的管理之下,那么用服务账户访问、再由应用程序侧进行授权,有时会更清晰。
认证使用者
↓
由应用程序进行授权
↓
以服务账户访问资源
↓
在审计日志中留下使用者 ID
在这种方式中,不使用模拟,取而代之的是应用程序的授权逻辑和审计日志变得更加重要。
24.3 想执行管理操作时
比起直接用使用者的令牌执行管理操作,通常更安全的做法是设立专用的管理服务或 API,在那里进行授权、输入校验、审计和回滚。
客户端
↓
向管理 API 发起请求
↓
管理 API 进行授权
↓
以最小必要权限执行操作
↓
审计日志
「先模拟管理员再说」,短期看起来很轻松。
但从长期来看,在审计、故障排查、权限变更、安全评审时会变得很吃力。
25. 总结
Windows 的模拟令牌,是正确使用 Windows 权限管理的重要机制。
不过,如果不理解就使用,也很容易写出看起来成功、实际却很危险的代码。
把需要掌握的要点列在下面:
- 访问令牌代表用户、组、权限等安全上下文
- 进程拥有主令牌
- 模拟令牌主要附加在线程上,用于访问检查
- 模拟不是权限提升
- 主令牌和模拟令牌的用途不同
- 模拟级别决定了只能识别,还是能实际访问,还是能委派给远程
- 如果通过 Win32 API 进行模拟,必须用
try/finally调用RevertToSelf - 在 .NET 中,用
WindowsIdentity.RunImpersonated/RunImpersonatedAsync把范围表达得更小 - 使用
LogonUser时,要注意凭据管理和登录类型 - 网络共享不仅涉及模拟,也涉及 Kerberos 委派和服务账户设计
- 令牌句柄应使用
SafeAccessTokenHandle配合using来管理 - 不仅要测试成功的场景,也要测试应该被拒绝的场景
在实现模拟令牌时,重要的不是「以另一个用户身份运行了」这一点本身,而是能否说清楚以下这些内容。
执行了什么处理
基于谁的请求
以什么账户身份
只执行了多大范围
在哪里还原
如何记录成功与失败
只要能把这些内容梳理清楚,模拟就不是一个可怕的机制。
它可以成为一个实务上的工具,帮助你善用 Windows 的 ACL、服务账户、现有的文件服务器和企业内部的域资产。
参考资料
- 本文的示例代码(类库、演示程序、单元测试)
https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-impersonation-token - Microsoft Learn: Access Tokens
https://learn.microsoft.com/en-us/windows/win32/secauthz/access-tokens - Microsoft Learn: Impersonation Tokens
https://learn.microsoft.com/en-us/windows/win32/secauthz/impersonation-tokens - Microsoft Learn: Impersonation Levels
https://learn.microsoft.com/en-us/windows/win32/secauthz/impersonation-levels - Microsoft Learn:
SECURITY_IMPERSONATION_LEVELenumeration
https://learn.microsoft.com/en-us/windows/win32/api/winnt/ne-winnt-security_impersonation_level - Microsoft Learn:
ImpersonateLoggedOnUserfunction
https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-impersonateloggedonuser - Microsoft Learn:
RevertToSelffunction
https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-reverttoself - Microsoft Learn:
LogonUserfunction
https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-logonusera - Microsoft Learn:
DuplicateTokenExfunction
https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-duplicatetokenex - Microsoft Learn:
TOKEN_INFORMATION_CLASSenumeration
https://learn.microsoft.com/en-us/windows/win32/api/winnt/ne-winnt-token_information_class - Microsoft Learn:
WindowsIdentity.RunImpersonated
https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsidentity.runimpersonated - Microsoft Learn:
WindowsIdentity.RunImpersonatedAsync
https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsidentity.runimpersonatedasync - Microsoft Learn: Configure Windows Authentication in ASP.NET Core
https://learn.microsoft.com/en-us/aspnet/core/security/authentication/windowsauth
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
业务应用的日期时间与时区 ── 从 DateTime 的陷阱到 UTC 存储原则、测试设计
服务器迁移后时间整整差了 9 个小时、只有海外分支机构的日期会变成前一天——本文从 DateTime 的 Kind 与隐式转换梳理日期时间事故的根本原因。内容涵盖与 DateTimeOffset 的取舍、UTC 存储与 ISO 8601 原则、TimeZoneInfo 与夏...
在 C# 业务应用中使用 SQLite ── WAL 模式、排他控制、防损坏对策、与 EF Core 的取舍
本文整理在 Microsoft.Data.Sqlite 中把 SQLite 嵌入业务 Windows 应用的实务知识:连接字符串与连接池、WAL 模式的原理、SQLITE_BUSY 与写入归并、通过事务提速、类型映射的陷阱、用 VACUUM INTO 备份,以及该在什么场合...
Windows 服务的创建与运维 ── 从任务计划程序的取舍到 BackgroundService 服务化
常驻处理应该做成 Windows 服务,还是任务计划程序就够用?本文整理判断表,以及用 .NET Worker Service(UseWindowsService)创建服务的方法、运行账户、恢复选项、事件日志、安全停止处理,从实务角度梳理投入运维所需的设计。
如何理解 Windows 的会话隔离 ── Session 0・RDP・多用户同时运行
整理 Windows 应用程序开发者容易混淆的「会话」概念。说明服务无法显示 UI 的 Session 0 隔离原因、RDP 连接时会话的行为、命名对象的会话隔离,以及共享 PC・RDS 环境中常见的设计失误,从实务角度解说。
防止 Windows 应用程序重复启动 ── 命名 Mutex 与重复执行时的窗口前置
整理业务型 Windows 应用程序的常见需求——「不让同一个应用程序启动两次」——如何用命名 Mutex 来实现。涵盖 Global\ 与 Local\ 命名空间差异在 RDP 环境中的陷阱、拥有线程限制与 AbandonedMutexException、在 SetFor...
常见问题
汇总了咨询这一主题时常见的问题。
- Windows 的模拟令牌是什么?
- 模拟令牌是附加在线程上的令牌,使该线程能够以另一个安全上下文接受访问检查。并不是整个进程都变成另一个用户,而是只有执行模拟的那个线程,会以该用户的权限接受文件、注册表等资源的访问检查。这不是「变成管理员的魔法」,也就是说并非权限提升,而是让服务器进程中的一部分处理,以客户端用户的权限来接受访问检查的机制。
- 主令牌和模拟令牌有什么区别?
- 主令牌代表进程的安全上下文,用于 CreateProcessAsUser 等进程启动场景。模拟令牌则用于让线程以另一个安全上下文运行,出现在 ImpersonateLoggedOnUser 或 Named Pipe 的客户端模拟中。如果想启动进程,原则上需要主令牌;如果手上只有模拟令牌,则需要通过 DuplicateTokenEx 来生成主令牌。把这两者混淆的话,就会在 Access denied 之类的报错中迷失方向。
- 为什么明明已经模拟了,却还是出现 Access denied?
- 需要检查的角度有很多。除了在模拟范围内确认 WindowsIdentity.GetCurrent() 的 Name,还要确认 ImpersonationLevel,看它是不是已经达到 Impersonation 及以上,而不只是 Identification 级别。此外要检查目标资源的 ACL,确认实际的 I/O 是否发生在模拟范围之外或另一个任务中,是否遇到本地成功但只有 UNC 失败的双跳(double hop)问题,以及 UAC 导致的非提升令牌的影响。名称看起来符合预期,权限却仍可能不足。
- 在 .NET 中该如何安全地实现模拟?
- 基本做法是使用 WindowsIdentity.RunImpersonated / RunImpersonatedAsync,将模拟范围封闭在 lambda 表达式内部。需要在模拟状态下执行的异步处理,应在 RunImpersonatedAsync 内部 await,避免从模拟范围内部抛出 fire-and-forget 的任务。如果通过 Win32 API 进行模拟,必须用 try/finally 调用 RevertToSelf。应把模拟范围最小化到只覆盖必要的 I/O,并使用 SafeAccessTokenHandle 配合 using 来管理令牌句柄。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。