Ataque a la cadena de suministro en PyPI: el caso LiteLLM
El paquete LiteLLM comprometido en PyPI llevaba un robador de credenciales en cada wheel. Qué frenan el pinning de hashes y los lockfiles, y qué no.

Tres archivos. Esa es toda la huella de uno de los ataques a la cadena de suministro de Python más desagradables que hemos visto: un cook.pth de 12 bytes, un cook.py de 22 KB y un sitecustomize.py de 25 KB. Cuando LiteLLM 1.107.0 llegó a PyPI, esos tres archivos viajaban dentro de cada uno de sus siete wheels y, por cómo funciona el arranque de Python, se ejecutaban en todos los procesos de Python de un host infectado: tu app, tu runner de CI, el propio pip, incluso el language server de tu editor. No hacía falta importar litellm ni ejecutarlo. Con arrancar cualquier intérprete de Python bastaba.
El equipo de LiteLLM confirmó el compromiso y PyPI retiró la release en pocas horas, pero la ventana fue lo bastante larga. Las imágenes maliciosas de Docker, incluida la etiqueta latest, estuvieron disponibles unas dos horas y media. Si tu CI hizo pull durante ese periodo, el robador ya había recopilado claves SSH, credenciales de cloud, contraseñas del navegador, secretos de CI/CD y tokens de herramientas de IA, los había cifrado y los había enviado a un dominio controlado por el atacante. No era un typosquatting ni un paquete parecido. Era el paquete real, construido por el pipeline real y firmado por el proceso de release real. Y eso debería cambiar la forma en que piensas sobre la confianza en las dependencias.
Cómo tres archivos secuestraron el pipeline oficial de publicación
El atacante no hackeó PyPI ni coló un commit en el repositorio público: el código fuente de GitHub está limpio. Según el informe de incidente de LiteLLM, el atacante tenía las credenciales de la CLI de GitHub de tres desarrolladores, aparentemente extraídas de los logs de un despliegue interno de LiteLLM que estaba expuesto a internet. Con el token de un propietario de la organización, saltó la protección de ramas y apuntó el workflow privado de release a un repositorio parecido, Berri-AI/litellm en lugar de BerriAI/litellm. La credencial de un segundo desarrollador cumplió con la puerta de aprobación. El pipeline oficial construyó, firmó y publicó entonces los artefactos del atacante.
La inyección real era elegantemente pequeña. Una línea extra en el pyproject.toml del sdist:
[tool.maturin]
python-source = "."
data = "cook" # <-- injected, copies cook/ into the wheel
python-packages = ["litellm_cli"] # legitimate, present upstream
Esa directiva data = "cook" le indica a maturin que empaquete un directorio cook/ en la ruta purelib del wheel, y pip lo instala obedientemente en site-packages. El sdist en sí omitía la carpeta, así que alguien revisando la release vería una sola línea críptica y nada más. La carga se escondía en los artefactos de build, no en el código fuente. Revisar el historial de git, lo que la mayoría tratamos como fuente de verdad, no te habría mostrado absolutamente nada.
Por qué los archivos .pth y sitecustomize.py son un sueño para el malware
Dos características del arranque de Python hicieron este ataque inusualmente potente, y, sinceramente, son funcionalidades en las que la mayoría de desarrolladores de Python nunca han pensado. Primero: cualquier archivo .pth en site-packages se procesa al iniciar el intérprete, y las líneas que empiezan por import se ejecutan. El cook.pth de 12 bytes contenía exactamente una línea, import cook, y eso bastó para ejecutar el robador en cada proceso de Python de la máquina. Segundo: Python importa automáticamente un módulo llamado sitecustomize al arrancar si lo encuentra en el path. Los atacantes colocaron el suyo, que lanzaba un daemon desacoplado que llamaba cada hora a un archivo de texto alojado en GitHub, descargaba el código que el atacante quisiera y lo ejecutaba con exec().
Esto es mucho más grave que un paquete malicioso típico con un setup.py envenenado o un import malicioso en __init__.py. Esos se ejecutan al instalar o importar el paquete. Este se ejecutaba al correr cualquier cosa en Python. Tu pytest en CI. El pip list que escribiste para ver qué había pasado. El formateador black de tu IDE. Cada invocación volvía a activar la trampa.
Vale la pena detenerse en lo que el robador recopilaba, porque la lista parece un mapa de la vida moderna de un desarrollador: credenciales de AWS, Azure y gcloud, kubeconfigs, tokens de la CLI de GitHub, claves SSH y GPG, almacenes de contraseñas del navegador, historiales de shell, secretos de CI de GitLab runners y Jenkins, credenciales de Terraform, variables de entorno de otros procesos leídas desde /proc/<pid>/environ, credenciales de metadatos de instancias de AWS y, con una ironía macabra, tokens de Claude Code, Cursor, Copilot y una docena más de herramientas de programación con IA, además de las claves de proveedores de LLM guardadas en la propia base de datos del proxy de LiteLLM. Un solo pip install y el atacante se queda con prácticamente toda tu identidad como ingeniero.
No hacía falta importar litellm. Con arrancar cualquier intérprete de Python en el host bastaba para ejecutar el robador.
Pinning de hashes y lockfiles: necesarios, pero no suficientes
Cualquier checklist de seguridad de la cadena de suministro empieza por los lockfiles y el pinning de hashes, y así debe ser. Si fijas hashes con pip install --require-hashes o usas una herramienta como uv o Poetry con hashes bloqueados, un wheel que cambie en silencio después de que lo hayas revisado será rechazado. Es una protección real contra toda una clase de ataques: manipulación del registro, envenenamiento de mirrors, reemplazo de artefactos.
Pero seamos honestos sobre lo que hace aquí: nada, al menos para quien actualizó primero. El pinning de hashes garantiza que el artefacto que instalas mañana sea idéntico byte a byte al que instalaste hoy. En este ataque, los wheels maliciosos eran los artefactos oficiales: de origen propio, construidos por el pipeline y con hashes coherentes. En cuanto alguien ejecutó su actualización rutinaria, resolvió la nueva versión y subió el lockfile actualizado, los hashes maliciosos quedaron fijados en el repo con un check de CI en verde. El pinning te protege del cambio, pero no dice nada sobre la intención.
Donde el pinning sí brilla en una historia como esta es en el radio de impacto. Los equipos con hashes fijados y una cadencia de actualizaciones deliberada no se suben automáticamente a cada release en cuanto sale. Si tu política es «actualizar dependencias en un PR semanal, con un periodo de espera de unos días para cualquier cosa que no sea un fix crítico», la retirada de PyPI a las 13:23 UTC ocurrió antes de que se abriera tu ventana de actualización. Un proceso aburrido vence a una herramienta ingeniosa más veces de las que nos gustaría admitir.
Así que mantén tus lockfiles. Genéralos con hashes. Pero deja de decirte que son una defensa contra un pipeline de mantenedor comprometido: son una defensa contra la deriva y la manipulación, y un freno ante releases malas. Nada más.

