PyFu

Escaping Python exec and eval Sandboxes

Python-based Vulnerabilities Anatomy

Sooner or later an application decides it needs to run “just a little” user-supplied Python, a formula field, a rules engine, a plugin expression, and the developer reaches for eval() or exec() with what they believe is a locked-down namespace. This page is about why those homemade sandboxes almost always fail, and the standard moves for breaking out of them. The short version: restricting a eval/exec call from inside the same interpreter is fighting the language’s own object model, and the object model wins.

The naive sandbox

The canonical attempt looks like this. The developer passes empty globals with no builtins, reasoning that without __import__, open, or eval in scope, the user can compute arithmetic but nothing dangerous:

def run(expr: str):
    # "Safe": no builtins, no globals
    return eval(expr, {"__builtins__": {}})

eval("1 + 1", {"__builtins__": {}}) returns 2. eval("open('/etc/passwd')", ...) raises NameError. It feels safe. It is not, because the sandbox only controls the names in scope; it does nothing about what the attacker can reach by traversing objects those names are not required for.

Breaking out: rebuild builtins from the object graph

Even with __builtins__ stripped, the attacker can still write literal objects, (), '', [], and every object exposes __class__. From there the Walking the Python Object Graph with subclasses() chain reconstructs everything that was taken away. The classic one-liner recovers the real __builtins__ through warnings.catch_warnings, which is always loaded:

# Runs even though the sandbox passed {"__builtins__": {}}
eval(
    "[c for c in ().__class__.__base__.__subclasses__() "
    "if c.__name__ == 'catch_warnings'][0]()._module."
    "__builtins__['__import__']('os').system('id')",
    {"__builtins__": {}},
)

Nothing in that expression uses a builtin from the sandbox namespace. It starts from the tuple literal (), climbs to object, enumerates loaded subclasses, grabs a class whose module namespace still has the genuine builtins, and calls __import__('os').system. The empty __builtins__ was never an obstacle.

If subprocess happens to be imported, the even shorter Popen-by-name gadget works directly:

eval(
    "[c for c in ().__class__.__base__.__subclasses__() "
    "if c.__name__ == 'Popen'][0]('id', shell=True, stdout=-1).communicate()",
    {"__builtins__": {}},
)

Blacklisting strings does not help either

The second common defense is filtering the input text, rejecting it if it contains import, os, eval, __, and so on. This loses to Python’s own string and attribute machinery, because names can be assembled at runtime rather than written literally:

# "os" never appears as a literal; getattr/chr rebuild the forbidden names
getattr(obj, '__cl' + 'ass__')
"__impo" "rt__"            # adjacent string literals concatenate
"".join([chr(111), chr(115)])   # -> "os"

Dunder access can be reached through getattr with a computed name, forbidden substrings can be split across concatenated literals, and characters can be produced with chr(). Any blacklist that operates on the raw expression text is bypassable by an attacker who simply does not spell the banned words.

What actually contains untrusted Python

The lesson from every failed in-interpreter sandbox is the same: confinement has to happen at a layer the evaluated code cannot reach.

  • Do not evaluate untrusted Python at all. Most “formula” features need a tiny expression grammar, parse it yourself, or use ast.literal_eval for pure data literals (and know that even literal_eval is for data, never for calls).
  • If you must run a restricted dialect, use a purpose-built sandbox, RestrictedPython compiles to a vetted subset and is meaningfully harder than a hand-rolled namespace, though it has had escapes and must be kept current.
  • For arbitrary code, isolate the process, not the namespace. Run it in a separate, unprivileged process under OS-level controls: seccomp-bpf to cut syscalls, a container with a read-only filesystem and no network, or a stronger boundary like gVisor or a microVM. The object graph cannot escape a syscall filter.

This is also why the Jinja2 sandbox and RestrictedPython are defense-in-depth rather than guarantees, and why the same __subclasses__() chain shows up in template injection, pickle gadgets, and PyJail challenges alike.

Tested on CPython 3.12.3. The takeaway: an eval/exec jail built from namespace tricks inside the interpreter is not a security boundary, it is an obfuscation layer, because the attacker rebuilds whatever you remove via the object model. Treat any feature that evaluates user-controlled Python as remote code execution and put a real, sub-language boundary around it. Runnable escape demos are in generic-py-fu/sandbox-escape/ in the lab.

Why sandbox escapes matter from an offensive security perspective

A homemade Python sandbox is one of my favorite findings, because it advertises both that the feature runs my code and that the developer believed they had stopped it. The restriction tells me exactly what to undo, and the object model gives me a single, reusable way to undo it. Once I confirm my expression reaches an in-interpreter eval/exec, the empty __builtins__ and the name blocklist are obfuscation, not containment; the __subclasses__() climb hands the genuine builtins back and I am at full RCE in the application’s own process. The escape is the same primitive whether the wrapper is a “formula field”, a Jinja2 sandbox, or a PyJail challenge, so one payload generalizes across targets.

The high-value tell is the sandbox itself: a stripped namespace or a string filter is a sign someone knew this was dangerous and tried to contain it in the wrong layer. I treat that as a marker pointing straight at a reachable code-execution sink, not as a defense to respect.

On an assessment these are what I look for:

  • A passed-in globals dict meant to restrict. eval(x, {"__builtins__": {}}), exec(x, safe_globals), and similar are escape candidates the moment I control x.
  • Input-text blocklists. Rejecting import, os, eval, or __ filters spelling, and I bypass it with getattr on a computed name, adjacent-literal concatenation, and chr().
  • RestrictedPython or a hand-rolled “safe eval”. Meaningfully harder than an empty namespace but still in-process, so I check the version against known escapes and probe the allowed subset.
  • A template engine sandbox reached by user input. Jinja2’s SandboxedEnvironment and friends are defense-in-depth, not a guarantee, and the same object-graph chain applies.
  • Any literal I can write. (), '', [], and {} each expose __class__, which is the only entry point the climb needs.

For defenders the takeaway is that an in-process restriction is not a boundary the evaluated code cannot cross, so the audit verdict on any namespace-based sandbox is “assume escapable” and the real control lives at the OS layer below the interpreter. The climb itself is detailed in Walking the Python Object Graph with subclasses().

Mitigation

The durable fix is to accept that an in-process Python sandbox is not a security boundary: stripping __builtins__, blocklisting attribute names, and wrapping eval in a restricted namespace all fall to the same object-graph climb, so none of them should be relied on to contain untrusted code. Run attacker-influenced code in a separate process under an operating-system sandbox such as seccomp, a hardened container, gVisor, or a dedicated VM, with no filesystem or network access and hard CPU and memory limits, and treat the absence of that isolation as the vulnerability rather than the cleverness of any single escape.