🛡️ sandbox-labs GitHub ↗

03 · Procesamiento seguro de archivos comprimidos#

En una frase, para cualquiera: descomprimir un fichero que te mandó otra persona es dejar que esa persona decida dónde se escriben cosas en tu disco. Este caso lo hace dentro de una jaula y te entrega un informe de cada entrada.

Estado real: 🟡 building · Carpeta: cases/03-file-detonation/ · Puerto: 8803


Por qué se realiza este caso#

Un archivo comprimido no es una caja: es una lista de instrucciones de escritura. Cada entrada dice «crea un fichero en esta ruta con este contenido». Y la ruta la eligió quien hizo el archivo.

Si tu programa se fía de esa ruta, quien te manda el fichero elige dónde escribes:

Lo que trae el archivoLo que consigue
Una entrada llamada ../../etc/cron.d/tareaEscribir fuera de la carpeta de destino, en el sistema. Se llama zip slip
Una entrada llamada /etc/passwdRuta absoluta: ignora tu carpeta por completo
Un fichero de 42 KB que descomprime a 4,5 GBLlenar el disco o agotar la memoria. Se llama zip bomb
Un enlace simbólico apuntando a /home/tú/.ssh/Que la siguiente entrada escriba a través de él
Un nombre con C:\Windows\ o con caracteres Unicode confusosSaltarse comprobaciones escritas para el otro sistema
Archivos dentro de archivos, veinte nivelesAgotar el proceso que los recorre

Este es el caso donde el sandbox no es opcional. En los demás se puede discutir si hace falta; aquí la operación consiste literalmente en escribir en el disco lo que dijo un desconocido.

La idea que enseña, y que ningún otro caso enseña#

El informe vale más que el bloqueo. Un antivirus dice «sí» o «no» y no explica nada. Aquí el resultado es una tabla: por cada entrada del archivo, qué se hizo con ella y por qué. Se aprende más de una entrada rechazada con su motivo que de un «archivo peligroso» sin más.

El sandbox deja de ser una pared y pasa a ser un microscopio: sirve para mirar, no solo para impedir.

Casos de uso reales#

Cómo funciona#

flowchart LR
  U["📦 Archivo subido"] --> S["🧭 Servicio :8803"]
  S --> J
  subgraph J["🔒 Jaula (sin red · disco temporal)"]
    E["🔍 Recorrer entradas<br/>SIN escribirlas todavía"]
    V["⚖️ Comprobar cada ruta,<br/>tamaño y tipo"]
    W["📝 Escribir solo<br/>lo que pasó"]
    E --> V --> W
  end
  J --> R["📄 Informe por entrada"]

Qué se comprueba en cada entrada#

flowchart TB
  A["Entrada del archivo"] --> B{"¿La ruta sale de<br/>la carpeta destino?"}
  B -- sí --> B1["🚫 zip slip"]
  B -- no --> C{"¿Ruta absoluta<br/>o estilo Windows?"}
  C -- sí --> C1["🚫 Rechazada"]
  C -- no --> D{"¿Es enlace<br/>simbólico o duro?"}
  D -- sí --> D1["🚫 Rechazada"]
  D -- no --> E{"¿Supera el techo<br/>de tamaño o de ratio?"}
  E -- sí --> E1["🚫 zip bomb"]
  E -- no --> F{"¿Se pasa del número<br/>máximo de entradas?"}
  F -- sí --> F1["🚫 Corte"]
  F -- no --> G["✅ Se extrae y se anota"]

La comprobación ocurre antes de escribir. Comprobar después de extraer es no comprobar: el daño ya está hecho.

Esquemas#

Entrada — POST /api/inspect#

El cuerpo es el archivo comprimido en crudo. Hay también ejemplos preparados en

LímiteValor
Tamaño de subidaacotado; se responde 413 al pasarse
Total descomprimidoacotado, para cortar la zip bomb
Número de entradasacotado

Salida#

