A packet capture driver ships with PktTrack network tools. An internal audit flagged a race condition in the capture commit path. Management said the window is “too small to exploit in practice.” Prove them wrong.
Goal:
1. Reverse WinCapture.sys to understand the IOCTL interface.
2. Find and exploit the race condition in IOCTL_COMMIT_CAPTURE.
3. Write exploit.exe (x64 Windows PE) that wins the race, corrupts the
key object, and retrieves the flag.
4. Send exploit.exe here -- the server runs it against a live WinCapture
driver instance and returns the output.
Driver interface: \\.\pipe\WinCapture
We get WinCapture.sys. The exploit reaches the driver over a named pipe; a harness bridges pipe messages to the IOCTL dispatch and shares one driver instance across connections, which is what makes the race reachable at all.
Two things fall out. The flag in the binary is a placeholder built on the stack — the trailing H bytes are REX.W prefixes of the movabs instructions loading it as immediates — so the real flag comes from the server after a successful exploit. And the driver was built with MinGW GCC 13 with an empty .idata: it never calls IoCreateDevice or ExAllocatePool, managing everything inside a large static .bss. That makes it a self-contained module the harness can load and call directly.
Source Code Analysis
WinCaptureDeviceControl is a standard DeviceIoControl handler over an IRP-like request object, dispatching through a jump table:
1
movedi, [rdx+0x80] ; ioctl
2
leaedx, [rdi+0x3dffe000] ; ioctl - 0xC2002000
3
cmpedx, 0x10
4
ja .invalid ; STATUS_INVALID_DEVICE_REQUEST
5
learcx, [rip+0xfbf] ; jump table @ 0x12000
6
movsxdrdx, [rcx+rdx*4]
7
addrdx, rcx
8
jmprdx
The index is ioctl - 0xC2002000 and only multiples of four hit real handlers:
IOCTL
Handler
0xC2002000
STORE — write a capture slot
0xC2002004
ALLOC_VALUE — allocate value object B
0xC2002008
ALLOC_KEY — allocate KEY object A
0xC200200C
COMMIT_CAPTURE — copy slot into value
0xC2002010
GET_FLAG — grant access
The .bss layout puts the two objects in a bump-allocated region:
Single-threaded, nothing here is exploitable. value_size is at most 0x200, the check forces slot_size no larger than value_size, and the copy length is bounded by the same value. Because the bump allocator 16-byte-aligns every chunk, the KEY object A always sits at B + align16(value_size) — exactly at or past the end of B’s chunk — so a copy of at most value_size bytes fills B and stops just short of A. The alignment is what guards field_4.
The double fetch breaks that guarantee, because the value that was validated and the value that is used are two different reads:
Thread T reaches the check at T1 while slot_size is 0x10, no larger than value_size of 0x10, so it passes.
Another thread flips slot_size to 0x1000 with a STORE.
Thread T reaches T2, re-reads 0x1000, and copies 0x1000 bytes into the 0x10-byte buffer.
That write starts at B and runs straight through A, overwriting the magic along with field_4. So the source data has to rebuild a valid key rather than just corrupt one — and the same alignment that used to be the guard now works in our favour. A is 16-aligned relative to B, so filling the whole slot with a 16-byte-periodic pattern means every possible landing offset reconstructs a valid unlocked key:
01 59 45 4b 41 41 41 41 00 00 00 00 00 00 00 00
└ magic ┘ └ field_4 ┘
0x4b455901 != 0
Recovering the pipe wire format
The pipe protocol belongs to the harness, which we do not have — but the server runs our binary and returns its stdout, which makes it a usable oracle. A probe that tries several framings on fresh connections settles it quickly.
The response framing was obvious from the first reply (0d 00 00 c0 | 00 00 00 00, i.e. status 0xC000000D with zero info). For the request:
Requests are [u32 ioctl][u32 in_len][in_len bytes] with no output-length field, and responses are [u32 status][u32 info][info bytes]. GET_FLAG returning 0xC0000022 — the field_4 denial — rather than 0xC0000023 for buffer-too-small also confirms the harness passes a default output length of at least 0x80, so the only remaining obstacle really is field_4.
Exploitation
Allocate the value then the key so A lands immediately after B, store the periodic payload, then run flipper threads toggling slot_size against committer threads spamming the commit path, polling GET_FLAG until it grants:
The audit note in the banner was right that the window is only a few cycles wide — and irrelevant, because nothing stops us from retrying it tens of thousands of times a second.