This post describes the blind exploitation of a known driver vulnerability in the context of @HackSysTeam‘s Advanced Windows Kernel Exploitation training given at NorthSec 2019 in Montreal. Skip to the conclusion for a step-by-step summary of the exploit without spoiling the solution.
The full exploit code can be found on my GitHub
Last week I attended NorthSec 2019 and was fortunate enough to take part in the Advanced Windows Kernel Exploitation training offered by HackSysTeam. The course was overall very pleasant and covered foundational kernel space concepts all the way up to advanced mitigation bypasses and exploitation. The material was extremely well presented and explained, making it easy to follow along.
The instructors also ran a mini CTF “competition” (which I may have taken a bit too seriously!) in parallel to the training to provide an environment in which to put the attendees’ newly acquired knowledge to the test. The track had increasingly difficult challenges that culminated in exploiting a real driver without any prior knowledge of its internals. While nobody solved it during the training, I could not let go and decided that I would finish it no matter what.
In this article I’ll try to give a detailed account of each step taken along the way to get a fully working exploit.
NOTE: For brevity I have only given decompiled and slightly cleaned up code listings in this post, but I have included the function addresses for those who want to look at the binary or assembly listings.
The CTF challenge description only mentions a
System Mechanics driver to
exploit, but does not give any additional information about how to interact with
it, leaving it up to the participant to figure out the where, what, and how.
amp.sys are provided, meaning that the
driver to exploit is likely one of those.
The first step is thus to setup a VM and install the program, and take a look at
the installed drivers using a tool called WinObj. After confirming that the
drivers are indeed present, it’s important to identify which driver exposes
functionality to userland. Here, it’s helpful to know that all kernel drivers
are required to create a
Device in order to expose any kind of
functionality to the outside world, including other kernel drivers. This means
it’s easy to spot the device using
Driver objects in Windows expose a limited number of I/O Request Packet (
slots which act as a low-level callback interface for specific events that occur
in the system. A few examples include
IRP_MJ_CLOSE. From a
userland perspective, the interesting
IRP code is
which is a generic packet that contains an operation code named an I/O control
IOCTL for short. Without diving too much into internals, this allows
the driver to expose custom functionality for users to call through the Windows
IoDeviceControl API call in user mode, as long as they can acquire a handle to
the driver device.
Since the IRP table must be populated before returning from the
(the equivalent of
main for kernel drivers), it’s relatively straightforward
to locate the IOCTL handling routine and thus identify exposed IOCTLs by
reversing the driver from its entry point.
One way to do that is to use Ghidra and locate calls to
both reveals the device name as well as the general location where the IRP
handler functions are bound.
We know the IOCTL handler must be at
0x2c580 because the IRP handler table is
located at offset
+0x70 in the
14 according to MagNumDb.
1: kd> dt _DRIVER_OBJECT nt!_DRIVER_OBJECT +0x000 Type : Int2B +0x002 Size : Int2B +0x008 DeviceObject : Ptr64 _DEVICE_OBJECT ... snip ... +0x070 MajorFunction :  Ptr64 long //  at +0x70 + (8 * 0) // ... //  at +0x70 + (8 * 0n14) = 0xe0
Diving into the handler function it’s fairly simple, and has a single IOCTL check insead of the expected switch statement:
This bit of code gives us two useful bits of knowledge:
- The IOCTL code:
- The address of the handler for this IOCTL
During the training, one of the CTF flags was to write a fuzzer to discover a crash, however, during the initial reversing effort to identify the IOCTL to fuzz, a quick look at the IOCTL handler was enough to notice a vulnerability thanks to Ghidra’s (free) decompiler which is so powerful that it makes it almost trivial to find what we are looking for.
Indeed, the user-controlled pointer is being dereferenced and written to in kernel-land. This effectively means that the caller is able to write anywhere in valid memory, including passing a pointer to kernel memory and having it written to. Unforutnately, finding this vulnerability and exploiting it are two very different stories.
In this section I am omitting a lot of hours spent hunting deadends. There are two main objectives here:
- Figure out where to write
- Figure out what to write
Despite the obviousness of those objectives, and having the vulnerability right in my face, it somehow took a lot longer than I’m comfortable admitting to connect the dots.
The general idea is that each process running in Windows is associated with an access token which determines what the process is allowed to do. The token is a complex data structure with a critical part that decides which actions a given process is allowed to perform:
1: kd> dt -r nt!_TOKEN nt!_TOKEN +0x000 TokenSource : _TOKEN_SOURCE +0x000 SourceName :  Char +0x008 SourceIdentifier : _LUID +0x000 LowPart : Uint4B +0x004 HighPart : Int4B ... snip ... +0x040 Privileges : _SEP_TOKEN_PRIVILEGES +0x000 Present : Uint8B // Privileges to consider +0x008 Enabled : Uint8B // Privileges that are granted +0x010 EnabledByDefault : Uint8B // Privileges granted to child processes ... snip ...
This so called
_SEP_TOKEN_PRIVILEGES structure which resides at offset
(on 64-bit Windows) of the structure is nothing more than a bitfield where
means that a privilege is granted, and
0 means it isn’t. In other words, if
one is able to write
0xffffffffffffffff in all three fields of the structure,
the current process will have all permissions granted, and any child process
will inherit those permisisons.
Great, let’s do it!… Wait… where is this token in memory?
After spending a long time trying to find a read primitive in order to bypass
kASLR, I finally had a bit of a revelation thinking back on the training
material: It’s possible to use userland APIs to leak kernel addresses. One such
NtQuerySystemInformation. Some of the system information
classes are not very well documented, but after fiddling around for a while I
managed to get what I wanted: A way to figure out exactly where the token is
located. Explaining it in text would be too long-winded so instead, here is the
python snippet to leak the current process’ token address in kernel land:
Without getting into the nitty gritty, this call leaks the address of every kernel object associated to a handle and looks through the results in order to identify the access token handle that belongs to the current process. Once it is found, the code extracts the kernel-land address of the token.
Okay, after all of this effort and trouble, we still have one major hurdle: The
write that we have allows us to write anywhere, but does not allow us to control
the value of the write. This part takes a bit of additional reversing to
identify a code path in the driver that will return a value as close as possible
First, it’s important to understand the structure of the buffer being passed into the IOCTL:
+00 4B opcode [0..9] +04 4B padding +08 8B pointer to arguments buffer +10 8B pointer to result buffer
opcode determines which handler is called in
call_handler as shown
above. The padding is unused and can be ignored. Next, the
contains a pointer to a buffer which will be read by the driver to retrieve the
handler’s arguments. Lastly, the
result field contains a pointer to the memory
location where the driver will write its result.
There are 10 handlers in total. The idea is to find one that returns a
satisfying value into the
result buffer. Digging into each opcode handler that
gets assigned to
g_call_table, two of them stand out:
decided to go with
0x8 because the decompiled output is shorter:
So all we need to do is send an
arguments buffer that has a NULL value for
the first parameter and the driver should write
0xfffffffe to the location of
our choice. This can be triggered multiple times to enable almost all privileges
in the current process’ token.
To summarize the attack, the following steps are taken:
- Open the token in read-only within userland to get a handle on it.
- Get the current process ID
NtQuerySystemInformationto leak kernel addresses of all objects with a handle.
- Find the information entry for the token handle in the current process and get the kernel address. This bypasses kASLR.
- Build an IOCTL request for the vulnerable driver that will return
0xfffffffeand set the output buffer address to point to the token privileges.
- Repeat the previous step with all token privilege fields.
- Spawn a child process which will inherit the token permissions.
The nice thing about this exploit is that it does not require any code execution, and therefore the only mitigation that needs to be bypassed is kASLR, which is trivial at medium integrity-level (but a completely different beast at low IL.)
Well, this was a long winded article. If you’ve stuck this far, hopefully the write-up was clear enough that it was possible to follow along. This exploit required a bit of creativity with the restricted ability to control what was being written. I must admit that I initially spent a lot of time trying to get a true arbitrary write-what-where primitive and an arbitrary read.
The current exploit is limited in that it relies on medium integrity level calls in order to elevate privileges. Despite that, it was a great introduction to real-life kernel exploiting and reversing.