La atestación de procedencia habría detectado este caso
Aquí está el lado incómodo: este ataque concreto tenía una anomalía evidente y detectable, y las herramientas del ecosistema para detectarla existen hoy. Los wheels maliciosos no se construyeron desde el repositorio público, sino desde un repo parecido que creó el atacante. Cualquier paso de verificación que preguntara «¿este artefacto se construyó desde el commit y el repositorio que dice?» habría fallado estrepitosamente.
Eso es exactamente lo que ofrece la atestación de procedencia. PyPI soporta las atestaciones PEP 740: cuando un paquete se publica mediante Trusted Publishers desde GitHub Actions, el workflow puede generar una declaración firmada que vincula el artefacto con el repositorio, el workflow y el commit exactos que lo produjeron. Una comprobación en el lado del consumidor se vuelve mecánica: verificas la atestación y confirmas que el repo de origen es BerriAI/litellm, no Berri-AI/litellm. Los bundles de Sigstore, la procedencia SLSA y la verificación al estilo de pip-audit pertenecen todos a esta familia.
La brecha hoy es de adopción y de aplicación. Las atestaciones son opcionales para quien publica, y casi nadie las verifica en el momento de instalar, aunque existan. Esa es la parte que hay que cambiar, y encaja con algo que ya hemos dicho sobre la infraestructura de confianza, que solo funciona cuando alguien la comprueba de verdad: una firma que nadie verifica es decoración. Si tu organización tiene su propio índice de paquetes o proxy, y si tienes más de un puñado de ingenieros, deberías tenerlo, «rechazar artefactos sin procedencia verificable del origen esperado» es una política que puedes aplicar de forma centralizada, una vez, para todos los desarrolladores.
También hay una lección más sutil. El atacante necesitaba tres credenciales de desarrolladores: una para redirigir el workflow, otra para aprobar y otra para no levantar sospechas. Eso es un fallo sistémico de higiene de credenciales, no un error aislado. Los tokens de Trusted Publisher basados en OIDC y de vida corta en PyPI habrían reducido drásticamente la superficie de ataque: no hay ningún token de larga duración esperando en un archivo de log para ser robado. Si tu proyecto sigue publicando con tokens de API guardados como secretos de CI, esto es tu aviso.
Instalaciones en sandbox y el caso de los periodos de espera
Supongamos el peor escenario: una release maliciosa se cuela por tus buenas prácticas de lockfiles y por tus comprobaciones de procedencia, porque algún día pasará. ¿Qué queda? La contención. La pregunta deja de ser «cómo evitamos los paquetes malos» y pasa a ser «cuando un paquete malo se ejecuta, ¿a qué puede llegar?».
En CI, la respuesta es realmente alcanzable. Los builds deberían ejecutarse en entornos efímeros, sin credenciales de larga duración, con tokens acotados y con filtrado de tráfico saliente. El comportamiento del robador de LiteLLM es muy ruidoso en la red si estás escuchando: un POST a un dominio desconocido con una cabecera de autenticación personalizada, una descarga desde una URL raw de GitHub en un bucle cada hora y, la señal más clara de todas, un proceso de Python consultando 169.254.169.254, el servicio de metadatos de instancias de AWS. El código de aplicación casi nunca tiene una razón legítima para llamar directamente a IMDS; para eso está el SDK. Si alertas sobre procesos de Python que acceden a ese endpoint, detectarás toda una familia de ladrones de credenciales, no solo este. Ya hemos escrito sobre los puntos ciegos del logging de autenticación en la nube; este es uno de ellos, y cerrarlo es barato.
Las máquinas de los desarrolladores son más difíciles, y no voy a fingir lo contrario. Un portátil donde el mismo usuario ejecuta su editor, su navegador, su agente SSH y cualquier pip install arbitrario es un bufet libre para un robador. Las medidas pragmáticas: haz las instalaciones exploratorias en contenedores o devcontainers en lugar de en el entorno del host; mantén las instalaciones de paquetes dentro del devcontainer del proyecto para que un sitecustomize.py malicioso nunca llegue a los site-packages de tu host; y trata ~/.aws/credentials como un pasivo: SSO con tokens de vida corta (aws sso login, gcloud auth) significa que hay mucho menos en disco que robar. Nada de esto es exótico. Es solo fricción, y la fricción es precisamente el objetivo.
- Fija versiones y hashes en un lockfile, pero trata las actualizaciones como eventos deliberados, no como ruido de fondo.
- Establece una ventana de espera de 48 a 72 horas antes de que las actualizaciones de dependencias no críticas lleguen a los builds de producción. La mayoría de las releases maliciosas se retiran en pocas horas.
- Verifica las atestaciones de procedencia donde existan y prefiere paquetes publicados mediante Trusted Publishers con OIDC frente a tokens de API de larga duración.
- Ejecuta los builds de CI en entornos efímeros y sin credenciales, con filtrado de tráfico saliente; alerta sobre cualquier proceso de Python que acceda a los endpoints de metadatos de instancias.
- Instala y ejecuta código de terceros en contenedores en las máquinas de los desarrolladores, y sustituye los archivos estáticos de credenciales de cloud por sesiones SSO de vida corta.
- Audita tu entorno en busca de los IoC si descargaste algo en esa ventana:
cook.pth,cook.pyo unsitecustomize.pysuelto en site-packages, y/tmp/.pc_once_*o/tmp/remote_code_loader.locken disco.
Lo que este ataque dice sobre dónde está el riesgo real
Fíjate en lo que el atacante ni siquiera intentó. No explotó una vulnerabilidad de código. No necesitó un zero-day. Fue a por el objetivo más blando del software moderno: las personas y las máquinas que tienen credenciales permanentes. Tres tokens de la CLI de GitHub, aparentemente extraídos de los logs de un despliegue interno expuesto a internet, bastaron para tomar el control de un pipeline de release que sirve a millones de descargas. Es el mismo patrón que hemos visto repetirse en la industria: secretos que se dispersan en logs, agentes y archivos de configuración, cada uno una llave maestra. Si ejecutas una instancia interna de cualquier cosa y la expones a internet, sus logs ya forman parte de tu modelo de amenazas. Esa frase debería incomodar a más de uno.
También hay un problema de velocidad que conviene nombrar. LiteLLM es un proyecto que avanza muy rápido, con decenas de miles de commits y releases frecuentes, y está en el centro del stack de herramientas de IA, arrastrado por frameworks y SDKs de los que muchos equipos ni siquiera saben que dependen. El alcance transitivo significa que no eliges tu exposición eligiendo tus dependencias directas. Un SBOM deja de ser papeleo de cumplimiento en este mundo: es lo que te permite responder «¿nos afecta?» en minutos en lugar de en días. Las herramientas que generan una lista de materiales a partir de evidencias reales del build, y no extrayendo datos del manifiesto, detectarán las dependencias que tu requirements.txt no menciona.

