Python Sandbox Escape Prevention — Deep Dive

Anatomy of a Sandbox Escape

The Classic Subclass Walk

The most well-known escape technique exploits Python’s object model. Every object inherits from object, and object.__subclasses__() returns all loaded subclasses — including classes from modules that provide system access.

# Starting from a string literal with no imports available:
# Step 1: Get the 'object' base class
base = "".__class__.__bases__[0]  # <class 'object'>

# Step 2: Enumerate all subclasses
subclasses = base.__subclasses__()
# This returns hundreds of classes, including:
# <class '_io._IOBase'>, <class 'os._wrap_close'>, etc.

# Step 3: Find a useful class
# os._wrap_close has a __globals__ dict containing 'os' module
for i, cls in enumerate(subclasses):
    if cls.__name__ == '_wrap_close':
        # cls.__init__.__globals__['system']('id')
        pass  # In a real escape, this executes commands

This technique works because CPython maintains a global registry of all instantiated classes. Even in a “restricted” environment with no imports, the interpreter has already loaded dozens of modules during startup.

Escape via __globals__

# Any function from the standard library carries its module's namespace
# If you can access any function, you can reach its module's imports

# Example: the 'format' builtin leads to string module
# string module may reference os, sys, etc.
# f.__globals__ contains everything the module imported

# Defense: RestrictedPython guards __globals__ access
# Attack: Computed attribute names bypass static guards
getattr(func, '__' + 'globals' + '__')  # String concatenation

Escape via code Objects

# Python functions have replaceable __code__ attributes
def innocent():
    return 42

# An attacker can construct a code object that does something else
import types
malicious_code = compile("__import__('os').system('whoami')", 
                         "<x>", "eval")
# In restricted environments, compile() might be blocked
# But code objects can be constructed from raw bytecode

Escape via ctypes

If ctypes is available (it’s a standard library module), the game is over:

import ctypes
# ctypes.CDLL provides direct access to C shared libraries
# This means arbitrary system calls, memory manipulation, etc.
libc = ctypes.CDLL("libc.so.6")
libc.system(b"whoami")

Escape via gc Module

import gc
# gc.get_objects() returns every Python object in memory
# This includes module objects, function objects, etc.
for obj in gc.get_objects():
    if hasattr(obj, '__name__') and obj.__name__ == 'os':
        # Found the os module without importing it
        obj.system('whoami')
        break

Why Pure-Python Sandboxing Fails: A Proof

Python’s execution model has three properties that make sandboxing theoretically impossible:

  1. Universal introspection: Every object can inspect every other reachable object’s attributes, including internal implementation details.

  2. Mutable runtime: Classes, functions, and modules can be modified at runtime. Guards can be removed, functions can be replaced, and new code can be injected.

  3. Reachability through object graph: From any object, there exists a finite traversal path to any module loaded in the interpreter. Since CPython loads os, sys, and io during startup, these are always reachable.

No amount of Python-level filtering can close all paths simultaneously because new paths are discovered through creative combinations of these three properties.

Production-Grade Isolation Architectures

Architecture 1: Docker + seccomp

# Dockerfile for code execution sandbox
FROM python:3.12-slim

RUN useradd -m -s /bin/false sandbox
RUN pip install --no-cache-dir numpy pandas  # Allowed libraries

COPY seccomp-profile.json /etc/seccomp-profile.json
COPY executor.py /app/executor.py

USER sandbox
WORKDIR /tmp
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "read", "write", "close", "fstat", "lseek",
        "mmap", "mprotect", "munmap", "brk",
        "rt_sigaction", "rt_sigprocmask",
        "access", "getpid", "clone", "execve",
        "wait4", "exit_group", "arch_prctl",
        "futex", "set_tid_address", "set_robust_list",
        "openat", "newfstatat", "getrandom"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
# executor.py — runs inside the container
import sys
import json
import resource
import signal

def set_limits():
    """Enforce resource limits."""
    # CPU time: 10 seconds
    resource.setrlimit(resource.RLIMIT_CPU, (10, 10))
    # Memory: 256 MB
    resource.setrlimit(resource.RLIMIT_AS, 
                       (256 * 1024 * 1024, 256 * 1024 * 1024))
    # No new files
    resource.setrlimit(resource.RLIMIT_NOFILE, (32, 32))
    # No child processes
    resource.setrlimit(resource.RLIMIT_NPROC, (0, 0))

def execute_code(code: str, timeout: int = 5) -> dict:
    """Execute untrusted code with strict limits."""
    set_limits()
    
    # Timeout via alarm signal
    signal.alarm(timeout)
    
    output_capture = {"stdout": "", "stderr": "", "error": None}
    
    try:
        from io import StringIO
        import contextlib
        
        stdout = StringIO()
        stderr = StringIO()
        
        with contextlib.redirect_stdout(stdout), \
             contextlib.redirect_stderr(stderr):
            exec(code, {"__builtins__": __builtins__}, {})
        
        output_capture["stdout"] = stdout.getvalue()[:10000]
        output_capture["stderr"] = stderr.getvalue()[:10000]
    except Exception as e:
        output_capture["error"] = f"{type(e).__name__}: {str(e)[:1000]}"
    
    return output_capture

