正确处理 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.Runasync 的过程中,不知不觉又变回了原来的用户
  • 处理在模拟状态下一直延续到日志输出和后续流程,导致权限边界变得模糊
  • 混淆了主令牌和模拟令牌,导致进程启动失败
  • 卡在「用户明明在 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
模拟令牌 用于让线程以另一个安全上下文运行 ImpersonateLoggedOnUserSetThreadToken、Named Pipe 的客户端模拟

特别重要的一点是:如果想启动进程,原则上需要主令牌

即使拥有模拟令牌,也不一定能直接用它来启动另一个用户的进程。

典型的流程是这样的:

模拟客户端
  ↓
通过 OpenThreadToken 获取模拟令牌
  ↓
通过 DuplicateTokenEx 生成主令牌
  ↓
传给 CreateProcessAsUser 等 API

反过来,如果只是想「让这个线程以另一个用户身份进行文件访问」,那就属于使用模拟令牌的场景,而不是启动进程。

如果把这两者混在一起,就会在 API 参数看起来都没问题的情况下,被 Access deniedThe parameter is incorrect 之类的错误卡住。

7. 理解模拟级别

模拟令牌具有模拟级别(impersonation level)

代表性的有 4 个级别:

模拟级别 大致含义
Anonymous 服务器无法获得客户端的识别信息
Identification 服务器可以识别客户端,但不能用于以该权限访问对象
Impersonation 服务器可以在本机系统上以客户端的权限运行
Delegation 服务器还可以将客户端的权限委派给远程系统

实务中经常卡壳的地方,是 IdentificationImpersonation 的区别。

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 在本地接近当前凭据,常用于远程连接时使用指定凭据的场景

这里想说的重点,不是要死记某个特定的登录类型。

重要的是以下两点:

  1. 登录类型会影响本地访问、网络访问、进程启动时的行为
  2. 需要确认返回的令牌到底是主令牌,还是模拟令牌

例如,把通过 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 来确认 TokenTypeTokenImpersonationLevelTokenElevationTypeTokenIntegrityLevel 等信息。

不过,从实务设计的角度来看,与「模拟管理员用户来做任何事」相比,把必要的最小操作封闭在专用服务或管理 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 把进程启动和文件访问当作同一个话题来处理

有时只做文件访问,用模拟令牌就足够了。

但如果想以另一个用户身份启动进程,就会涉及主令牌、用户配置文件、桌面、环境变量、会话、权限等其他议题。

如果使用 CreateProcessAsUserCreateProcessWithTokenW,应把它当作与模拟不同的设计来对待。

22. 测试的角度

模拟相关处理,如果只在手头的管理员环境中确认,容易出现疏漏。

至少应准备以下这些测试用例。

用例 确认内容
有权限的用户 能读取 / 写入目标文件
无权限的用户 能正确地以 Access denied 失败
不存在的用户 能作为认证失败来处理
密码错误 不在日志中泄露敏感信息,且能正确失败
网络共享 确认本地与 UNC 之间行为差异
异步处理 await 之后仍能在预期范围内运行
发生异常 模拟一定能被解除
并发请求 不会混入其他用户的模拟
服务运行 使用实际的服务账户运行,而不是开发者的交互式登录环境

尤其不能省略这两点:

应该成功的操作确实成功
应该失败的操作确实失败

对于模拟相关处理,不仅要测试成功的场景,还要测试没有权限的用户确实会被拒绝。

如果本应被拒绝的操作反而成功了,就说明模拟或授权的设计可能存在问题。

23. 实现检查清单

在实现前后,应确认下面这份检查清单。

角度 检查内容
目的 能否说明为什么需要模拟
替代方案 是否考虑过服务账户或应用授权是否足够
范围 模拟范围是否已经最小化
还原 出现异常时是否也能确实还原
异步 是否在 RunImpersonatedAsync 内部 await 到处理完成
令牌 是否混淆了主令牌和模拟令牌
模拟级别 是否已区分 IdentificationImpersonation / 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、服务账户、现有的文件服务器和企业内部的域资产。

参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表