Mi opinión: la respuesta de la industria a los ataques a la cadena de suministro ha puesto demasiado peso en el teatro de la prevención, escáneres que marcan versiones conocidas como malas una vez que el daño está hecho, y muy poco en los controles poco vistosos que habrían cambiado esta historia. Credenciales de publicación de vida corta. Verificación de procedencia en el proxy. Periodos de espera. Alertas de tráfico saliente hacia endpoints de metadatos. Instalaciones en sandbox. Ninguno de estos es un problema de investigación. Son decisiones de configuración, disponibles hoy, y la mayoría de los equipos no ha implementado ninguna. Los atacantes de LiteLLM necesitaron una mala línea en un archivo de build y tres tokens robados. Las defensas de arriba les habrían obligado a superar cuatro capas independientes en lugar de una. Eso es lo que significa realmente la defensa en profundidad: no más escáneres, sino más formas independientes de que algo falle. Es la misma filosofía que hay detrás de diseñar sistemas que resisten las catástrofes, en lugar de sistemas que simplemente esperan que todo salga bien.
Alguien ya está planeando la próxima release comprometida. No vas a impedir que se publique. Pero sí puedes decidir si se ejecuta con tus claves de AWS al alcance, tu agente SSH desbloqueado y una vía de red clara hacia un dominio controlado por el atacante, o si muere en un contenedor efímero sin credenciales y detrás de un firewall. Esa decisión se toma ahora, en la configuración de tu CI y en tu devcontainer, no el día en que se publica el informe de incidente.


