愚弄沙箱: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开始覆盖。我选择这个地址来满足三个条件:
- 避免损坏SepNullDaclSd的Owner字段,它直接位于SepMediumDaclSd之上
- 清零SepMediumDaclSd的Control字段(低字节)
- 最小化SepMediumDaclSd的Owner字段中被覆盖的字节数