← Todo el material
Curso Avanzado 24 de agosto de 2026

Seguridad de IA — Módulo 10: secretos, sandboxing y ejecución de código

Décimo módulo del Curso de Seguridad de IA: cómo contener a un agente que ejecuta código o comandos. Aislamiento por contenedor y microVM, control de egress de red, gestión de secretos que el agente nunca debe ver, y el patrón de ejecutar código generado por un LLM sin comprometer el host.

☰ Índice del contenido

En el módulo 9 aprendimos a desconfiar del artefacto: un modelo descargado, un paquete de pesos, una dependencia del pipeline pueden venir con carga hostil, y la firma criptográfica es lo único que separa “confío en la fuente” de “confío en un archivo random”. Pero hay un caso en que la desconfianza tiene que ir un paso más lejos, porque el artefacto no solo se carga: se ejecuta. Un agente que escribe código y lo corre, que ejecuta comandos de shell, que lanza SQL generado o que carga ese modelo no confiable del módulo anterior, está tomando texto producido por un proceso estadístico y entregándoselo a un intérprete con permisos reales. Este módulo trata la pregunta que aparece ahí: cómo correr algo que no es de fiar sin que se lleve el host por delante.

Como todo el curso, esto es material defensivo: sirve para contener tus propios agentes, en tu propia infraestructura. Las técnicas de aislamiento que vienen son para levantar paredes alrededor de código que tú vas a ejecutar, no para vulnerar entornos ajenos.

La tesis del módulo

Cuando un agente ejecuta código, comandos o SQL, la pregunta deja de ser “confío en el modelo” y pasa a ser “qué puede tocar el proceso si el peor código imaginable corre en él”. La contención es del entorno, no del prompt. Un system prompt que dice “no borres archivos” es una sugerencia estadística; un sandbox sin acceso al disco del host es una garantía física. Diseña como si cada ejecución fuera a intentar comprometerte, porque la que importa lo hará.

Por qué la ejecución cambia el juego

En los módulos anteriores el daño de un ataque estaba acotado por lo que las tools permitían: si el agente no tenía tool de correo, la injection no exfiltraba nada por correo. La ejecución de código rompe ese límite, porque el código es la tool universal. Un intérprete de Python puede abrir sockets, leer variables de entorno, montar el sistema de archivos, hacer peticiones HTTP y lanzar subprocesos. Darle a un agente un exec() sin contención es darle todas las tools a la vez, incluidas las que no quisiste darle.

Los escenarios concretos donde esto aparece:

  1. Code interpreter. El agente escribe Python para analizar un CSV, graficar, transformar datos. Útil y peligroso a partes iguales: el mismo import os que necesita para leer el archivo del usuario sirve para leer /etc/passwd.
  2. Ejecución de comandos de shell. Un agente de DevOps que corre kubectl, terraform, git. Cada comando es una acción con efecto real sobre infraestructura.
  3. SQL generado. El agente traduce una pregunta en lenguaje natural a una consulta. Un DROP TABLE generado por confusión o por injection es indistinguible, para la base de datos, de uno escrito a mano.
  4. Cargar un modelo no confiable (el del módulo 9). Deserializar pesos puede ejecutar código; ese código corre en el proceso que hizo el load.

El rango de daño va de RCE (Remote Code Execution, ejecución de código arbitrario en tu servidor) a exfiltración de secretos a pivote lateral (usar el host comprometido como trampolín hacia otros sistemas de tu red). Y el detonante no siempre es un ataque: a veces el modelo simplemente se equivocó y generó un comando destructivo. Para el host, un rm -rf por injection y uno por alucinación hacen exactamente el mismo agujero.

