bitdebit2

One bit flip into IO_list_all, a seccomp filter that never checks the arch, and a flag read out through connection-drop timing.

2026.09.19 DefCamp CTF 2026 Quals Pwn
FLAG CTF{d85bcbd101b11415ebd95ef88841f6c24893bb287c6179b739c94b8d3f00e403}

main gives you one arbitrary bit flip and a buffer you control at an address it prints. The read can’t leave the buffer (offset < size), so the flip is the only way out.

The leak and the fake FILE

Ask for 0x10000000 and malloc mmaps it right under libc, every time, so the %p is a libc leak: libc = leak + 0x10003ff0.

IO_list_all holds &_IO_2_1_stderr, and clearing one bit in 22..28 of it lands inside my 256 MB chunk. main returns, exit() hits _IO_flush_all, and it walks the list into my fake FILE.

vtable _IO_wfile_jumps to pass the check, write_base and buf_base NULL so _IO_wfile_overflow falls into _IO_wdoallocbuf, which ends in:

mov rax,[rax+0xe0] ; call [rax+0x68]     with rdi = FILE

So rax is my wide vtable and rdi is my FILE, and libc has

push rax ; pop rsp ; ... ; mov rax,[rdi+8] ; jmp [rax+0x18]     @ 0x16bde0

which is exactly that pair. rsp lands on my data, jmp goes to pop rsp ; ret, ROP.

The seccomp filter never checks the arch

The seccomp is 9 instructions and never loads offset 4, so it never checks the architecture. It allows 9 and 295. On x86_64 that’s mmap and preadv; through int 0x80 the same numbers are link and openat. So 295 is both halves of what I need.

No pop rdx in libc, but setcontext+0x20 is one, and pop rdx ; call [rax+0x20] at 0x176cbd works with a fake vtable whose +0x20 is pop rax ; ret. Chain is mmap RWX at 0x20000 (low, so a 32-bit ecx can reach it), memmove the stub, jump:

mov eax,295 ; mov ebx,0xffffff9c ; mov ecx,0x20080 ; xor edx,edx ; int 0x80
mov edi,eax ; mov esi,0x20090 ; mov eax,295 ; mov edx,1 ; xor r10d,r10d ; xor r8d,r8d ; syscall

Reading the flag through timing

The flag is in memory, and nothing you’re allowed to call can put a byte on the socket. The only observable left is when the connection drops. So the stub compares one byte to a threshold and either dies now or spins 1.5e9 times first: 0.17 s vs 0.65 s, no overlap, no need to know the remote CPU speed. Binary search, 4 connections per hex char, ~7 min.

I did a linear timing encode first (delay proportional to the nibble). Perfect locally, total garbage through the ssh tunnel — got CTF{tGDSTd1@BrA15A5TRsG4U out of it before giving up.

What actually cost me the most

stdin is _IONBF, so the newline scanf pushed back is still in the FILE buffer and getc eats that, not a socket byte. Sending payload + "\n" shifts both fgets, strtoull gets "\n" → 0, the bit index gets my address string which is > 7, and that jumps past the flip and both prctls to a clean exit. No crash, no seccomp, client measures 5 ms and you assume your ROP is broken.

Repro

ssh -N -i key -p 30393 -L 1338:localhost:1337 tunnel@<ip>
python3 oracle.py 127.0.0.1 1338

exploit.py does one connection, oracle.py drives the bisection. Both attached.

#heap#file-structure#seccomp#timing-oracle#rop