欺骗沙箱:一次Chrome-atic逃逸
目录
在我的实习期间,我的导师Le Qi分配我分析CVE-2024-30088,这是Windows内核映像ntoskrnl.exe中的一个双重获取竞争条件漏洞。GitHub上有一个公开的POC,演示了从中等完整性级别到SYSTEM的权限提升(EoP)。此外,我还被挑战(更像是被迫💀)将漏洞利用链式操作,以逃逸Chrome渲染器沙箱,实现从不信任完整性级别到SYSTEM的EoP。容易,对吧?🤡
注意: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
|
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
|
__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
|
__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
|
__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内部结构(像我一样),我建议查看这些文章。这些资源是救命稻草:
本质上,Chrome使用多进程架构,其中一个特权进程(又称代理)控制多个沙箱化的目标进程。目标进程以受限权限运行,并通过IPC机制与代理通信。特别是,Chrome渲染器进程在特别限制的沙箱中运行,因为它处理包含JavaScript的不受信任的Web内容。
| 限制 |
描述 |
| 令牌 |
无特权 |
| 完整性级别 |
不信任 |
| 作业 |
不允许创建子进程 |
| 备用桌面 |
第三个桌面,与默认和登录桌面分开 |
障碍一:完整性警察
在Chrome渲染器中运行利用时,我遇到的第一个障碍是NtQuerySystemInformation(用于检索渲染器进程的令牌地址)失败,错误代码为0xC0000022(STATUS_ACCESS_DENIED)。要找出原因,我们必须分析NtQuerySystemInformation,它调用ExpQuerySystemInformation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
NTSTATUS __fastcall NtQuerySystemInformation(int a1, unsigned int *a2, unsigned int a3, ULONG *a4) {
...
return ExpQuerySystemInformation(a1, p_Group, v6, a2, a3, a4); // 这里
}
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
|
__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字段中被覆盖的字节数。
1
2
3
|
// 从0xFFFFF80104F55F09开始覆盖
PAGEDATA:FFFFF80104F55EF8 ; _SECURITY_DESCRIPTOR SepNullDaclSd
P
|