Hay una asimetría que conviene interiorizar antes de seguir. En una tool con firma acotada — enviar_correo(destinatario, cuerpo) — el atacante está limitado por los argumentos que la tool acepta; puedes validar el destinatario y cerrar el abuso ahí. En la ejecución de código no hay firma que validar: el argumento es un programa entero, en un lenguaje Turing-completo, y no existe un análisis estático que decida de forma confiable si ese programa es benigno. No puedes “revisar el código antes de correrlo” con una regla; el problema de decidir qué hace un programa arbitrario es, literalmente, indecidible. Por eso la contención de la ejecución no es una lista de comandos prohibidos: es un perímetro alrededor del intérprete. No preguntas “¿este código es malo?”; asumes que lo es y limitas lo que puede tocar.

Las capas de aislamiento, de menos a más fuerte

No existe “el sandbox”: existe un espectro de contención con costos crecientes. La regla es elegir la capa por el valor de lo que proteges y por qué tan hostil esperas que sea el código, no por moda.

Capa 1 — Proceso con permisos reducidos

Lo mínimo: correr el código como un usuario sin privilegios, con un seccomp (secure computing mode, un filtro del kernel de Linux que restringe qué llamadas al sistema puede hacer el proceso) que bloquee syscalls peligrosas, sin capacidades de root, con ulimit. Es barato y mejor que nada, pero comparte el kernel del host: una vulnerabilidad de escalada de privilegios en el kernel se salta todo. Sirve como capa complementaria, no como frontera única.

Capa 2 — Contenedor

Un contenedor (Docker y similares) aísla el sistema de archivos, la red y los procesos vía namespaces y cgroups del kernel. Es el punto de partida práctico. Pero hay que decirlo claro, porque es el error más común del módulo:

Ojo: un contenedor NO es una frontera de seguridad fuerte por sí solo. Un contenedor comparte el kernel del host. Todas las vulnerabilidades de escape de contenedor que existen — y existen, se publican cada año — apuntan a ese kernel compartido. Docker fue diseñado para aislar cargas de trabajo que confías entre sí, no para contener código adversarial. Es la primera capa, no la última.

Capa 3 — gVisor

gVisor es un kernel de aplicación en espacio de usuario, escrito por Google, que se interpone entre el contenedor y el kernel real. Su runtime runsc intercepta cada llamada al sistema del código sandboxeado y la resuelve en un núcleo Go (llamado Sentry) que reimplementa la interfaz de syscalls de Linux; el Sentry mismo solo necesita 68 syscalls del host (de las más de 300 que expone Linux) y corre bajo un filtro seccomp que lo limita a esas. El resultado es que el código adversarial nunca habla directo con el kernel que quieres proteger: habla con una imitación en espacio de usuario. El costo es rendimiento: cada syscall paga un peaje extra, así que las cargas intensivas en syscalls (mucho I/O, muchos procesos) corren notablemente más lento que en un contenedor nativo.

Capa 4 — microVM

Una microVM (máquina virtual mínima y de arranque rápido) da aislamiento por hipervisor (la capa que virtualiza hardware y separa VMs entre sí), que es la frontera más fuerte disponible fuera de máquinas físicas separadas. Firecracker, de AWS, arranca microVMs en decenas de milisegundos y es lo que hay debajo de Lambda y de varios servicios de ejecución. Cada ejecución corre en su propia VM con su propio kernel: para saltar al host hay que romper el hipervisor, un objetivo mucho más duro que romper un namespace. Cuesta más arranque y más memoria por instancia, pero es lo que usarías para código verdaderamente no confiable de terceros.

Capa 5 — Servicio gestionado de ejecución

Puedes no operar nada de lo anterior y delegar en un servicio hecho para esto. E2B, Modal o los sandboxes de code interpreter de los proveedores de LLM te dan sandboxes efímeros por API. E2B, por ejemplo, expone un SDK de Python donde Sandbox.create() levanta un entorno aislado y sandbox.run_code() ejecuta código dentro, con la contención (microVM por debajo) resuelta por ellos. Cambias control por velocidad de integración y por no mantener la infraestructura de aislamiento tú mismo.

