● CWE-22 · CVE-2007-4559

Salto de directorios en tarfile.extractall de Python (CVE-2007-4559): cómo ocurre y cómo corregirlo

Antes de los filtros de extracción, tarfile.extractall() escribía cada miembro en la ruta guardada en el archivo comprimido, así que los nombres con .., las rutas absolutas o los enlaces podían dejar archivos fuera de la carpeta de destino. Cómo lo bloquea el argumento filter="data" de la PEP 706, por qué el destino no puede salir de la entrada del usuario y qué límites de tamaño añadir, con un ejemplo en Python.

Explicación en Lenguaje Sencillo (ELI5)

Una empresa de mudanzas desembala cajas en tu piso nuevo y coloca cada objeto donde diga su etiqueta. Una etiqueta que pone 'cocina, luego por la puerta principal, luego al dormitorio del vecino' se sigue al pie de la letra, porque los operarios solo leen etiquetas y nunca comprueban que la dirección siga dentro de tu piso. Un archivo comprimido funciona igual: cada fichero lleva su propia ruta de destino. La solución es un encargado que lee antes cada etiqueta y rechaza todo lo que saldría del piso, además de cualquier cosa que no sea una caja normal, como un cartel que apunta a otro sitio.

Conceptos Clave y Términos

CVE-2007-4559
El módulo tarfile de Python no impedía que los nombres de los miembros salieran del directorio de extracción. El problema se conocía desde 2007, y en 2022 Trellix calculó que unos 350.000 proyectos de código abierto llamaban a extractall() sobre archivos no confiables.
Nombres de miembro con .. o rutas absolutas
Una entrada tar llamada ../outside.txt o /etc/cron.d/job se resuelve fuera del destino al unirla con él. Sin filtro, extractall() escribe en esa ruta resuelta con los permisos del proceso.
Miembros que son enlaces simbólicos o duros
Un archivo comprimido puede crear primero dentro del destino un enlace que apunta a otro lugar y luego escribir un fichero normal a través de él. Comprobar solo los nombres no lo detecta, por eso los filtros también revisan el destino de los enlaces.
Filtros de extracción (PEP 706)
filter="data" rechaza nombres absolutos, escapes con .., enlaces que salen del destino y archivos de dispositivo, y elimina los bits set-user-ID y de ejecución para otros. Está disponible desde Python 3.12 y en las versiones de seguridad de 3.8 a 3.11, y pasa a ser el valor por defecto en Python 3.14.
Límites de recursos
Los filtros no limitan el tamaño. Un archivo comprimido pequeño puede expandirse en millones de miembros o muchos gigabytes, así que cuenta los miembros y el tamaño total antes de extraer.

Flujo de Ataque Paso a Paso

Paso 1

La app acepta un archivo comprimido subido

Una importación de datos, un instalador de plugins o un gestor de artefactos de CI recibe un .tar.gz de un usuario.

Paso 2

Un nombre de miembro apunta fuera de la carpeta

La ruta guardada de una entrada sube por encima del destino o es absoluta. Un archivo de prueba con un miembro llamado ../outside.txt ilustra el caso.

Paso 3

extractall escribe en la ruta resuelta

El fichero acaba donde se resuelva el nombre, y el propio destino puede salir de un job_id que envía el usuario.

Paso 4

Se sustituyen archivos de la aplicación

La configuración, las plantillas o los scripts sobrescritos se ejecutan la próxima vez que la aplicación los carga, y así este salto de directorios se convierte en ejecución de código.

Código Fuente: Vulnerable vs. Seguro

IMPLEMENTACIÓN VULNERABLE
# imports.py: cada miembro va adonde apunte su nombre guardado
import tarfile
from pathlib import Path

UPLOAD_ROOT = Path("/srv/app/imports")


# carpeta elegida por quien llama, sin filtro, sin límites de tamaño
def import_bundle(archive_path: str, job_id: str) -> Path:
    dest = UPLOAD_ROOT / job_id
    dest.mkdir(parents=True, exist_ok=True)
    with tarfile.open(archive_path) as tar:
        tar.extractall(dest)
    return dest
PARCHE SEGURO Y ROBUSTO
# imports.py: filtro data, carpeta con nombre del servidor y límites de tamaño
import tarfile
import uuid
from pathlib import Path

UPLOAD_ROOT = Path("/srv/app/imports")
MAX_MEMBERS = 10_000
MAX_TOTAL_BYTES = 500 * 1024 * 1024


# rechaza archivos con demasiados miembros o bytes
def check_limits(tar: tarfile.TarFile) -> None:
    members = tar.getmembers()
    if len(members) > MAX_MEMBERS or sum(m.size for m in members) > MAX_TOTAL_BYTES:
        raise ValueError("archive too large")


# filter='data' bloquea .., nombres absolutos, enlaces hacia fuera y archivos de dispositivo
def import_bundle(archive_path: str) -> Path:
    dest = UPLOAD_ROOT / uuid.uuid4().hex
    dest.mkdir(parents=True)
    with tarfile.open(archive_path, mode="r:*") as tar:
        check_limits(tar)
        tar.extractall(dest, filter="data")
    return dest

Lista de Verificación de Seguridad para Ingeniería

Fuentes

← Ver el directorio completo de seguridad Todas las guías de vulnerabilidades →