CNG密钥隔离服务中的权限提升漏洞分析与利用

本文深入分析了CNG密钥隔离服务中的两个安全漏洞CVE-2023-28229和CVE-2023-36906,详细介绍了UAF漏洞的根因、信息泄露漏洞的利用方法,以及如何通过漏洞链实现沙箱逃逸的完整攻击过程。

从沙箱中隔离我 - 探索CNG密钥隔离的权限提升

作者:k0shl of Cyber Kunlun

摘要

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

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

简单概述

CNG密钥隔离是lsass进程下的一个服务,为私钥提供密钥进程隔离。CNG密钥隔离作为一个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],然后它将调用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竞争条件得到了修复。

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