if __name__ == "__main__":
    code = sys.stdin.read()
    result = execute_code(code)
    print(json.dumps(result))

Architecture 2: nsjail (Lightweight Namespace Jail)

# nsjail configuration for Python execution
# Provides Linux namespace isolation without full container overhead

nsjail \
  --mode once \
  --time_limit 10 \
  --max_cpus 1 \
  --rlimit_as 256 \
  --rlimit_cpu 10 \
  --rlimit_fsize 1 \
  --rlimit_nofile 32 \
  --disable_clone_newnet \
  --cwd /tmp/sandbox \
  --chroot / \
  --user nobody \
  --group nogroup \
  --seccomp_policy_file /etc/nsjail/python.policy \
  -- /usr/bin/python3 /app/executor.py

nsjail is what Google uses internally for sandboxing untrusted code. It’s lighter than Docker (no daemon, no image layers) and provides finer-grained control over namespaces.

Architecture 3: Firecracker microVM

For the strongest isolation, each execution runs in a dedicated microVM:

# Orchestrator: launches Firecracker microVMs for code execution
import subprocess
import json
import tempfile

class MicroVMExecutor:
    """Execute code in Firecracker microVMs."""
    
    def __init__(self, kernel_path: str, rootfs_path: str):
        self.kernel = kernel_path
        self.rootfs = rootfs_path
    
    def execute(self, code: str, timeout: int = 10) -> dict:
        # Write code to a temporary file that will be mounted
        with tempfile.NamedTemporaryFile(mode='w', suffix='.py',
                                          delete=False) as f:
            f.write(code)
            code_path = f.name
        
        # Firecracker VM configuration
        config = {
            "boot-source": {
                "kernel_image_path": self.kernel,
                "boot_args": "console=ttyS0 reboot=k panic=1"
            },
            "drives": [{
                "drive_id": "rootfs",
                "path_on_host": self.rootfs,
                "is_root_device": True,
                "is_read_only": True,
            }],
            "machine-config": {
                "vcpu_count": 1,
                "mem_size_mib": 128,
            },
            "network-interfaces": []  # No network
        }
        
        # Launch and collect results
        # (Simplified — production requires API socket management)
        return {"status": "executed", "vm": "firecracker"}

AWS Lambda uses Firecracker to achieve millisecond startup times with full VM isolation. Each invocation gets its own kernel, preventing any cross-tenant information leakage.

RestrictedPython: Capabilities and Limits

from RestrictedPython import compile_restricted
from RestrictedPython import safe_globals

# RestrictedPython compiles code with instrumented bytecode
code = """
result = sum([x * 2 for x in range(10)])
"""

byte_code = compile_restricted(code, '<inline>', 'exec')
glb = safe_globals.copy()
glb['_getiter_'] = iter  # Allow iteration
glb['_getattr_'] = getattr  # Guard attribute access (simplified)

exec(byte_code, glb)
print(glb.get('result'))  # 90

RestrictedPython is appropriate for:

  • Template rendering engines
  • Business rule evaluation
  • Calculated fields in spreadsheet-like applications
  • Simple scripting DSLs

It is not appropriate for:

  • Running arbitrary user code
  • Plugin systems with untrusted plugins
  • Online judge submissions
  • Any scenario where a motivated attacker controls the input

Defense-in-Depth Checklist

For running untrusted Python code in production:

  1. Isolation layer: Container, VM, or namespace jail — never same-process
  2. Resource limits: CPU time, memory, disk, file descriptors, network
  3. Network isolation: No network access unless explicitly needed (and then only allowlisted destinations)
  4. Filesystem: Read-only rootfs, ephemeral writable temp directory, no access to host filesystem
  5. User: Run as unprivileged user with no sudo capability
  6. Seccomp: Allowlist of system calls — block ptrace, mount, reboot, etc.
  7. Timeout: Hard kill after time limit to prevent infinite loops and resource exhaustion
  8. Output limits: Truncate stdout/stderr to prevent memory exhaustion via output flooding
  9. Disposable: Destroy the execution environment after each run — never reuse
  10. Monitoring: Log all executions, alert on anomalies (high CPU, network attempts, privilege escalation)

The Python Audit Hook System (3.8+)

Python 3.8 introduced sys.addaudithook() — not a sandbox, but a monitoring layer:

import sys

def audit_hook(event: str, args: tuple):
    """Log dangerous operations without blocking them."""
    dangerous_events = {
        "import", "open", "exec", "compile",
        "subprocess.Popen", "os.system", "socket.connect",
    }
    if event in dangerous_events:
        print(f"AUDIT: {event} {args}", file=sys.stderr)
        # In production: send to security monitoring

sys.addaudithook(audit_hook)

Audit hooks can’t be removed once installed (by design), making them useful for monitoring. However, they’re advisory, not enforcement — a determined attacker with access to ctypes can bypass them by calling C functions directly.

The one thing to remember: Python’s dynamic nature makes in-language sandboxing a losing battle — production safety requires external enforcement through OS isolation, and the strongest architectures combine multiple layers (containers + seccomp + resource limits + monitoring) to create defense-in-depth.

pythonsecurityruntime

See Also