`eval()` y `exec()`: RCE por diseño y alternativas seguras

Cuando ves eval() en código de producción que procesa input externo, estás mirando una vulnerabilidad de Remote Code Execution (RCE) esperando ser explotada. No es exageración: eval(input_usuario) le entrega al atacante las mismas capacidades que tiene tu proceso Python, con los mismos permisos del sistema operativo.

eval() toma un string y lo ejecuta como expresión Python. exec() hace lo mismo pero acepta statements completos (asignaciones, bucles, definiciones de funciones, imports). La distinción técnica importa poco en la práctica: ambos tienen exactamente las mismas implicaciones de seguridad porque Python no tiene un sandbox real a nivel de intérprete.

El diseño intencional es el problema. Python evalúa el string en el contexto del intérprete activo, con acceso al sistema de módulos completo. No hay una capa de aislamiento entre “código evaluado” y “código del proceso”. Cuando pasan un string a eval(), ese string no es datos: es código con todos sus privilegios.

El momento en que deberías usar eval() o exec() con input no controlado es nunca. En herramientas de desarrollo propias, scripts de administración donde controlas el 100% del input, o sistemas de metaprogramación interna —sin datos externos— la conversación cambia. Pero si el string viene de un usuario, de una API, de un archivo de configuración editado por terceros, o de cualquier fuente que no controlas completamente en origen, el riesgo es inaceptable.

Lo que se rompe no es solo tu aplicación: es el servidor, el filesystem, las credenciales en memoria, las variables de entorno con tokens de producción. El atacante tiene exactamente lo que tú tienes.

import ast
import json
import operator
import re

# ──────────────────────────────────────────────────
# 1. Demostración del problema (NO usar con input real)
# ──────────────────────────────────────────────────

def vulnerable_config_parser(user_input: str):
    """Jamás hagas esto con input externo."""
    return eval(user_input)  # RCE garantizado


# Payloads que un atacante enviaría:
ATTACK_PAYLOADS = [
    '__import__("os").system("id")',
    '__import__("subprocess").check_output(["whoami"])',
    '[x for x in ().__class__.__bases__[0].__subclasses__() '
    ' if x.__name__ == "WarningMessage"][0]',  # escapa por la jerarquía de clases
    'open("/etc/passwd").read()',
]

# ──────────────────────────────────────────────────
# 2. El "sandbox" roto: restringir globals no sirve
# ──────────────────────────────────────────────────

def fake_sandbox(user_input: str):
    """
    Pasar globals vacíos parece seguro. No lo es.
    El atacante puede recuperar clases base del runtime
    y llegar a os.system desde cualquier objeto.
    """
    restricted_globals = {"__builtins__": {}}  # ilusión de seguridad
    return eval(user_input, restricted_globals)


# Este payload funciona incluso con builtins vacíos:
bypass = (
    "[c for c in ().__class__.__mro__[-1].__subclasses__() "
    " if c.__name__ == 'Popen'][0](['id'], capture_output=True).stdout"
)
# fake_sandbox(bypass)  # → ejecuta 'id' igualmente


# ──────────────────────────────────────────────────
# 3. La alternativa real: ast.literal_eval()
# ──────────────────────────────────────────────────

def safe_parse_config(user_input: str) -> dict | list | str | int | float | bool | None:
    """
    ast.literal_eval() solo evalúa literales de datos Python:
    strings, bytes, números, tuplas, listas, dicts, sets, bool, None.
    Cualquier otra cosa lanza ValueError o TypeError.
    """
    try:
        return ast.literal_eval(user_input)
    except (ValueError, SyntaxError) as exc:
        raise ValueError(f"Input inválido o no seguro: {exc}") from exc


# ──────────────────────────────────────────────────
# 4. Calculadora segura sin eval: usa un despachador explícito
# ──────────────────────────────────────────────────

SAFE_OPS = {
    "+": operator.add,
    "-": operator.sub,
    "*": operator.mul,
    "/": operator.truediv,
}

_EXPR_PATTERN = re.compile(r"^\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)\s*$")

def safe_calculate(expression: str) -> float:
    """
    Evalúa expresiones simples sin tocar eval().
    Si el formato no coincide exactamente, falla ruidosamente.
    """
    match = _EXPR_PATTERN.match(expression)
    if not match:
        raise ValueError(f"Expresión no permitida: {expression!r}")

    left, op_str, right = match.groups()
    op_func = SAFE_OPS.get(op_str)
    if op_func is None:
        raise ValueError(f"Operador no permitido: {op_str!r}")

    return op_func(float(left), float(right))


# ──────────────────────────────────────────────────
# 5. Casos de uso legítimos para json vs ast.literal_eval
# ──────────────────────────────────────────────────