CapaFortalezaCosto / latenciaCuándo usarla
Proceso + seccompBajaCasi nulaCódigo semi-confiable, como refuerzo
ContenedorMedia (kernel compartido)BajaPunto de partida, nunca la única capa
gVisorAltaMedia (peaje por syscall)Multi-tenant, código no confiable con presupuesto de latencia
microVM (Firecracker)Muy alta (hipervisor)Media-alta (arranque + RAM)Código de terceros verdaderamente hostil
Servicio gestionado (E2B/Modal)Alta (según proveedor)Variable + costo por usoNo quieres operar aislamiento propio

La lectura de la tabla: el contenedor solo es aceptable como capa única para código que ya confías; en cuanto corres código generado por un LLM o subido por un usuario, subes a gVisor o microVM. Visto de lado, lo que cambia entre capas es qué tan lejos del kernel del host queda el código adversarial:


 código no confiable
        │
        ▼
 ┌─────────────────┐   comparte kernel:
 │   contenedor    │   frontera débil
 └────────┬────────┘
          │
          ▼
 ┌─────────────────┐   kernel de usuario
 │  gVisor Sentry  │   intermedio (Go)
 └────────┬────────┘
          │ 68 syscalls al host
          ▼
 ┌─────────────────┐   o, más fuerte:
 │  hipervisor /   │   microVM con su
 │  microVM        │   propio kernel
 └────────┬────────┘
          │
          ▼
   ┌─────────────┐
   │ kernel host │  ◄── lo que proteges
   └─────────────┘

Cada caja intermedia es una pared más entre el código y la última línea. El contenedor solo apoya el código sobre el kernel del host; gVisor mete un kernel de usuario en medio; la microVM mete un hipervisor y un kernel entero propio. Más paredes, más costo, más protección.

Cuidado: una microVM perfecta no te salva si el sandbox tiene la API key montada y salida a internet. El aislamiento de cómputo y el control de egress/secretos son ejes independientes: podrías tener la VM más fuerte del mundo y aún así exfiltrar todo, porque el código, sin escapar de la VM, hace una petición HTTP con la credencial que le dejaste. La fortaleza del sandbox contiene el escape; la disciplina de red y secretos contiene la exfiltración. Necesitas las dos, y las siguientes dos secciones son la segunda mitad.

El caso del SQL y los comandos: contener el permiso, no el proceso

Hay un matiz importante: no todo lo que un agente ejecuta se contiene con un sandbox de cómputo. Cuando el agente genera SQL, el intérprete peligroso no es un proceso tuyo, es el motor de la base de datos, que corre en otra parte y con sus propios permisos. Meter la consulta en un contenedor no protege nada: la consulta igual llega a la base. Ahí la contención es de privilegio en la conexión: el agente usa un usuario de base de datos de solo lectura, sobre una vista que ya filtra por tenant, sin permiso de DROP, DELETE ni UPDATE. El DROP TABLE generado por injection falla no porque lo detectaste, sino porque el rol que lo ejecuta no tiene ese permiso. Mismo principio con los comandos de shell de un agente de infraestructura: no le des una credencial de administrador y confíes en el prompt; dale un rol con exactamente los permisos de la tarea y ni uno más.

La regla general, entonces, tiene dos ramas. Si el código corre en tu proceso o tu host, lo contienes con un sandbox (contenedor, gVisor, microVM). Si el código actúa sobre un sistema externo con sus propios permisos (una base de datos, una nube, un repositorio), lo contienes con mínimo privilegio en la credencial con que actúa. Casi siempre necesitas las dos a la vez.

Control de egress de red: el sandbox mudo por defecto

El aislamiento de cómputo no basta si el sandbox puede hablar con internet. El egress (tráfico de salida, del sandbox hacia afuera) es el canal por el que el daño se vuelve permanente: sin salida de red, un código malicioso puede romper cosas dentro del sandbox efímero, pero no puede sacar nada ni llamar a casa. Con egress abierto, cualquier ejecución se convierte en un canal de exfiltración — exactamente el problema de exfiltración vía canal de salida que vimos en seg-05, ahora amplificado porque el código puede construir la petición que quiera.