{
  "entries": [
    { "name": "documento.txt", "status": "extraída", "bytes": 1024 },
    { "name": "../../etc/cron.d/x", "status": "rechazada", "reason": "zip slip: la ruta sale de la carpeta destino" },
    { "name": "grande.bin", "status": "rechazada", "reason": "zip bomb: supera el total descomprimido permitido" }
  ],
  "summary": { "total": 3, "extracted": 1, "rejected": 2 },
  "runtime": "bwrap"
}
CampoQué es
entriesEl producto del caso: una línea por entrada, con motivo
summaryEl recuento, para vigilar
runtimeQué frontera se usó de verdad

Software necesario#

ComponenteVersiónPara qué¿Obligatorio?
Python3.11+El servicio; zipfile es de la biblioteca estándar
Rust1.75+sandboxctl
bubblewrap0.6+La jaula. Aquí no es opcional: se escribe en disco
Linux o WSL2kernel 5.10+Namespaces sin privilegios

Instalación#

sudo apt install bubblewrap util-linux python3
cargo build --release
cargo run -p sandboxctl -- doctor

Cómo se ejecuta#

cargo run -p sandboxctl -- service up file-detonation

Procesos que se crean#

sandboxctl service up file-detonation
  │
  ├─ systemd --user scope        ← cgroup: memoria y PIDs acotados
  │   └─ bwrap                   ← jaula con disco temporal y sin red
  │       └─ python3 app.py      ← el servicio, en socket Unix
  │
  └─ sandboxctl service forward  ← puente TCP :8803 ↔ socket Unix

El límite de memoria del cgroup importa más aquí que en otros casos: es lo que convierte una zip bomb en «el proceso murió» en lugar de «el equipo se quedó sin memoria».

Tiempo de carga#

OperaciónCoste típico
service up hasta que /health responde0,5–2 s
Inspección de un archivo pequeño (decenas de entradas)20–80 ms
Inspección de un archivo con miles de entradas0,3–2 s
Corte por zip bombinmediato al superar el techo

Estado real y qué falta#

Construido: el servicio, la detección de zip slip, zip bomb, rutas absolutas y enlaces, los techos de tamaño y de número de entradas, y el informe por entrada.

Falta, y empieza por el nombre: este caso no es detonación. Detonar es ejecutar la muestra y observar su comportamiento; aquí solo se extrae con cuidado. Debe renombrarse a 03-safe-archive-processing, y la detonación de verdad pasa a ser el caso 06, que necesita una máquina virtual desechable.

Falta también: más formatos comprimidos, detección de tipo MIME real —no por extensión—, nombres Unicode confusos, archivos anidados y checksum por entrada.

Si algo falla#

SíntomaCausaCómo se soluciona
Todas las entradas salen rechazadaEl archivo trae rutas absolutas o estilo WindowsLeer el reason de cada entrada: dice qué regla la paró. Reempaquetar con rutas relativas (zip -r salida.zip carpeta/ desde dentro de la carpeta)
zip bomb: supera el total descomprimido permitidoEl archivo se expande más allá del techo, y puede ser legítimo1. Subir el techo en app.py. 2. Si el equipo no tiene cgroups, no lo subas: sin memory.max el corte por tamaño es la única defensa que queda
El proceso muere sin responder nadaEl cgroup lo mató al alcanzar memory.maxEs contención, no avería. Confirmarlo mirando memory.peak y oom_kill en la evidencia. Si el archivo es legítimo, subir memoryMb en la política
cuerpo ausente o mayor que N bytes (413)La subida supera el techo del servicioTrocear el archivo, o subir MAX_UPLOAD_BYTES sabiendo que el techo protege el proceso que lo recorre
Un archivo válido se rechaza por completoAlguna entrada trae un enlace simbólico o duroLos enlaces no se extraen nunca: permiten que la entrada siguiente escriba a través de ellos. Reempaquetar resolviendo los enlaces (zip --symlinks no; usar tar -h o copiar el contenido real)
El informe no coincide con lo que ves al descomprimir con otra herramientaOtras herramientas aplican menos comprobacionesEse es el punto del caso. El informe por entrada es la referencia; la otra herramienta es la que está siendo permisiva

Los fallos que afectan a cualquier caso —no se puede crear el sandbox, no hay cgroups, un puerto ocupado, procesos huérfanos, la compilación en Windows— están resueltos uno a uno en Cuando algo falla.


Ver también: Catálogo completo · Estado del proyecto · Modelo de amenazas