愚弄沙箱:Chrome渲染器逃逸技术揭秘

本文详细分析了CVE-2024-30088漏洞的利用过程,展示了如何通过竞态条件漏洞绕过Chrome渲染器沙箱的完整性检查和作业对象限制,最终实现从不受信完整性级别到SYSTEM权限的完整逃逸链。

愚弄沙箱:Chrome渲染器逃逸 | STAR Labs

2025年7月10日 · 11分钟阅读 · Vincent Yeo (@goatmilkkk)

目录

在我的实习期间,导师Le Qi分配我分析CVE-2024-30088,这是Windows内核映像ntoskrnl.exe中的一个双重获取竞态条件漏洞。GitHub上已有公开的POC,演示了从中等完整性级别到SYSTEM的权限提升(EoP)。

此外,我还被挑战(更像是被迫💀)将漏洞利用链扩展到逃逸Chrome渲染器沙箱,实现从不受信完整性级别到SYSTEM的权限提升。

简单,对吧?🤡

注意:CVE-2024-30088在24H2之前发布,因此我使用23H2 Windows VM进行分析

狩猎开始:寻找CVE-2024-30088的触发点

当NtQueryInformationToken的TokenInformationClass字段设置为TOKEN_ACCESS_INFORMATION常量时,可以触发该漏洞。乍看之下,这个函数完全无害,只是让具有适当访问权限的进程检索访问令牌信息:

1
2
3
4
5
6
7
__kernel_entry NTSYSCALLAPI NTSTATUS NtQueryInformationToken(
  [in]  HANDLE                  TokenHandle,
  [in]  TOKEN_INFORMATION_CLASS TokenInformationClass,
  [out] PVOID                   TokenInformation, // 用户模式缓冲区
  [in]  ULONG                   TokenInformationLength,
  [out] PULONG                  ReturnLength
);

这是一个读取函数,但这并不意味着我们不能用它实现任意写入

对于TOCTOU,追踪TokenInformation很重要,因为它是用户可以控制的缓冲区

注意内核何时写入它

深入探索:追踪线索

查看NtQueryInformationToken中TOKEN_ACCESS_INFORMATION常量的switch case,只有SepCopyTokenAccessInformation引起了我的注意,因为它是唯一一个接受TokenInformation(用户提供的缓冲区)作为参数的函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
NtQueryInformationToken( ... ) {
    
    ...
        
    case TokenAccessInformation: // case 22
    
        ...		
            
        if ( v5 < TokenAccessInformationBufferSize )
        goto LABEL_58;
        SepCopyTokenAccessInformation(
            v30,
            userBuffer, // TokenInformation字段
            v5,
            v147,
            v157,
            Handle,
            v156,
            v155,
            v154,
            v153,
            v152,
            v151,
            v150,
            v110,
            v109);
    
       ...
           
}

跟随SepCopyTokenAccessInformation,我发现它将一堆关于令牌的数据复制到用户缓冲区,然后调用AuthzBasepQueryInternalSecurityAttributesToken来复制令牌对象的安全属性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
__int64 __fastcall SepCopyTokenAccessInformation(
        _TOKEN *Token,
        _TOKEN_ACCESS_INFORMATION *userBuffer,
        UINT64 userBufferLength,
        UINT64 PrivilegeCount,
        UINT64 GroupsLength,
        UINT64 GroupsSALength,
        UINT64 RestrictedSidsLength,
        UINT64 RestrictedSidsSALength,
        UINT64 PackageSidLength,
        UINT64 CapabilitySidsLength,
        UINT64 CapabilitySidsSALength,
        UINT64 TrustSidLength,
        UINT64 SecurityAttributesLength,
        UINT8 UseNewTrust,
        void *NewTrustSid) {
        
	...

    userBuffer->AuthenticationId = Token->AuthenticationId; // 复制一些令牌数据
    v16 = PrivilegeCount;
    userBuffer->TokenType = Token->TokenType;
    userBuffer->ImpersonationLevel = Token->ImpersonationLevel;
    userBuffer->Flags = Token->TokenFlags;
    userBufferEnd = userBuffer + userBufferLength;
    
    ...
    
    AuthzBasepQueryInternalSecurityAttributesToken(
        Token->pSecurityAttributes,
        userBufferSecurityAttributes, // 复制令牌安全属性
        userBufferEnd - userBufferSecurityAttributes,
        &v38
    );
    v36 = &userBufferSecurityAttributes[SecurityAttributesLength];
    userBuffer->SecurityAttributes = userBufferSecurityAttributes;
	return SepConvertTokenPrivilegesToLuidAndAttributes(Token, v36->Privileges);
  
  }