La regla es de mínimo privilegio aplicada a la red:

  • Por defecto, sin salida. El sandbox arranca sin ruta a internet (--network none en Docker). Si la tarea no necesita red, no la tiene, y punto.
  • Allowlist cuando haga falta. Si el código legítimamente necesita, digamos, descargar de un índice de paquetes interno, se abre solo ese destino, por dominio o IP, a través de un proxy que registra cada conexión. Todo lo demás se rechaza.
  • Nunca allowlist por “conveniencia”. Abrir 0.0.0.0/0 porque “a veces el usuario necesita bajar algo” es entregar el canal de exfiltración de vuelta. Si hace falta descargar, se hace fuera del sandbox y se monta el resultado adentro.

Dos destinos internos se olvidan siempre y son de los peores. El primero es el endpoint de metadatos de la nube (la IP 169.254.169.254 en AWS, GCP, Azure): si el sandbox puede alcanzarla, un código malicioso pide las credenciales temporales del rol de la instancia y pivota con los permisos de tu infraestructura. El segundo es el DNS: aunque bloquees HTTP, una resolución DNS a un dominio del atacante saca datos codificados en el nombre consultado (datos-robados.atacante.test). Un egress “cerrado” que deja pasar DNS o metadatos no está cerrado. La allowlist se aplica a la capa de red, no solo al cliente HTTP.

Gestión de secretos: lo que el agente jamás debe ver

Acá está la parte que más incidentes causa en la práctica. El agente y el código que ejecuta nunca deben ver credenciales de larga vida. Ninguna. Una API key en el prompt, o en una variable de entorno accesible desde el sandbox, es un incidente esperando su momento: el código generado puede leer os.environ en una línea, y con injection indirecta alguien puede hacer que lo haga y lo exfiltre.

El patrón correcto tiene tres piezas:

  1. Broker de secretos por fuera del sandbox. El sandbox no tiene la credencial; cuando necesita hacer una llamada autenticada, la petición sale hacia un proxy/broker que corre fuera del sandbox, y es el broker quien inyecta el secreto en la petición saliente. El código nunca ve la key; solo ve el proxy.
  2. Tokens de corta vida y con alcance acotado (scoped). En vez de una key permanente con todos los permisos, se emite un token que expira en minutos y que solo puede hacer la operación concreta que la tarea requiere. Si se filtra, el daño está acotado en tiempo y en alcance.
  3. Nada de secretos montados. El sandbox se levanta sin variables de entorno con credenciales, sin archivos de config con tokens, sin volúmenes que contengan .env. Lo que no está montado no se puede leer.

Esto conecta con la práctica del propio sitio: los secretos viven en Railway como variables de entorno del servicio, y el código que se ejecuta en contextos no confiables no corre en ese mismo proceso con acceso a ellas. El principio es idéntico — la credencial vive donde el código adversarial no llega.

Cuidado: “pero es una env var, no está en el prompt” no es defensa. Para el código que corre en el sandbox, os.environ["OPENAI_API_KEY"] y una key escrita en el prompt son igual de accesibles. La única defensa real es que la credencial no exista dentro del perímetro del sandbox.

Efímero, con límites, y nunca reutilizado

Tres reglas más que completan la contención:

  • Sistema de archivos efímero. El sandbox se crea para una ejecución y se destruye al terminar. Nada de estado que persista entre corridas: lo que el código escribió muere con el contenedor. Root de solo lectura (--read-only) más un directorio temporal descartable es el patrón.
  • Límites de CPU, memoria, tiempo y procesos. Sin ellos, un código puede colgar el host con un bucle infinito, agotar la RAM o lanzar una fork bomb. Esto es el unbounded consumption de OWASP LLM10 (consumo sin límites): la denegación de servicio que no necesita ni malicia, solo un modelo que generó un while True. Se acota con --cpus, --memory, un timeout duro y --pids-limit.
  • No reutilizar el sandbox entre usuarios. Un sandbox reciclado arrastra lo que dejó el usuario anterior: archivos, variables, procesos. La injection del usuario A no debe poder tocar los datos del usuario B. Un sandbox por ejecución, o al menos por usuario, sin excepciones.

