在最近几个月,微软修复了我报告的两个CNG Key Isolation服务漏洞,编号为CVE-2023-28229和CVE-2023-36906。其中CVE-2023-28229包含6个具有相似根本原因的释放后重用漏洞,CVE-2023-36906是一个越界读取信息泄露漏洞。微软在其评估状态中标记为“不太可能被利用”,但实际上,我利用这两个漏洞成功完成了利用。
作为一名年度更新的博主,我分享这篇博文来介绍我在CNG Key Isolation服务上的利用过程,让我们开始旅程吧!
简单概述
CNG Key Isolation是lsass进程下的一个服务,为私钥提供密钥进程隔离。CNG Key Isolation作为一个RPC服务器运行,可以被Appcontainer完整性进程访问。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],它将继续执行易受攻击的代码。因此句柄值是可预测的。
我制作了以下简单的图表来向您展示这些对象之间的关系。
CVE-2023-28229的根本原因
在本节中,我将介绍CVE-2023-28229的根本原因,我将以密钥对象为例,实际上其余对象也有类似的问题。
当我研究keyiso服务时,我发现每个对象都有自己的分配和释放接口。以密钥对象为例,有分配RPC接口s_SrvRpcCryptCreatePersistedKey和释放RPC接口s_SrvRpcCryptFreeKey。我很快注意到对象分配和释放之间存在问题。
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],然后它将调用vftable中的函数。
当密钥对象的引用计数被初始化为1并增加时,没有锁定函数,这意味着在初始化和添加之间存在一个时间窗口,在引用计数设置为1 [a] 之后,密钥对象将被释放 [c] [d],并且当引用计数增加1 [b] 时,它可以通过下一次检查 [e],最终,当调用vftable的函数时 [f],将导致释放后使用。
我编写了PoC并发现它可能是可利用的,但如以下代码所示,vftable的函数是从存储在密钥对象偏移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));
|
如果我能够控制释放的缓冲区,并且我有一个有用的地址,我可以将这个地址设置为释放缓冲区的偏移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,最终导致appcontainer沙箱逃逸。
补丁
微软通过在密钥对象初始化和释放之间添加锁定函数来修补。
补丁前:
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));
[...]
|