A foundation for kernel exploitation via Cobalt Strike
Posted on by Tijme Gommers.
[TL;DR] Dell’s bios kernel driver is vulnerable for an arbitrary kernel memory read/write. This blog describes how to utilise it in a Cobalt Strike (CS) Beacon Object File (BOF) to perform kernel exploitation. As an example, we escalate privileges to
NT AUTHORITY\SYSTEM. The result has been published on GitHub.
This blog post consists of 5 chapters. Identifying the vulnerability, developing an exploit to read and write kernel memory, converting everything to a Cobalt Strike (CS) Beacon Object File (BOF) foundation that performs privilege escalation, describing its usage, and a demo of the end result.
Loading the vulnerable driver in IDA presents the following
jmp at the bottom references the actual driver entry written by the developer. That function receives
DRIVER_OBJECT* driver and
IRP* irp as arguments. In that function the
IRP_MJ_DEVICE_CONTROL callback in
driver->MajorFunction is set.
MajorFunction is at offset
0x70 on the
DRIVER_OBJECT. The offset of the
IRP_MJ_DEVICE_CONTROL callback in
MajorFunction is at offset 0x70 as well. The sum is
0xE0, which we can see being set on
DRIVER_OBJECT) in the graph below. Thus,
rax is the
sub_11170 contains a large switch statement with various IO control codes (IOCTLs). I use an IDA Python plugin called Call Tree Overviewer which yields an overview of the call tree, to easily identify what certain switch cases do. It turns out some of the IOCTLs call
memmove, and some call sub functions with
One of these sub functions is
sub_15294, which is called by two IOCTLs (
0x9B0C1EC8). The only difference between the IOCTLs is that, when calling
dl register is set to
rcx as argument.
rcx is set to
rdi, one of the first variables being assembled to a struct, in the
IRP_MJ_DEVICE_CONTROL callback. It contains two 64-bit integers. The first one being set to
irp->AssociatedIrp->SystemBuffer (a user controlled buffer), and the second one to
irp->CurrentStackLocation->InputBufferLength (the length of the user controlled buffer).
memmove is being called with arguments based on the first pointer in
rcx (which is the user input, and it seems to be a struct). The function definition of
memmove is included below. Based on the
dl register (which was previously set based on the IOCTL being called), certain properties of the struct are either used for destination or source, implying that
sub_15294 supports both reading and writing kernel memory based on user input. If this happens without proper input validation, the kernel driver is vulnerable to a write-what-where vulnerability.
In the Windows 64-bit calling convention, the following registers would be used to call
r9 is being set to
irp->AssociatedIrp->SystemBuffer. Then two flows are possible. Within the writing flow (IOCTL
0x9B0C1EC8), before calling
r9+0x18(the address to write to).
rdxpoints to the sum of
rbx+0x10(the address to write from).
r8is set to
Within the reading flow (IOCTL
0x9B0C1EC4), before calling
rcx/destinationpoints to the sum of
rbx+0x10(the address to read to).
r9+0x18(the address to read from).
r8is set to
24(number of bytes to move).
Concluding, this allows us to construct the following struct:
Which will call
memmove in the following ways:
As there is no proper input validation whatsoever, the kernel driver is indeed vulnerable to a write-what-where vulnerability.
To communicate with the driver, we first need to open a handle to it. The pseudocode below shows how to open a handle to the symbolic link of the driver device. Using the software DeviceTree (from OSR), the ACLs on the symbolic link can be inspected. This shows that
Everyone has permissions to open handle to the symbolic link. Thus, the code below to create a handle to the symbolic link can even be executed by low-privileged users on the system. This gives threat actors the opportunity for privilege escalation.
Afterwards, this handle can be used to call the two vulnerable IOCTLs. I’ve created two functions for this. The first one is for reading kernel memory, by passing the device handle and a kernel address to it.
The second one is for writing kernel memory, by passing the device handle, a kernel address and a 64-bit value to write.
The code below is proof-of-concept code on how to use these functions. We read from and write to the address
0xfffff78000000000, which holds the
KUSER_SHARED_DATA struct on 64-bit Windows machines. The first 64-bits of this struct hold
TickCountLowDeprecated (a deprecated
TickCountMultiplier also a
To create a foundation for kernel exploitation via Cobalt Strike, we convert the exploit to a Beacon Object File that utilises the
memoryWrite functions. The implementations of these functions may change over time (for example if we use a different vulnerable driver). But if their function definitions stay the same, we can essentialy develop exploits as Beacon Object Files that do not depend on a specific vulnerable driver, but rather the two function definitions.
The first thing we’ll do is create a
makefile to compile our code to both a PE-file and a BOF subsequently.
In the above
makefile, we’ve defined our x64 and x86 compiler in a variable. Additionally, we define the name of our project in
BOFNAME. When running
make, all commands in the first action will be executed, in this case everything under
all. The first two commands are the compilation to a BOF. As can be seen, the variable
BOF is defined to be used for the macro preprocessor. The second two are for the compilation to a PE-file.
main function is exported and called by a PE-loader on, for example, double click by a user. For a BOF, we can define which function is called on run ourselves. The Cobalt Strike documentation always uses the
go function, so we’ll go ahead and use that as well. In both
go, we’ll call the same
3.3 Aggressor script
Cobalt Strike uses aggressor scripts (
.cna files) to import functionality, such as Beacon Object Files, into Cobalt Strike. The aggressor script below tells Cobalt Strike to provide the hacker with a function
kernel_mii is called, it will import the compiled Beacon Object File, and run the
To be able to utilise Cobalt Strike functionality (like printing to the Cobalt Strike console), we need to include a Beacon Object header.
The required contents for the header file can be downloaded here. It contains all required function definitions to utilise Cobalt Strike functionality.
As stated, printing to the Cobalt Strike console works differently than printing to a Windows console. We need to define cross-compatible print methods and use them instead of functions like
puts. The code below defines
PRINT_ERROR, which will use
fprintf (Windows console) or
BeaconPrintf depending on whether
BOF is defined.
We can now use these functions as follows.
3.6 Windows API
The print statement above is a good start, but it won’t work in Cobalt Strike. This is because
GetLastError() is called, which is located in
Kernel32.dll. It cannot be resolved by the linker (Cobalt Strike).
Beacon Object Files require a
__declspec(dllimport) keyword, which is defined in
DECLSPEC_IMPORT. This indicates to the compiler that this function is found within a DLL, telling the compiler essentially “this function will be resolved later”. Since Cobalt Strike is the linker, this is needed to tell the compiler to let the linking come later. Since the linking will come later, this also means a full function prototype must be supplied to the Beacon Object File (source).
GetLastError(), an example is included below. First, we define the prototype definition. Using it, we can call
KERNEL32$GetLastError(). However, as we’re cross-compiling to a PE-file as well, it would be nice to just call
GetLastError(). To do this, we use the macro preprocessor to redirect
KERNEL32$GetLastError() for Beacon Object Files.
Here’s an example header file for various Windows API function definitions.
3.7 Privilege Escalation
The last thing we need to do is perform the actual privilege escalation on the current process. We’ll do this as follows.
- Take note of
- Iterate the
_EPROCESSchain until our own process is found.
- Overwrite our token with
PsInitialSystemProcess offset in
The first step can be achieved by retrieving the variable
Take note of
The second step can be achieved by obtaining the kernel base (for which there are various options), and then using the base and system process offset to find the system process, and then its token.
_EPROCESS chain until our own process is found
To find our own process, we find the active process linked list in the system’s
_EPROCESS. We iterate the list until the
_EPROCESS ID equals our own process ID.
Overwrite our token with
The last step is to adjust the code in the step above to overwrite the token if our
_EPROCESS is found. We overwrite the token of our own process, at offset
0x4b8, with the token of the system process.
If we compile our code and load it into Cobalt Strike via its script manager, we can use it to escalate privileges (if the vulnerable driver is installed and running). This can be done as seen in the command and corresponding output below.
The results of this research are available on GitHub. This includes the compiled Beacon Object File and PE-file.