El patrón: ejecutar código generado sin comprometer el host

Este es el molde. Ejecuta código no confiable en un contenedor efímero, sin red, con límites de recursos y timeout, sin secretos montados, y con el sistema de archivos descartable. Los flags de Docker están verificados contra su documentación:

"""Ejecución de código generado por un LLM en un sandbox efímero.

Uso defensivo: contener CÓDIGO PROPIO no confiable (generado por el
agente o subido por un usuario) para que no toque el host. La contención
es de flags y arquitectura, no de confianza en el modelo.
"""
import subprocess
import tempfile
import textwrap
from pathlib import Path

LIMITES = [
    "--network", "none",        # sin egress: no exfiltra, no llama a casa
    "--memory", "512m",         # tope de RAM: mata al pasarse
    "--cpus", "1.0",            # tope de CPU
    "--pids-limit", "128",      # frena la fork bomb
    "--read-only",              # root inmutable
    "--cap-drop", "ALL",        # sin capacidades de kernel
    "--security-opt", "no-new-privileges",  # no escala privilegios
    "--user", "65534:65534",    # usuario 'nobody', jamás root
]


def ejecutar_codigo(codigo: str, timeout_s: int = 10) -> dict:
    """Corre `codigo` en un contenedor efímero y aislado.

    Sin secretos: NO se pasa ninguna variable de entorno con
    credenciales. Sin red. Sin persistencia. El contenedor se
    autodestruye (`--rm`) al terminar."""
    with tempfile.TemporaryDirectory() as tmp:
        script = Path(tmp) / "code.py"
        script.write_text(textwrap.dedent(codigo), encoding="utf-8")
        cmd = [
            "docker", "run", "--rm",
            *LIMITES,
            # tmpfs descartable para lo que el código necesite escribir
            "--tmpfs", "/work:rw,size=64m,noexec",
            "-v", f"{script}:/work/code.py:ro",
            "python:3.12-slim",
            "timeout", str(timeout_s),          # timeout DENTRO del contenedor
            "python", "/work/code.py",
        ]
        try:
            r = subprocess.run(
                cmd, capture_output=True, text=True,
                timeout=timeout_s + 5,           # cinturón por si docker cuelga
            )
            return {"ok": r.returncode == 0,
                    "stdout": r.stdout[:10_000],
                    "stderr": r.stderr[:10_000]}
        except subprocess.TimeoutExpired:
            return {"ok": False, "stdout": "", "stderr": "timeout"}

Fíjate en lo que NO aparece: no hay env= con credenciales, no hay --network con salida, no hay volumen de escritura sobre el host, no hay root. Cada flag cierra una vía concreta de escape. Y para código verdaderamente hostil de terceros, este mismo patrón se corre bajo gVisor (agregando --runtime=runsc) o se delega a un servicio gestionado, cuya forma de uso es aún más simple:

# Pseudo-config de un sandbox gestionado (estilo E2B).
# El aislamiento por microVM lo resuelve el proveedor; tú solo
# creas el sandbox efímero y corres el código dentro.
from e2b_code_interpreter import Sandbox

with Sandbox.create() as sandbox:          # microVM aislada, sin secretos tuyos
    ejecucion = sandbox.run_code(codigo)   # corre el código generado
    resultado = ejecucion.logs             # stdout/stderr; el sandbox muere al salir del with

Cómo se ve el perímetro

El diagrama junta las piezas: el agente no ejecuta nada él mismo, delega en un sandbox efímero sin red y sin secretos; el egress pasa por una allowlist y los secretos los inyecta un broker que vive afuera del sandbox.


 ┌──────────┐  código a correr  ┌───────────────────┐
 │  agente  │ ────────────────► │  sandbox efímero  │
 └──────────┘                   │  sin red          │
      ▲                         │  sin secretos     │
      │ resultado               │  límites CPU/RAM  │
      └─────────────────────────┤  fs descartable   │
                                └───┬──────────┬────┘
                 salida de red      │          │ llamada
                 controlada         ▼          ▼ autenticada
                            ┌──────────────┐ ┌───────────┐
                            │  allowlist   │ │  broker   │
                            │  de egress   │ │ de secre- │
                            │  (proxy log) │ │ tos       │
                            └──────┬───────┘ └─────┬─────┘
                                   ▼               ▼
                            destinos permitidos  API con
                            (lo demás: rechazo)  token corto

