Isolate me from sandbox - Explore elevation of privilege of CNG Key Isolation
作者:昆仑实验室的 k0shl
摘要
最近几个月,微软修复了我上报的CNG Key Isolation服务中的漏洞,分配了CVE-2023-28229和CVE-2023-36906。CVE-2023-28229包含了6个根源相似的释放后使用漏洞,CVE-2023-36906是一个越界读取信息泄露漏洞。微软在评估状态中将其标记为"利用可能性较低",但实际上,我利用这两个漏洞完成了利用链的构建。
作为年更博主(抱歉:P),我分享这篇博文来介绍我在CNG Key Isolation服务上的利用过程,让我们开始吧!
简要概述
CNG Key Isolation是lsass进程下的一个服务,为私钥提供密钥进程隔离。CNG Key Isolation作为一个RPC服务器工作,可以被诸如Adobe或Firefox中的渲染进程等应用容器完整性进程访问。keyiso服务中有一些重要的对象,我们简单过一遍:
- 上下文对象。上下文对象就像是keyiso RPC服务器的管理对象,当客户端调用打开存储提供程序来创建一个新的提供程序对象时,它会持有该提供程序对象,并由一个名为SrvCryptContextList的全局列表管理。这个对象必须首先初始化。
- 提供程序对象。客户端应该在所有提供程序的集合中打开一个已存在的提供程序,如果打开成功,它将分配提供程序对象并将指针存储到上下文对象中。
- 密钥对象。密钥对象由上下文对象管理,它会被分配并插入到上下文对象中。
- 内存缓冲区对象。内存缓冲区对象由上下文对象管理,它会被分配并插入到上下文对象中。
- 密钥对象。密钥对象由上下文对象管理,它会被分配并插入到上下文对象中。
在这四个对象中,提供程序对象/密钥对象/密钥对象具有相似的对象结构,对象的偏移0x0存储着魔数值,0x44444446表示提供程序对象,0x44444447表示密钥对象,0x44444449表示密钥对象。当这些对象被释放时,魔数值会被设置为另一个值。对象的偏移0x8存储着引用计数,对象的偏移0x30存储着对象的索引。这个索引就像是对象的句柄,当客户端用它来搜索特定对象时,它会成为一个标志,这意味着对象是可预测的,它从0开始,当一个新的对象被分配时,它会加1。
关于我如何赢得对象句柄的竞争,这里有一些额外的信息。当我审查代码时,我注意到句柄是可预测的,让我们检查一下SrvAddKeyToList函数:
1
2
3
4
5
6
7
|
SrvAddKeyToList:
handlevalue = ++*(_QWORD *)(context_object + 0xA0); // =====> [a]
*(_QWORD *)(key_object + 0x30) = handlevalue; // =====> [b]
SrvFreeKey:
if ( *((_QWORD *)key_object + 6) == handlevalue ) // ====> [c]
break;
|
句柄值存储在上下文对象的偏移0xA0处,实际上,句柄值就像一个索引值,初始值为0,当一个新的密钥对象被分配时,索引会加1 [a] 并被设置到新密钥对象的偏移0x30处 [b]。当密钥对象被释放时,它会比较句柄值,如果匹配 [c],它将继续执行易受攻击的代码。所以句柄值是可预测的,例如,当你创建第一个密钥时,你可以用句柄值1调用SrvFreeKey;或者当你创建第10个密钥对象时,你可以用句柄值10调用SrvFreeKey。这样,当使用新的句柄值将密钥添加到上下文对象时,密钥对象可以在FreeKey函数中被检索到。
我制作了以下简单的图表来向你展示这些对象之间的关系。
CVE-2023-28229的根本原因
在本节中,我将介绍CVE-2023-28299的根本原因,我将以密钥对象为例,实际上其余对象也有类似的问题。
当我在研究keyiso服务时,我发现每个对象都有它们自己的分配和释放接口,例如密钥对象,有名为s_SrvRpcCryptCreatePersistedKey的分配RPC接口和名为s_SrvRpcCryptFreeKey的释放RPC接口。我很快注意到对象分配和释放之间存在问题。
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
40
41
42
43
44
|
__int64 __fastcall SrvCryptCreatePersistedKey(
struct _RTL_CRITICAL_SECTION *a1,
__int64 a2,
_QWORD *a3,
__int64 a4,
__int64 a5,
int a6,
int a7)
{
[...]
keyobject = RtlAllocateHeap(NtCurrentPeb()->ProcessHeap, 0, 0x38ui64);
[...]
*((_DWORD *)keyobject + 1) = 0;
*(_DWORD *)keyobject = 0x44444447;
*((_DWORD *)keyobject + 2) = 1; // ==========> [a]
*((_QWORD *)keyobject + 4) = v12;
SrvAddKeyToList((__int64)a1, (__int64)keyobject); // =============> [b]
v11 = 0;
*a3 = *((_QWORD *)keyobject + 6);
return v11;
[...]
}
__int64 __fastcall SrvCryptFreeKey(__int64 a1, __int64 a2, __int64 a3)
{
[...]
if ( _InterlockedExchangeAdd(freebuffer + 2, 0xFFFFFFFF) == 1 ) // ============> [c]
{
v17 = SrvFreeKey((PVOID)freebuffer); // ===============> [d]
if ( v17 < 0 )
DebugTraceError(
(unsigned int)v17,
"Status",
"onecore\\ds\\security\\cryptoapi\\ncrypt\\iso\\service\\srvutils.c",
700i64);
}
if ( _InterlockedExchangeAdd(freebuffer + 2, 0xFFFFFFFF) == 1 ) // ===============> [e]
{
v12 = (*(__int64 (__fastcall **)(_QWORD, _QWORD))(*((_QWORD *)freebuffer + 4) + 0x80i64))( // ==============> [f]
*(_QWORD *)(*((_QWORD *)freebuffer + 4) + 0x118i64),
*((_QWORD *)freebuffer + 5));
v13 = v12;
[...]
}
|
当客户端调用分配RPC接口时,keyiso会从进程堆中分配一个堆并初始化结构,它会先将密钥对象的引用计数设置为1 [a],然后将密钥对象添加到上下文对象中,并增加引用计数 [b]。当客户端释放密钥对象时,keyiso会检查引用是否为1 [c],如果是,keyiso将释放密钥对象 [d],但它仍然在释放后使用了密钥对象 [e],然后它会调用虚函数表中的函数。
在密钥对象的引用计数初始化为1和增加之间没有锁函数,这意味着在初始化和添加之间存在一个时间窗口,在引用计数设置为1 [a]之后,密钥对象将被释放 [c] [d],并且在引用计数加1 [b]时,它可以通过下一次检查 [e],最终,当虚函数表中的函数被调用时 [f],会导致释放后使用。
我编写了PoC并发现它可能是可利用的,但如下面的代码所示,虚函数表中的函数是从存储在密钥对象偏移0x20处的指针中选取的,这意味着即使我可以控制释放的缓冲区,我仍然需要密钥对象偏移0x20处的一个有效地址。我需要一个信息泄露。
CVE-2023-36906的根本原因
然后我尝试寻找信息泄露,我浏览了RPC接口,发现提供程序对象中存储了一个属性结构,该属性可以通过RPC接口SPCryptSetProviderProperty和SPCryptGetProviderProperty进行查询和设置。
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
__int64 __fastcall SPCryptSetProviderProperty(__int64 a1, const wchar_t *a2, _DWORD *a3, unsigned int a4, int a5)
{
[...]
if ( !wcscmp_0(a2, L"Use Context") )
{
v15 = *(void **)(v8 + 32);
if ( v15 )
RtlFreeHeap(NtCurrentPeb()->ProcessHeap, 0, v15);
Heap = RtlAllocateHeap(NtCurrentPeb()->ProcessHeap, 0, v6);
*(_QWORD *)(v8 + 32) = Heap;
if ( !Heap )
{
v10 = 1450i64;
LABEL_21:
v9 = -2146893810;
v11 = 2148073486i64;
goto LABEL_42;
}
v17 = Heap;
goto LABEL_40;
}
memcpy_0(v17, a3, v6); // ============> [b]
}
[...]
}
__int64 __fastcall SPCryptGetProviderProperty(
__int64 a1,
const wchar_t *a2,
_DWORD *a3,
unsigned int a4,
unsigned int *a5,
int a6)
{
[...]
if ( !wcscmp_0(a2, L"Use Context") )
{
v17 = *(_QWORD *)(v10 + 32);
v15 = 21;
if ( !v17 )
goto LABEL_31;
do
++v13;
while ( *(_WORD *)(v17 + 2 * v13) ); // =============> [c]
v16 = 2 * v13 + 2;
if ( 2 * (_DWORD)v13 == -2 )
{
LABEL_31:
v11 = 517i64;
LABEL_32:
v9 = -2146893807;
v12 = 2148073489i64;
goto LABEL_57;
}
v25 = *(const void **)(v10 + 32);
memcpy_0(a3, v25, v16); // ============> [d]
}
[...]
}
|
客户端可以指定要设置的属性,如果属性名为"Use Context",它将分配一个大小可由客户端控制的新缓冲区,并将"Use Context"缓冲区存储到提供程序对象中。但当我审查查询代码时,我注意到"Use Context"应该是一个字符串类型,它会在while循环中遍历缓冲区,直到遇到空字符时跳出循环 [c],然后将整个缓冲区返回给客户端。
当我将"Use Context"属性设置为缓冲区内容非零时,会发生越界读取。实际上,这个属性是一个很好的利用原语对象,因为缓冲区的大小和内容可以由客户端控制。
利用阶段
现在,我有一个可以泄露相邻对象内容的越界读取漏洞,以及一个释放后使用的权限提升漏洞,如果我能控制释放的缓冲区,就可以调用任意地址。我认为是时候链接这些漏洞了。
我回过头来看释放的缓冲区,首先找出我需要什么:
1
2
3
|
v12 = (*(__int64 (__fastcall **)(_QWORD, _QWORD))(*((_QWORD *)freebuffer + 4) + 0x80i64))(
*(_QWORD *)(*((_QWORD *)freebuffer + 4) + 0x118i64),
*((_QWORD *)freebuffer + 5));
|
如果我能控制freebuffer,并且我有一个有用的地址,我可以把这个地址设置到freebuffer的偏移0x20处。在有效地址中有两个重要的地址,该地址的偏移0x80应该是一个有效的函数地址,偏移0x118应该是另一个缓冲区。
lsass进程启用了XFG缓解措施,所以我不能在这个利用中使用ROP,但如果我能控制函数的第一个参数,我就可以使用LoadLibraryW来加载一个受控的dll路径。所以目标是将有效地址的偏移0x80设置为LoadLibraryW地址,并将payload dll设置到该地址偏移0x118处存储的地址。
正如我在上一节中介绍的,属性"Use Context"是一个很好的原语对象,因为我可以控制这个属性的大小和整个内容,并且我有一个越界读取问题,所以问题是哪个对象应该与我的属性对象相邻?
我回顾了keyiso的所有对象,发现内存缓冲区可能是一个有用的目标。
1
2
3
4
5
6
7
8
9
|
v7 = SrvLookupAndReferenceProvider(hContext, hProvider, 0);
[...]
_InterlockedIncrement((volatile signed __int32 *)(v7 + 8));
*(_QWORD *)Heap = v7; // ===========> [a]
*((_QWORD *)Heap + 1) = v32;
SrvAddMemoryBufferToList((__int64)hContext, (__int64)Heap);
v26 = *((_QWORD *)Heap + 4);
Heap = 0i64;
*v15 = v26;
|
当内存缓冲区创建时,keyiso会查找提供程序对象并将其存储到内存缓冲区的偏移0x0处 [a]。所以,如果我用非零值填充属性对象,然后查询属性对象,它将泄露提供程序对象的地址。
当然,不同的对象有不同的尺寸,在进行堆风水时,我不需要担心不同对象对布局的影响。
最后,我设想出了如下的利用场景:
- 喷洒提供程序对象和内存缓冲区对象。提供程序对象用于最终利用阶段,内存缓冲区用于泄露提供程序对象地址。
- 释放一些内存缓冲区对象以制造堆空洞,然后分配与内存缓冲区对象大小相同的属性,它将占据一个被释放的空洞,然后查询属性以获取提供程序对象的地址。
- 释放足够多的提供程序对象,以确保泄露的提供程序对象地址被释放,然后喷洒与提供程序对象大小相同的属性,以占据泄露的提供程序对象地址。LoadLibraryW地址和payload dll应该存储在伪造的提供程序对象的偏移0x80和偏移0x118处。但我只有一个泄露的地址,我可以在属性缓冲区的另一个偏移处设置payload dll路径,并将地址设置在属性缓冲区的偏移0x118处。
- 最后,我可以用三个不同的线程触发释放后使用:线程A用于分配密钥对象,线程B用于释放密钥对象,线程C用于分配与密钥对象大小相同的属性对象,并在属性缓冲区的偏移0x20处设置伪造的引用计数和泄露的属性地址。
- 当客户端赢得竞争条件(即在密钥对象在SrvFreeKey函数中被释放后,属性对象占据了密钥对象的空洞),它最终将在lsass进程中加载任意dll,最终导致应用容器沙箱逃逸。
补丁
微软通过在密钥对象初始化和释放之间添加锁函数来修补漏洞。
补丁前:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
[...]
RtlLeaveCriticalSection(v5);
if ( _InterlockedExchangeAdd(freebuffer + 2, 0xFFFFFFFF) == 1 )
{
v17 = SrvFreeKey((PVOID)freebuffer);
if ( v17 < 0 )
DebugTraceError(
(unsigned int)v17,
"Status",
"onecore\\ds\\security\\cryptoapi\\ncrypt\\iso\\service\\srvutils.c",
700i64);
}
if ( _InterlockedExchangeAdd(freebuffer + 2, 0xFFFFFFFF) == 1 )
{
v12 = (*(__int64 (__fastcall **)(_QWORD, _QWORD))(*((_QWORD *)freebuffer + 4) + 0x80i64))(
*(_QWORD *)(*((_QWORD *)freebuffer + 4) + 0x118i64),
*((_QWORD *)freebuffer + 5));
[...]
|
补丁后:
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
|
[...]
RtlEnterCriticalSection(v8);
v12 = *((_QWORD *)v9 + 2);
if ( *(volatile signed __int64 **)(v12 + 8) != v9 + 2
|| (v13 = (volatile signed __int64 **)*((_QWORD *)v9 + 3), *v13 != v9 + 2) )
{
__fastfail(3u);
}
*v13 = (volatile signed __int64 *)v12;
*(_QWORD *)(v12 + 8) = v13;
if ( _InterlockedExchangeAdd64(v9 + 1, 0xFFFFFFFFFFFFFFFFui64) == 1 )
{
v14 = SrvFreeKey(v9);
if ( v14 < 0 )
DebugTraceError(
(unsigned int)v14,
"Status",
"onecore\\ds\\security\\cryptoapi\\ncrypt\\iso\\service\\srvutils.c",
705i64);
}
RtlLeaveCriticalSection(v8);
if ( _InterlockedExchangeAdd64(v9 + 1, 0xFFFFFFFFFFFFFFFFui64) == 1 )
{
v15 = (*(__int64 (__fastcall **)(_QWORD, _QWORD))(*((_QWORD *)v9 + 4) + 128i64))(
*(_QWORD *)(*((_QWORD *)v9 + 4) + 280i64),
*((_QWORD *)v9 + 5));
[...]
|
感谢与 @chompie1337、@DannyOdler 和 @cplearns2h4ck 的讨论。实际上,即使在补丁之后,在SrvFreeKey被调用后,仍然应该存在UAF,因为SrvFreeKey函数必须释放密钥对象,但在函数返回后仍然存在一个引用。但该函数似乎永远不会被调用,这是我无法理解为什么微软要这样设计的奇怪代码。但在他们在密钥对象初始化和释放之间添加锁函数后,UAF竞争条件得到了修复。
2023-09-01
阅读次数 16592
8 条评论