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:
-
Universal introspection: Every object can inspect every other reachable object’s attributes, including internal implementation details.
-
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.
-
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, andioduring 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:
- Isolation layer: Container, VM, or namespace jail — never same-process
- Resource limits: CPU time, memory, disk, file descriptors, network
- Network isolation: No network access unless explicitly needed (and then only allowlisted destinations)
- Filesystem: Read-only rootfs, ephemeral writable temp directory, no access to host filesystem
- User: Run as unprivileged user with no sudo capability
- Seccomp: Allowlist of system calls — block
ptrace,mount,reboot, etc. - Timeout: Hard kill after time limit to prevent infinite loops and resource exhaustion
- Output limits: Truncate stdout/stderr to prevent memory exhaustion via output flooding
- Disposable: Destroy the execution environment after each run — never reuse
- 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.
See Also
- Python Certificate Pinning Why your Python app should remember which ID card a server uses — and refuse impostors even if they have official-looking badges.
- Python Cryptography Library Understand Python Cryptography Library with a vivid mental model so secure Python choices feel obvious, not scary.
- Python Dependency Vulnerability Scanning Why the libraries your Python project uses might be secretly broken — and how to find out before hackers do.
- Python Hashlib Hashing How Python turns any data into a unique fingerprint — and why that fingerprint can never be reversed.
- Python Hmac Authentication How Python proves a message wasn't tampered with — using a secret handshake only you and the receiver know.