然后,AuthzBasepQueryInternalSecurityAttributesToken调用AuthzBasepCopyoutInternalSecurityAttributes,这里就是漏洞所在。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
__int64 __fastcall AuthzBasepQueryInternalSecurityAttributesToken(
        _AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION *tokenSecurityAttributes,
        _DWORD *userBufferSecurityAttributes,
        unsigned int securityAttributesSize,
        unsigned int *a4) {

	...
		
        result = AuthzBasepCopyoutInternalSecurityAttributes( // 漏洞在这里
        tokenSecurityAttributes, 
        userBufferSecurityAttributes,
        securityAttributesSize);

	...

}

顿悟时刻:理解竞态条件

当TokenObject->SecurityAttributesList中的_UNICODE_STRING结构被复制到用户缓冲区时,漏洞就出现了。

1
2
3
4
5
typedef struct _UNICODE_STRING {
  USHORT Length;
  USHORT MaximumLength;
  PWSTR  Buffer; // 注意:这是指向字符串的指针(不是字符串本身!)
} UNICODE_STRING, *PUNICODE_STRING;

具体来说,它由于以下操作序列而出现:

  • 内核在用户缓冲区中存储一个指针pBuffer
  • 内核将字符串复制到pBuffer指向的内存
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
__int64 __fastcall AuthzBasepCopyoutInternalSecurityAttributes(
        _AUTHZBASEP_SECURITY_ATTRIBUTES_INFORMATION *tokenSecAttr,
        int *userBufferSecurityAttributes,
        unsigned int a3) {
    
    ...
    
    ADJ(offsetToUserBuffer)->unicodeString.MaximumLength = maxLength;
    ADJ(offsetToUserBuffer)->unicodeString.Length = 0;
    ADJ(offsetToUserBuffer)->unicodeString.Buffer = pBuffer; // [1] 指定写入地址
    RtlCopyUnicodeString(&ADJ(offsetToUserBuffer)->unicodeString, (SA_Entry + 0x20)); // [2] 复制字符串
    
    v9 = AuthzBasepCopyoutInternalSecurityAttributeValues(SA_Entry, offsetToUserBuffer - 104, v18, v6 - v18, &v20);
    if ( (v9 & 0x80000000) != 0 )
    goto LABEL_18;
    
    ...
        
}

在步骤[1]和[2]之间,我们可以使用竞态线程更改pBuffer(内核写入的地址),获得部分写入原语。

武器化发现

既然已经有公开的POC,我将只在高层次上描述利用过程:

写入位置 通过解析NtQueryInformationToken的输出缓冲区(即SecurityAttributesList.Flink + 0x20)获取竞态地址

写入内容 通过NtQuerySystemInformation获取利用进程的令牌地址

从这里,我们可以使用竞态线程覆盖令牌的SeDebugPrivilege位来提升到SYSTEM。对于大多数进程,内核将使用Unicode字符串L"TSA://ProcUnique"(32字节)覆盖目标位置。

进入Chrome渲染器沙箱

如果您不熟悉Chrome内部结构(像我一样),我建议查看这些文章。这些资源绝对是救命稻草:

https://developer.chrome.com/blog/inside-browser-part1 https://chromium.googlesource.com/chromium/src/+/main/docs/design/sandbox.md

本质上,Chrome使用多进程架构,其中一个特权进程(又称代理)控制多个沙箱化的目标进程。目标进程以受限权限运行,并通过IPC机制与代理通信。

特别是,Chrome渲染器进程在特别严格的沙箱下运行,因为它处理包含JavaScript的不受信任的Web内容。

限制 描述
令牌 无特权
完整性级别 不受信
作业 禁止创建子进程
备用桌面 第三个桌面,与默认和登录桌面分离

