从沙箱隔离逃逸:探索CNG密钥隔离服务的权限提升漏洞

本文详细分析了微软CNG Key Isolation服务中的两个漏洞CVE-2023-28229和CVE-2023-36906,前者是多个UAF漏洞,后者是OOB读取漏洞。作者通过堆风水、信息泄露和竞争条件,成功组合利用,实现了从应用容器沙箱到lsass进程的权限提升。

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]。所以,如果我用非零值填充属性对象,然后查询属性对象,它将泄露提供程序对象的地址。

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

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

  1. 喷洒提供程序对象和内存缓冲区对象。提供程序对象用于最终利用阶段,内存缓冲区用于泄露提供程序对象地址。
  2. 释放一些内存缓冲区对象以制造堆空洞,然后分配与内存缓冲区对象大小相同的属性,它将占据一个被释放的空洞,然后查询属性以获取提供程序对象的地址。
  3. 释放足够多的提供程序对象,以确保泄露的提供程序对象地址被释放,然后喷洒与提供程序对象大小相同的属性,以占据泄露的提供程序对象地址。LoadLibraryW地址和payload dll应该存储在伪造的提供程序对象的偏移0x80和偏移0x118处。但我只有一个泄露的地址,我可以在属性缓冲区的另一个偏移处设置payload 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

阅读次数 16592

8 条评论

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