Lo que el diagrama grita: la credencial nunca entra al sandbox y la red nunca sale sin pasar por la allowlist. El código adversarial puede hacer lo que quiera dentro de la caja, y da igual, porque la caja no tiene ni secretos que robar ni salida por donde sacarlos.

Errores comunes

  • Tratar el contenedor como frontera de seguridad. Docker aísla cargas que confías entre sí; comparte el kernel con el host. Para código adversarial hace falta gVisor o microVM. El contenedor es la primera capa, jamás la única.
  • Dejar el egress abierto “por si acaso”. Un sandbox con salida a internet es un canal de exfiltración regalado. Por defecto sin red; allowlist estrecha solo cuando la tarea concreta lo exige.
  • Pasar secretos como variables de entorno al sandbox. El código generado lee os.environ en una línea. Que sea env var y no prompt no cambia nada: si la credencial está en el perímetro, se puede leer y filtrar.
  • Credenciales de larga vida. Una API key permanente filtrada es un incidente indefinido. Tokens de corta vida, con alcance acotado, inyectados por un broker externo.
  • Reutilizar el sandbox entre usuarios. Arrastra archivos, procesos y datos del usuario anterior. Un sandbox por ejecución; nada persiste entre corridas.
  • Olvidar los límites de recursos. Sin --memory, --cpus, --pids-limit y timeout, un while True o una fork bomb tumban el host. Es el unbounded consumption de OWASP LLM10, y no necesita malicia: basta una alucinación.
  • Confiar en el prompt para contener la ejecución. “No borres archivos ni hagas peticiones de red” es una sugerencia probabilística. La contención vive en los flags del sandbox y en la arquitectura del perímetro, no en el system prompt.
  • Cerrar HTTP pero dejar DNS y metadatos. Un egress que bloquea el puerto 80/443 pero deja resolver DNS o alcanzar 169.254.169.254 sigue abierto: por ahí se exfiltra y se roban credenciales de la instancia. La allowlist es de red, no solo del cliente HTTP.
  • Sandboxear el código que genera SQL en vez de acotar el permiso. El SQL peligroso corre en la base, no en tu proceso. Meterlo en un contenedor no evita el DROP; un usuario de solo lectura sobre una vista filtrada, sí.
  • Creer que la microVM sola basta. Aislamiento de cómputo y control de exfiltración son ejes distintos. Una VM inexpugnable con la key montada y salida abierta exfiltra sin escapar de la VM.

La frase para llevarse

“La contención de un agente que ejecuta código no se mide por lo que el modelo promete no hacer, sino por lo que el proceso no puede tocar aunque lo intente. Sin red, sin secretos, sin persistencia y con límites duros: si el peor código imaginable corre ahí, que no pase nada.”

Qué viene

Módulo 11: red teaming ofensivo. Llevas diez módulos construyendo defensas — modelo de amenazas, OWASP, exfiltración, supply chain, sandboxing. Ahora nos ponemos el sombrero del atacante, siempre sobre sistemas propios: cómo diseñar una campaña de red teaming que pruebe todas estas defensas a la vez, encadenando vectores (una injection indirecta que llega a una ejecución de código que intenta el egress) para ver dónde se rompe la cadena. Porque una defensa que nunca se atacó en serio es una hipótesis, no una garantía.

Material relacionado

SeguridadSandboxingEjecución de códigoSecretosAislamiento

Preguntas sobre este curso

Regístrate gratis para hacer preguntas y guardar tu progreso.

✉ Entérate de lo nuevo

Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.