障碍一:完整性检查机制

在Chrome渲染器中运行利用时,我遇到的第一个障碍是NtQuerySystemInformation(用于检索渲染器进程的令牌地址)失败,错误代码为0xC0000022(STATUS_ACCESS_DENIED)。

要找出原因,我们必须分析NtQuerySystemInformation,它调用ExpQuerySystemInformation:

1
2
3
4
5
6
NTSTATUS __fastcall NtQuerySystemInformation(int a1, unsigned int *a2, unsigned int a3, ULONG *a4) {

    ...

    return ExpQuerySystemInformation(a1, p_Group, v6, a2, a3, a4); // 这里
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
NTSTATUS __fastcall ExpQuerySystemInformation(int a1, void *a2, unsigned int a3, unsigned int *a4, unsigned int Length, ULONG *a6)
{
    
    ...
    
    switch ( a1 ) {
        case 64: // SystemExtendedHandleInformation
            
            ...

            if ( ExIsRestrictedCaller(PreviousMode) ) // 这里
                return 0xC0000022; // STATUS_ACCESS_DENIED
            SystemBasicInformation = ExpGetHandleInformationEx(a4, Length, &v130);
            break;
            
            ...
            
    }
}

原来代码出错是因为ExIsRestrictedCaller。让我们看看它是做什么的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
__int64 __fastcall ExIsRestrictedCaller(char a1) {

    ...
    
    SeCaptureSubjectContext(&SubjectContext);
    pass_check = SeAccessCheck(
        SeMediumDaclSd,
        &SubjectContext,
        0,
        0x20000u,
        0,
        0,
        &ExpRestrictedGenericMapping,
        1,
        &GrantedAccess,
        &AccessStatus);
    SeReleaseSubjectContext(&SubjectContext);
    if ( !pass_check )
        return 1;
    LOBYTE(v1) = AccessStatus < 0;
    return v1;
}

查看SeAccessCheck的前两个参数,似乎SubjectContext(渲染器进程)与SeMediumDaclSd进行比较,后者指向SepMediumDaclSd——一个代表中等完整性级别的安全描述符。

由于利用之前在Chrome外部成功,我们可以推断检查失败一定是由于渲染器沙箱的安全机制之一。特别是,渲染器进程以不受信完整性级别运行,因此当它的上下文与SeMediumDaclSd比较时,检查失败。

解决方案:破坏安全描述符

首先,我们必须熟悉SepMediumDaclSd使用的_SECURITY_DESCRIPTOR结构:

1
2
3
4
5
6
7
8
9
typedef struct _SECURITY_DESCRIPTOR {
  BYTE                        Revision;
  BYTE                        Sbz1;
  SECURITY_DESCRIPTOR_CONTROL Control; // 位标志,指示Sacl/Dacl是否存在。2字节长
  PSID                        Owner; 
  PSID                        Group;
  PACL                        Sacl; // 包含ACE条目,用于审计,MAC(完整性级别检查)
  PACL                        Dacl; // 包含ACE条目,用于DAC(允许/拒绝SID)
} SECURITY_DESCRIPTOR, *PISECURITY_DESCRIPTOR;

然后,让我们使用调试器检查SepMediumDaclSd的字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
WINDBG>x nt!SepMediumDaclSd
fffff80104f55f20 nt!SepMediumDaclSd = <no type information>

WINDBG>!sd fffff80104f55f20
->Revision: 0x1
->Sbz1    : 0x0
->Control : 0x10
            SE_SACL_PRESENT // 这里
->Owner   : S-1-5-18
->Group   : S-1-5-18
->Dacl    :  is NULL
->Sacl    : 
->Sacl    : ->AclRevision: 0x2
->Sacl    : ->Sbz1       : 0x0
->Sacl    : ->AclSize    : 0x20
->Sacl    : ->AceCount   : 0x1
->Sacl    : ->Sbz2       : 0x0
->Sacl    : ->Ace[0]: ->AceType: SYSTEM_MANDATORY_LABEL_ACE_TYPE // 指定强制访问级别(即完整性级别)
->Sacl    : ->Ace[0]: ->AceFlags: 0x0
->Sacl    : ->Ace[0]: ->AceSize: 0x14
->Sacl    : ->Ace[0]: ->Mask : 0x00000002
->Sacl    : ->Ace[0]: ->SID: S-1-16-8192

此时,我假设如果控制位标志SE_SACL_PRESENT(0x10)被清零,SeAccessCheck将假定安全描述符中没有SACL条目,从而跳过强制完整性控制检查。我使用调试器将Control字段修补为零进行了测试,果然有效!

精确手术问题

由于SepMediumDaclSd是ntoskrnl.exe中的一个全局变量,我们可以使用预取侧信道绕过kASLR获取其地址。然后,我们可以利用从CVE-2024-30088获得的部分写入原语来覆盖SepMediumDaclSd中的Control字段。

然而,当我尝试使用利用而不是修补来覆盖时,我得到了一个BSOD,错误消息如下:

1
2
3
4
5
6
*** Fatal System Error: 0x0000003b ( // SYSTEM_SERVICE_EXCEPTION
    0x00000000C0000005, // STATUS_ACCESS_VIOLATION
	0xFFFFF8010447BC7A, // 故障指令地址(这里的代码尝试访问Owner字段)
	0xFFFFF880DA5DDF20,
	0x0000000000000000
)

由此,我意识到由于利用覆盖了32字节,这意味着除了覆盖Control字段外,利用还损坏了原本持有有效池地址的Owner字段。结果,当SeAccessCheck尝试访问现在无效的指针时,Windows崩溃。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 利用前
PAGEDATA:FFFFF80104F55F20 ; _SECURITY_DESCRIPTOR SepMediumDaclSd
PAGEDATA:FFFFF80104F55F20 SepMediumDaclSd db 1                    ; Revision
PAGEDATA:FFFFF80104F55F21 db 0                                    ; Sbz1
PAGEDATA:FFFFF80104F55F22 dw 10h                                  ; Control
PAGEDATA:FFFFF80104F55F24 db 0, 0, 0, 0
PAGEDATA:FFFFF80104F55F28 dq 0FFFFBB0A8786B520h                   ; Owner
PAGEDATA:FFFFF80104F55F30 dq 0FFFFBB0A8786B520h                   ; Group
PAGEDATA:FFFFF80104F55F38 dq 0FFFF980E588AF5D0h                   ; Sacl
PAGEDATA:FFFFF80104F55F40 dq 0                                    ; Dacl

// 利用后(使用L"TSA//ProcUnique"覆盖32字节,从0xFFFFF80104F55F1F开始)
PAGEDATA:FFFFF80104F55F20 ; _SECURITY_DESCRIPTOR SepMediumDaclSd
PAGEDATA:FFFFF80104F55F20 SepMediumDaclSd db 0                    ; Revision
PAGEDATA:FFFFF80104F55F21 db 53h                                  ; Sbz1
PAGEDATA:FFFFF80104F55F22 dw 4100h                                ; Control // 清零低字节以绕过检查
PAGEDATA:FFFFF80104F55F24 db 0, 3Ah, 0, 5Ch
PAGEDATA:FFFFF80104F55F28 dq offset unk_503030785C303078          ; Owner // 无效池地址,访问时出错
PAGEDATA:FFFFF80104F55F30 dq offset unk_550063006F007200          ; Group // 未访问
PAGEDATA:FFFFF80104F55F38 dq offset unk_7500710069006E00          ; Sacl // 仅在设置SE_SACL_PRESENT时访问
PAGEDATA:FFFFF80104F55F40 dq offset unk_6500                      ; Dacl // 仅在设置SE_DACL_PRESENT时访问

因此,我们必须避免覆盖Owner字段中的池地址,特别是高字节。只要结果地址落在有效的池区域内,覆盖低字节是可能的。

我的最终利用从SepMediumDaclSd - 0n23开始覆盖。我选择这个地址来满足三个条件:

  1. 避免损坏SepNullDaclSd的Owner字段,它直接位于SepMediumDaclSd之上
  2. 清零SepMediumDaclSd的Control字段(低字节)
  3. 最小化SepMediumDaclSd的Owner字段中被覆盖的字节数
1
// 从0xFFFF
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计