A las 10:08:29 UTC del 21 de octubre, la protección de rama de GitHub hizo exactamente lo que estaba puesta para hacer: rechazar una modificación no autorizada del flujo de publicación de LiteLLM, porque no había pasado la revisión ni las comprobaciones obligatorias. A las 10:08:34 —cinco segundos más tarde— una credencial de una cuenta de desarrollador con privilegio pisó esa protección y aplicó el cambio. Hora y media después había siete paquetes envenenados en PyPI. No falló ningún control. Se cumplieron los tres.
LiteLLM es una pasarela que se pone delante de varios modelos de lenguaje para hablar con todos con la misma llamada. Si en tu empresa alguien ha montado algo con IA en el último año, merece la pena que mires si está por ahí abajo, aunque no haya aparecido en ninguna conversación de dirección. El 21 de octubre apareció en PyPI y en Docker una versión 1.107.0 que no había hecho el proyecto, con un ladrón de credenciales dentro. Y el equipo publicó después algo que casi nadie publica: la cronología con segundos de lo que pasó dentro de su propia tubería de publicación.
Ese documento es lo interesante, y no por el paquete. Es un plano, minuto a minuto, de cómo se atraviesa una cadena de publicación que tenía puestos los controles que recomienda todo el mundo. Nosotros no lo leímos pensando en Python; lo leímos pensando en el GitLab, el Jenkins o el Azure DevOps de cualquiera de las empresas de software a las que les llevamos la infraestructura.
Los cinco segundos, leídos del informe oficial
Esta es la secuencia tal y como la publica el propio proyecto, en horario UTC del 21 de octubre de 2026. No hay nada aquí que no esté en su aviso.
Léelo otra vez fijándote solo en los tres momentos en negrita. Son tres credenciales distintas de tres personas distintas, usadas en tres puntos distintos de la cadena, y cada una hace algo que está perfectamente permitido para quien la tiene: saltarse la protección de rama, aprobar un despliegue a producción, limpiar registros de ejecución. El atacante no rompió nada. Rellenó formularios.
Tres controles, una sola pregunta
Aquí está lo que nos llevamos, y es incómodo porque se parece mucho a lo que tenemos montado casi todos. Esos tres controles no son tres capas: son la misma capa repetida tres veces. La protección de rama pregunta «¿tienes credencial con privilegio para anularme?». La puerta de aprobación pregunta «¿hay una segunda credencial de desarrollador que diga que sí?». Y el registro de auditoría da por hecho que quien lo escribe no es quien ataca. Las tres preguntas son la misma pregunta: ¿eres quien dices ser? Y la respuesta era que sí, tres veces, porque las tres identidades estaban robadas.
Y hay un cuarto control que tampoco aparece en la cronología, porque no llegó a dispararse. El documento de la cadena de ataque lo dice de pasada, con una frase que vale una auditoría entera: «los tokens de la CLI de GitHub son post-2FA y no están sujetos a los flujos de reautenticación de segundo factor de "superusuario" de GitHub». Es decir: el segundo factor, que es lo que la mayoría de nosotros ponemos encima de la mesa cuando nos preguntan si la cadena está protegida, estaba activo y no tenía nada que decir aquí. Ya se había cumplido el día que alguien se autenticó en su propio portátil.
Tres puertas seguidas no son defensa en profundidad si las tres llevan la misma cerradura. Lo que hace falta son puertas que pregunten cosas distintas, que es exactamente de lo que va el Zero Trust cuando se aplica de verdad y no como etiqueta comercial. La pregunta que faltaba en toda la cadena, y que ningún control hizo en ningún momento, es la más simple de todas: ¿este cambio es el que queríamos hacer? Nadie comparó el flujo de publicación que se ejecutó contra el flujo de publicación aprobado. Si lo hubieran hecho, la línea que apuntaba a un repositorio con un guión de más en el nombre de la organización habría cantado sola.
El engaño fue el mismo truco dos veces, y las dos veces a un carácter de distancia. El flujo compilaba desde una organización de GitHub con un nombre casi igual al de la real. Y los datos robados, según el análisis técnico de StepSecurity, salían hacia un dominio que tampoco es el del proyecto: termina en .dev cuando el de verdad termina en .ai. Ninguna de las dos cosas la detecta un control que solo mira identidades.
De dónde salieron las credenciales (la parte que nos tocó de cerca)
El aviso explica el origen con una frase que conviene leer despacio: «Claude Code incrusta el token OAuth de la CLI de GitHub del usuario en un cuerpo de respuesta, y en situaciones en las que los cuerpos de respuesta se registran, esos tokens pueden aparecer en los registros de LiteLLM». El atacante entró en un despliegue interno de LiteLLM expuesto a Internet y sacó de ahí los tokens: primero el del propietario de la organización y después, el mismo día, los de dos ingenieros con permisos de mantenedor.
Esa es la frase que deberías llevarte a tu propia casa, porque no tiene nada que ver con LiteLLM. Si has puesto una pasarela delante de los modelos —y casi todo el mundo que hace algo serio con IA acaba poniendo una, por control de coste o por no repartir claves— esa pasarela registra las peticiones y las respuestas. Es su trabajo. El día que una herramienta de las que hablan con ella mete una credencial dentro de una respuesta, tu tabla de registros se convierte en un almacén de secretos que no declaraste, que nadie rota, que probablemente no está cifrado en reposo y al que tiene acceso mucha más gente de la que tiene acceso a tu gestor de contraseñas. Ya escribimos sobre el otro lado de este mismo problema cuando vimos que el agente de IA no sale en tu registro: sale quien le prestó el token.
Dos días después del incidente, el 23 de octubre, el proyecto publicó un segundo documento con la raíz de todo, y es más dura que la anterior. El 27 de mayo de 2026 se subió a un repositorio público —LiteLLM-Labs/lite-harness— un fichero de configuración que contenía la clave maestra de un despliegue de su plataforma de agentes. Lo cuentan ellos mismos: «el error se identificó rápidamente, se reescribió el commit por encima, e identificamos las claves expuestas como pendientes de revocación. Sin embargo, la revocación de la clave maestra no llegó a producirse». El 10 de octubre, 136 días después, el atacante usó esa clave para entrar en la plataforma y sacar de ahí el token. De ese token el propio documento dice dos cosas más: que «no estaba asociado a una cuenta de servicio y no tenía el alcance recortado», y que era el de la cuenta del propietario de la organización de GitHub.
El detalle que cambia el triaje: no hace falta usar la librería
Casi todo el mundo, al enterarse, hizo la pregunta equivocada: «¿alguna de nuestras aplicaciones usa esto?». No es esa. Según el desmontaje técnico de StepSecurity, el paquete envenenado metía en site-packages tres ficheros, y uno de ellos es el que cambia el problema de sitio: cook.pth, de doce bytes, cuyo contenido entero es import cook.
Un fichero .pth es un mecanismo viejo y legítimo de Python: el módulo site, al arrancar el intérprete, ejecuta cualquier línea de un .pth que empiece por import. Antes de tu código. En cada arranque del intérprete. Da igual si lanzas la aplicación, si ejecutas pip, si corres los tests, si haces un python -c de tres líneas para comprobar una cosa. Nadie tiene que importar litellm jamás. Basta con que el paquete esté instalado en ese entorno virtual.
Eso mueve el radio de alcance de las aplicaciones de IA a cualquier máquina donde alguien hiciera un pip install para probar: el portátil del desarrollador que lo miró un viernes, el contenedor efímero de integración continua, la máquina de alguien en prácticas, el entorno virtual de la prueba de concepto que se quedó encendida. Y lo que el ladrón recoge, siempre según StepSecurity, no es poco: contraseñas y cookies del navegador, claves de ~/.ssh, claves GPG, ficheros .pem, credenciales de AWS, Azure y GCP, configuraciones de kubectl y Docker, tokens de la CLI de GitHub, credenciales de herramientas de IA, la tabla de credenciales de la propia pasarela, y —esto es lo que menos se comenta— el entorno de todos los procesos en ejecución leído de /proc. Es decir, también las variables de entorno de servicios que no tienen nada que ver con Python.
El tercer fichero, sitecustomize.py, deja un proceso de fondo que cada hora se descarga un trozo de código de un servidor y lo ejecuta. El servidor no está escrito a fuego: lo lee de un fichero de texto alojado en un repositorio público, para poder cambiarlo cuando quiera. Eso ya no es un paquete malicioso; es una puerta abierta con mantenimiento incluido.
La ventana real: dos horas y cuarenta minutos
La pregunta operativa es más concreta que «usamos la librería» y que «la usábamos en esa época». qué se descargó tu infraestructura entre las 11:39:51 y las 14:20 UTC del 21 de octubre. En PyPI los paquetes estuvieron vivos desde las 11:39:51 hasta las 13:23. En Docker Hub y GHCR, las etiquetas v1.107.0, latest, main-latest y main-stable apuntaron a la imagen envenenada desde las 11:46:56 hasta las 14:20. En hora peninsular eso es de las 13:39 a las 16:20: media tarde de un miércoles laborable.
Dos horas y cuarenta minutos de ventana total. Si tus compilaciones corren de madrugada, lo más probable es que te libraras por horario. Si compilas en cada fusión a la rama principal, esa tarde compilaste varias veces. Esa diferencia —que no tiene nada que ver con la seguridad y todo con a qué hora corre tu robot— decidió quién se llevó el paquete y quién no. Nos parece un buen recordatorio de lo poco determinista que es esto: no te salvó tu política de dependencias, te salvó el cron.
Lo que NO es la lección
La reacción automática de medio sector fue «hay que fijar las versiones». Fijar versiones está bien y hay que hacerlo, pero aquí hace falta decirlo con más precisión, porque dicho así es falso consuelo: 1.107.0 era una versión fijable. Cualquiera que tuviera un robot de actualización de dependencias configurado para abrir y fusionar automáticamente las subidas de versión menor se trajo el paquete envenenado a través de su fichero de bloqueo, no a pesar de él. Tu versión fijada vale exactamente lo que valga el proceso que tiene permiso para cambiarla.
La segunda reacción fue «nosotros usamos contenedores, no instalamos paquetes a mano». También falla: las etiquetas latest y main-stable son referencias móviles, es decir, pines que no son pines. Quien reconstruyó una imagen esa tarde apuntando a main-stable se trajo el contenido malicioso sin cambiar una sola línea de su fichero de construcción. Lo que de verdad ancla una imagen es el digest, no la etiqueta, y casi nadie lo usa porque obliga a tocar el fichero cada vez que quieres actualizar. Ese es el precio real, y hay que decidirlo sabiendo cuánto cuesta.
Y ahora la parte que nos cuesta dinero decir: a una empresa de veinte personas no le vamos a recomendar que se monte su propio espejo de PyPI. Es caro de mantener, se queda desactualizado, y el día que falla lo hace en silencio. Lo proporcionado para ese tamaño son tres cosas aburridas: una cuarentena de 24 o 72 horas antes de adoptar cualquier versión nueva (que en este caso te habría salvado del paquete de PyPI, que vivió menos de dos horas; de las etiquetas móviles de Docker, no), el inventario de qué paquetes y qué imágenes entran de verdad en tus compilaciones, y quitarle al robot de dependencias el permiso de fusionar solo. Las tres son de un día de trabajo, no de un proyecto. El espejo propio es para quien compila cien veces al día, y entonces sí.
Siete comprobaciones, con los comandos
Esto es lo que haríamos nosotros, en este orden, en un parque donde haya Python en algún sitio. Los indicadores son los publicados por StepSecurity y por el propio proyecto. Antes de empezar, un aviso que tiene consecuencias: en una máquina sospechosa no arranques con pip install ni con pip uninstall. Cada invocación de Python dispara la carga. Se borra desde la shell.
- Busca el fichero, no la aplicación.
find / -name cook.pth -path "*site-packages*" 2>/dev/null, y pásalo por todas las máquinas con Python, no solo por los servidores de la aplicación: portátiles, corredores de integración continua, máquinas de laboratorio. El fichero son doce bytes; el inventario es lo caro. - Mira si el proceso de fondo sigue vivo.
ls -la /tmp/remote_code_loader.lockylsof /tmp/remote_code_loader.lockpara encontrar quién lo tiene cogido, y matarlo. El ladrón deja además un marcador,/tmp/.pc_once_72129118c59f5636c0bb, cuando ha conseguido enviar los datos; que no esté no demuestra nada. - Si aparece, no lo limpies: rehaz el entorno. La recomendación de StepSecurity es explícita, recrear el entorno virtual o la imagen desde cero es mejor que limpiarla in situ. Si por lo que sea tienes que limpiar, van los tres ficheros y también el
__pycache__correspondiente. - Rastrea la versión en tus ficheros y en tu espejo interno.
grep -rniE "litellm[=<>~! ]*1\.107\.0" . --include='*.txt' --include='*.toml' --include='*.lock' --include='Pipfile*'sobre los repositorios, incluidos los ficheros de bloqueo: es por ahí por donde entró. Y después mira si tu caché o tu espejo de paquetes guardó una copia, porque las retiradas de PyPI no vacían tu caché. - Compara digests de imágenes, no etiquetas.
docker images --digests | grep -E 'berriai/litellm'y cruza con los digests publicados como comprometidos. Si reconstruiste algo esa tarde apuntando alatestomain-stable, asume que entró. - Si hubo exposición, rota como si se hubiera llevado todo. El propio aviso lo dice sin rodeos: el rol de la instancia o de la tarea, todas las claves de proveedor, la contraseña de la base de datos, y también la clave maestra y las claves virtuales de la propia pasarela, que es por donde empezó todo. Añade las claves SSH y GPG de los usuarios de esas máquinas y las credenciales guardadas en el navegador. Rotar medio parque deja la sensación de haber actuado: apunta qué queda sin rotar y por qué, con fecha.
- Mira hacia atrás en el registro de tu nube. El aviso recomienda revisar CloudTrail; vale el equivalente que uses. Lo que buscas es uso de esas claves después de las 11:39 UTC del 21 de octubre desde sitios que no son los tuyos. Y comprueba cuántos días de registro guardas de verdad, porque esa ventana es la que decide si esta comprobación se puede hacer o no.
Y una octava, que no es técnica y es la que de verdad cambia algo: siéntate con quien lleva vuestra tubería de publicación y preguntadle tres cosas. Quién puede saltarse la protección de rama y si queda rastro fuera del sistema que el mismo privilegio puede borrar. Si la puerta de aprobación la satisface una persona o una credencial, porque no es lo mismo y casi siempre está montado como lo segundo. Y si alguien compara lo que se ejecuta con lo que se aprobó. Si las tres respuestas son incómodas, ya tienes el trabajo de la semana que viene. El patrón no es nuevo: ya lo vimos en el lado de npm cuando contamos lo de los 444 paquetes de npm y nada que parchear, y en el servidor de compilación cuando resultó que el servidor de integración continua guarda las llaves de producción.
Lo que nos llevamos
Hay que decir una cosa a favor de este equipo: publicaron la cronología con segundos, incluida la parte en la que su propia protección fue anulada y sus propios registros fueron borrados. Eso no se hace por gusto, y casi nadie lo hace. Buena parte de lo que sabemos de este incidente lo sabemos porque ellos lo escribieron.
Y lo que queda cuando apartas Python, la IA y el paquete concreto es una frase que vale para tu hipervisor, tu copia de seguridad y tu consola de gestión igual que para tu tubería de publicación: un control que solo verifica identidad vale cero el día que la identidad es lo que han robado. No porque esté mal puesto, sino porque está contestando a una pregunta que el atacante ya sabe responder. Los cinco segundos del 21 de octubre no son una anécdota curiosa: son el tiempo que tarda un control bien configurado en volverse decorativo.
Nosotros esto lo miramos como parte de la consultoría: inventario de lo que entra en tus compilaciones, quién tiene qué privilegio sobre la cadena, y qué queda registrado fuera del alcance de ese privilegio. Ese último punto es el que más veces hemos visto en blanco.
Y un apunte de detección, porque aquí no hay fichero raro que encontrar: lo que delata esa máquina es un proceso que se despierta cada hora, en un equipo donde nadie programó nada. Eso lo coge una herramienta que mira comportamiento, es decir EDR/MDR, y no un antivirus de firmas.
Una última honestidad: no hemos visto este paquete en ningún entorno de los que operamos, y no damos cifra de cuántas empresas se lo llevaron porque lo que se cuenta en estos casos son descargas, que no es lo mismo que empresas. Todo lo que afirmamos arriba sale de los dos documentos que publicó el propio proyecto y del desmontaje técnico de StepSecurity; donde ellos escriben «creemos», nosotros no hemos puesto «fue».
Fuentes (verificadas): cronología UTC, anulación de la protección de rama, credenciales implicadas, artefactos afectados y pasos de remediación — aviso oficial del proyecto, docs.litellm.ai; análisis técnico de los ficheros cook.pth, cook.py y sitecustomize.py, alcance del robo, digests e indicadores y comandos de detección — StepSecurity; hilo público del incidente — BerriAI/litellm #47826. Origen de la cadena: el commit del 27 de mayo de 2026 con la clave maestra, la revocación que no se produjo, el token sin alcance recortado del propietario de la organización y el apunte sobre los tokens de la CLI de GitHub y el segundo factor — registro canónico de la cadena de ataque, publicado por el proyecto el 23-10-2026.
¿Sabes quién puede saltarse los controles de tu cadena de publicación?
Hacemos ese inventario: qué entra en tus compilaciones, quién tiene privilegio sobre la cadena y qué queda registrado fuera de su alcance. Sale en una tarde y suele descubrir dos o tres cosas que nadie sabía que estaban así.
Hablar con everyWAN