bctf-infra
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.
$ ls tags/ techniques/
$ cat /etc/rate-limit
Rate limit reached (20 reads/hour per IP). Showing preview only — full content returns at the next hour roll-over.
bctf-infra — b01lersCTF 2026
Description
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.
Analysis
The three sandboxes
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}.
Server-side user separation
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:
...
$ grep --similar
Similar writeups
- [misc][free]build-a-builtin-revenge— b01lersc
- [misc][Pro]HashCashSlash— 0xl4ugh
- [misc][Pro]__pyjail__— kalmarctf
- [misc][Pro]Ergastulum— 0xl4ugh
- [misc][free]rustjail— b01lersc