探索CNG密钥隔离服务中的权限提升漏洞利用

本文深入分析了Microsoft CNG密钥隔离服务中的两个漏洞CVE-2023-28229和CVE-2023-36906。前者为6个具有相似根本原因的释放后重用漏洞,后者为越界读取信息泄露。作者成功组合利用这两个漏洞,在Lsass进程中加载任意DLL,实现了应用容器沙箱逃逸。

作者:k0shl of Cyber Kunlun

摘要

近几个月,微软修补了我报告的CNG密钥隔离服务中的漏洞,分配了CVE-2023-28229和CVE-2023-36906。CVE-2023-28229包含6个具有相似根本原因的释放后重用漏洞,CVE-2023-36906是一个越界读取信息泄露漏洞。微软在评估状态中将它们标记为"Exploitation Less Likely",但实际上,我利用这两个漏洞完成了利用。

作为年度更新的博主(抱歉:P),我分享这篇博客来介绍我对CNG密钥隔离服务的利用,让我们开始旅程吧!

简单概述

CNG密钥隔离是Lsass进程下的一个服务,为私钥提供密钥进程隔离。CNG密钥隔离作为RPC服务器工作,可以被应用容器完整性进程(如Adobe或Firefox中的渲染进程)访问。keyiso服务中有一些重要的对象,让我们简单了解一下:

  • 上下文对象 (Context object)。上下文对象就像是keyiso RPC服务器的管理对象,当客户端调用打开存储提供者来创建新的提供者对象时,它会持有提供者对象,并由一个名为SrvCryptContextList的全局列表管理。此对象必须首先初始化。
  • 提供者对象 (Provider object)。客户端应在所有提供者的集合中打开一个已存在的提供者,如果提供者打开成功,它将分配提供者对象并将指针存储到上下文对象中。
  • 密钥对象 (Key object)。密钥对象由上下文对象管理,它将被分配并插入到上下文对象中。
  • 内存缓冲区对象 (Memory Buffer object)。内存缓冲区对象由上下文对象管理,它将被分配并插入到上下文对象中。
  • 秘密对象 (Secret object)。秘密对象由上下文对象管理,它将被分配并插入到上下文对象中。

在这四个对象中,提供者对象/密钥对象/秘密对象具有相似的对象结构:对象的偏移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],然后它将调用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));

如果我能控制freebuffer,并且我有一个有用的地址,我可以将这个地址设置为freebuffer的偏移0x20处。在这个有效地址中有两个重要的地址:该地址的偏移0x80应该是一个有效的函数地址,偏移0x118应该是另一个缓冲区。

Lsass进程启用了XFG缓解措施,因此我无法在此利用中使用ROP。但如果我能控制函数的第一个参数,我可以使用LoadLibraryW来加载一个受控的dll路径。因此,目标是将有效地址的偏移0x80设置为LoadLibraryW地址,并将有效载荷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]。因此,如果我用非零值填充属性对象,当我查询属性对象时,它将泄露提供者对象的地址。

当然,不同的对象有不同的尺寸。当我进行堆风水时,不需要担心不同对象对布局的影响。

最后,我设想出如下的利用场景:

  1. 喷洒提供者对象和内存缓冲区对象。提供者对象用于最终利用阶段,内存缓冲区用于泄露提供者对象地址。
  2. 释放一些内存缓冲区对象以制造堆空洞,然后分配与内存缓冲区对象大小相同的属性。它将占据其中一个释放的空洞,然后查询该属性以获取提供者对象地址。
  3. 释放足够多的提供者对象,以确保泄露的提供者对象被释放,然后喷洒与提供者对象大小相同的属性,以占据泄露的提供者对象地址。LoadLibraryW地址和有效载荷dll应存储在假提供者对象的偏移0x80和偏移0x118处。但我只有一个泄露的地址。我可以将有效载荷dll路径设置在属性缓冲区的另一个偏移处,并将地址设置在属性缓冲区的偏移0x118处。
  4. 最后,我可以用三个不同的线程触发释放后重用:线程A用于分配密钥对象,线程B用于释放密钥对象,线程C用于分配与密钥对象大小相同的属性对象,并在属性缓冲区的偏移0x20处设置假的引用计数和泄露的属性地址。
  5. 当客户端赢得竞争(即在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

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计