Eval and Exec Dangers — Deep Dive

How eval() and exec() Work Internally

Both functions ultimately call compile() followed by the interpreter’s eval loop. eval() compiles the string in 'eval' mode (single expression), while exec() uses 'exec' mode (arbitrary statements). The compiled code object is then executed in the provided namespace.

# These are equivalent:
result = eval("2 + 3")

code = compile("2 + 3", "<string>", "eval")
result = eval(code)  # eval also accepts code objects

The key insight: the code runs with the full power of the interpreter. There is no reduced execution mode, no instruction filtering, and no capability system. Whatever the calling process can do, the evaluated code can do.

Anatomy of a Sandbox Escape

The most common “safe eval” attempt restricts __builtins__:

eval(user_input, {"__builtins__": {}}, {})

Here is how an attacker escapes this:

Step 1: Reach the object hierarchy

# Get <class 'object'> from any literal
().__class__.__bases__[0]
# or
"".__class__.__mro__[1]

Step 2: Enumerate all subclasses

().__class__.__bases__[0].__subclasses__()
# Returns a list of ALL loaded Python classes

Step 3: Find a useful class

# Look for os._wrap_close or similar classes that import os
[c for c in ().__class__.__bases__[0].__subclasses__()
 if c.__name__ == '_wrap_close'][0]

Step 4: Access the module’s globals

[c for c in ().__class__.__bases__[0].__subclasses__()
 if c.__name__ == '_wrap_close'][0].__init__.__globals__['system']('whoami')

This chain works because Python’s object model is deeply interconnected. Every object knows its class, every class knows its bases and subclasses, and methods carry references to their defining module’s global namespace. Cutting __builtins__ only removes the front door — the windows are wide open.

Real-World Exploits

Pickle deserialization: pickle.loads() internally uses exec-equivalent functionality. Malicious pickle payloads are a common attack vector in web applications that deserialize user data.

Template injection: Web frameworks like Jinja2, Mako, and Django templates can be exploited when user input is rendered as template code. Server-Side Template Injection (SSTI) often leads to eval()-equivalent execution.

YAML deserialization: PyYAML’s yaml.load() (without Loader=SafeLoader) can execute arbitrary Python code embedded in YAML tags. This has led to real CVEs in projects like Ansible and SaltStack.

Jupyter notebooks: .ipynb files contain executable code. Opening an untrusted notebook and running cells is equivalent to running exec() on attacker-controlled code.

The Scope of eval() and exec()

Both functions accept globals and locals dictionaries:

eval(expression, globals_dict, locals_dict)
exec(statements, globals_dict, locals_dict)

If omitted, they default to the caller’s actual globals and locals. This means:

import os
secret_key = "s3cr3t"

# This eval can access EVERYTHING in the current scope
eval(user_input)  # can read secret_key, call os.system(), etc.

Even when you pass restricted dictionaries, the evaluated code can reconstruct access through the object hierarchy as shown above. The only way to truly isolate eval() is to run it in a separate process with restricted permissions (see alternatives below).

Production-Grade Alternatives

ast.literal_eval() — For Data Parsing

Safe for evaluating strings containing only Python literals:

import ast

# Safe — only evaluates literals
data = ast.literal_eval("{'users': ['alice', 'bob'], 'count': 2}")

# Raises ValueError — function calls not allowed
ast.literal_eval("__import__('os').system('pwd')")

Limitations: cannot handle expressions like 1 + 2 or variable references. Purely for literal data structures.

Expression Parsers — For User-Defined Formulas

When users need to enter mathematical or logical expressions, use a proper parser:

# Using asteval — safe mathematical expression evaluator
from asteval import Interpreter
aeval = Interpreter()
result = aeval("sin(x) + cos(y)", x=1.0, y=2.0)

# Using simpleeval — minimal safe evaluator
from simpleeval import simple_eval
result = simple_eval("x * 2 + y", names={"x": 10, "y": 5})

These libraries parse expressions into their own AST and evaluate only whitelisted operations. They do not support imports, attribute access on arbitrary objects, or statement execution.

RestrictedPython — For Controlled Code Execution

RestrictedPython compiles Python source with a restricted compiler that blocks dangerous operations:

from RestrictedPython import compile_restricted, safe_globals

code = compile_restricted("result = sum([1, 2, 3])", "<user>", "exec")
glb = safe_globals.copy()
glb['_getiter_'] = iter  # allow iteration
exec(code, glb)
print(glb['result'])  # 6

It instruments attribute access, item access, and iteration with guard functions that you control. Not foolproof, but significantly more secure than raw eval().

Subprocess Sandboxing — For Maximum Isolation

For untrusted code that must actually execute, run it in an isolated subprocess:

import subprocess, json

result = subprocess.run(
    ["python3", "-c", untrusted_code],
    capture_output=True, text=True, timeout=5,
    # Restrict capabilities:
    # - No network (via seccomp/AppArmor)
    # - Limited filesystem (via chroot/container)
    # - Resource limits (via ulimit)
)

Docker containers, gVisor, nsjail, and Firecracker micro-VMs provide progressively stronger isolation. This is how services like Replit, Google Colab, and coding challenge platforms safely execute user code.

WebAssembly Sandboxing

Emerging tools like Pyodide (Python compiled to WebAssembly) provide sandboxed execution with no filesystem or network access by default:

# Server-side WASM execution for untrusted Python
import pyodide_runner  # hypothetical

result = pyodide_runner.eval_sandboxed(user_code, timeout=5)

Detection and Prevention

Static Analysis

Tools like Bandit scan your codebase for eval() and exec() usage:

bandit -r myproject/ -t B307  # B307 = eval() usage

Semgrep can enforce rules against dynamic code execution:

rules:
  - id: no-eval
    patterns:
      - pattern: eval(...)
    message: "eval() is a security risk. Use ast.literal_eval() or a parser."
    severity: ERROR

Code Review Checklist

When reviewing code that uses eval() or exec():

  1. Where does the input string come from? If any path leads to user input, reject.
  2. Is there an alternative that does not require code execution? Almost always yes.
  3. If truly necessary, is it running in an isolated environment with restricted permissions?
  4. Are there tests specifically for injection attacks?

The compile() Built-in: Not a Defense

Some developers think using compile() is safer because it separates compilation from execution:

code = compile(user_input, "<string>", "eval")
# Still dangerous — code object can do anything when executed
result = eval(code)

The compile() step does parse the code and would catch syntax errors, but it does not restrict what the code can do. A syntactically valid malicious payload compiles just fine.

exec() in Class and Module Patterns

Python itself uses exec() in a few places: namedtuple constructs class code as a string and executes it, importlib executes module code objects, and type() with three arguments uses exec-like machinery internally. These internal uses are safe because the code being executed is generated by trusted Python internals, not external input.

If you find yourself generating code strings programmatically, ask whether type(), dataclasses, attrs, or AST construction could achieve the same result without string-based code generation.

One thing to remember: eval() and exec() provide unrestricted code execution, and every attempt to sandbox them within the same Python process has been defeated. For untrusted input, use purpose-built tools: ast.literal_eval() for data, expression parsers for formulas, RestrictedPython for controlled execution, and process-level sandboxing for true isolation. Treat any code path from user input to eval() as a critical vulnerability.

pythonsecuritylanguage-implementation

See Also

  • Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
  • Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.
  • Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
  • Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.
  • Python 312 New Features Python 3.12 made type hints shorter, f-strings more powerful, and started preparing Python's engine for a world without the GIL.