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.
1. Vulnerability
Loading the vulnerable driver in IDA presents the following DriverEntry
function.
The 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 rdi
(the DRIVER_OBJECT
) in the graph below. Thus, sub_11170
in rax
is the IRP_MJ_DEVICE_CONTROL
callback.
As expected, 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 memmove
.
One of these sub functions is sub_15294
, which is called by two IOCTLs (0x9B0C1EC4
and 0x9B0C1EC8
). The only difference between the IOCTLs is that, when calling 0x9B0C1EC4
, the dl
register is set to 1
. sub_15294
receives 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).
In sub_15294
, 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 memmove
.
Within sub_15294
, r9
is being set to irp->AssociatedIrp->SystemBuffer
. Then two flows are possible. Within the writing flow (IOCTL 0x9B0C1EC8
), before calling memmove
:
rcx/destination
points tor9+0x18
(the address to write to).rdx
points to the sum ofrbx+0x8
andrbx+0x10
(the address to write from).r8
is set toirp->CurrentStackLocation->InputBufferLength
minus24
.
Within the reading flow (IOCTL 0x9B0C1EC4
), before calling memmove
:
rcx/destination
points to the sum ofrbx+0x8
andrbx+0x10
(the address to read to).rdx/source
points tor9+0x18
(the address to read from).r8
is set toirp->CurrentStackLocation->InputBufferLength
minus24
(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.
2. Exploit
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 ULONG
) and TickCountMultiplier
also a ULONG
.
3. Foundation
To create a foundation for kernel exploitation via Cobalt Strike, we convert the exploit to a Beacon Object File that utilises the memoryRead
and 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.
3.1 Makefile
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.
3.2 Entrypoint
Usually the 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 main
and go
, we’ll call the same boot
function.
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
. When kernel_mii
is called, it will import the compiled Beacon Object File, and run the go
function.
3.4 Headers
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.
3.5 Console
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 printf
and puts
. The code below defines PRINT
and 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 winnt.h
as 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).
For 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 GetLastError()
to 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.
- Find
PsInitialSystemProcess
offset inntoskrnl.exe
. - Take note of
PsInitialSystemProcess's
token. - Iterate the
_EPROCESS
chain until our own process is found. - Overwrite our token with
PsInitialSystemProcess's
token.
Find PsInitialSystemProcess
offset in ntoskrnl.exe
The first step can be achieved by retrieving the variable PsInitialSystemProcess
from ntoskrnl.exe
using GetProcAddress(...)
.
Take note of PsInitialSystemProcess's
token.
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.
Iterate the _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 PsInitialSystemProcess's
token
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.
4. Usage
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.
5. Result
The results of this research are available on GitHub. This includes the compiled Beacon Object File and PE-file.