def parse_user_data(raw: str):
    """
    Preferencia clara:
      - JSON externo → json.loads()
      - Literales Python internos (testing, configs) → ast.literal_eval()
      - Nunca → eval()
    """
    # Intenta JSON primero (formato estándar para APIs)
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        pass

    # Fallback para literales Python legítimos (e.g., tuplas, sets)
    # que JSON no puede representar
    return safe_parse_config(raw)


# ──────────────────────────────────────────────────
# Ejercicio: verificar qué acepta y qué rechaza ast.literal_eval
# ──────────────────────────────────────────────────

test_cases = [
    ('{"key": [1, 2, 3]}', True),        # dict con lista
    ("(1, 2, 'three')", True),           # tupla
    ("{1, 2, 3}", True),                 # set
    ("None", True),                      # None literal
    ('__import__("os")', False),         # import → rechazado
    ("1 + 1", False),                    # expresión aritmética → rechazado
    ("[x*2 for x in range(5)]", False),  # comprehension → rechazada
]

for raw, should_succeed in test_cases:
    try:
        result = ast.literal_eval(raw)
        status = "✅" if should_succeed else "❌ INESPERADO"
        print(f"{status} {raw!r:40} → {result!r}")
    except (ValueError, SyntaxError):
        status = "✅" if not should_succeed else "❌ INESPERADO"
        print(f"{status} {raw!r:40} → rechazado")

Qué está pasando en cada bloque

El problema central está en vulnerable_config_parser: una línea, cero validación, RCE completo. El string que recibe se compila y ejecuta en el intérprete activo. No hay diferencia entre ese código y el que escribiste tú.

El sandbox roto en fake_sandbox ilustra por qué la “solución” obvia no funciona. Pasar {"__builtins__": {}} elimina los builtins del namespace visible, pero no elimina el sistema de objetos de Python. Cualquier objeto —incluso (), una tupla vacía— tiene __class__, que tiene __mro__, que lleva a object, que tiene __subclasses__(). Esa lista contiene absolutamente todas las clases cargadas en el proceso, incluidas subprocess.Popen e io.FileIO. El atacante navega por la jerarquía de clases del runtime para llegar a lo que necesita. Este bypass es bien conocido desde al menos 2011 y no tiene parche porque es inherente al modelo de objetos de Python.

ast.literal_eval() opera de manera fundamentalmente diferente: no llama al intérprete. Parsea el string a un AST (Abstract Syntax Tree) y luego evalúa únicamente nodos de tipo literal: Constant, List, Tuple, Dict, Set. Si el AST contiene cualquier otro nodo —llamadas a funciones, nombres de variables, operadores aritméticos, comprehensions— lanza ValueError. No hay ruta de escape porque nunca entras al intérprete.

La calculadora explícita muestra el patrón correcto cuando necesitas evaluar expresiones que ast.literal_eval() no acepta: parsea manualmente con regex o con el módulo ast directamente, extrae los operandos, y despacha a funciones nativas. Es más código, pero el espacio de operaciones posibles está completamente bajo tu control.

La jerarquía json vs ast.literal_eval(): si estás hablando con APIs o recibiendo datos externos, json.loads() es la respuesta correcta —JSON tiene semántica de datos bien definida y sin construcciones de código. ast.literal_eval() tiene sentido para literales Python en contextos internos: archivos de configuración que ya no quieres migrar a JSON, fixtures de testing, o estructuras como set y tuple que JSON no puede representar.

Errores que debes conocer

Error: Usar eval() con una lista de builtins “permitidos” creyendo que eso crea un sandbox seguro.

# ❌ Wrong
SAFE_BUILTINS = {"abs": abs, "len": len, "range": range}
result = eval(user_input, {"__builtins__": SAFE_BUILTINS})

# ✅ Right
result = ast.literal_eval(user_input)  # si solo necesitas datos
# O un despachador explícito si necesitas lógica

La lista blanca de builtins no bloquea el acceso a __subclasses__() ni a los atributos de los objetos del runtime. ast.literal_eval() elimina el problema de raíz al no ejecutar el AST.


Error: Usar eval() para deserializar estructuras de datos Python porque “es más cómodo que json”.

# ❌ Wrong
config = eval(open("config.txt").read())  # si config.txt es editable por terceros

# ✅ Right
import json
with open("config.json") as f:
    config = json.load(f)
# O si el archivo usa sintaxis Python y lo controlas 100%:
config = ast.literal_eval(open("config.txt").read())

eval() sobre archivos que podrían ser modificados por usuarios o procesos no confiables convierte una lectura de configuración en un vector de escalada de privilegios.


Error: Asumir que exec() es “menos peligroso” que eval() porque “solo ejecuta statements”.

# ❌ Wrong
exec(user_code, {"__builtins__": {}})  # misma superficie de ataque

# ✅ Right
# No hay versión segura de exec() con input externo.
# Diseña la funcionalidad sin necesitar ejecución dinámica de código.

exec() tiene exactamente la misma exposición al bypass por jerarquía de clases que eval(). La restricción de globals aplica igual de mal a ambos.

197

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio