LiteLLM 1.107.0: la master key que nadie revocó y el pipeline que envenenó PyPI
Una master key de la plataforma de agentes de LiteLLM se publicó por error en un repositorio de GitHub el 27 de mayo de 2026. 136 días después, el atacante entró, robó tokens de GitHub de propietario y envenenó el pipeline de release: PyPI sirvió un robador de credenciales durante 103 minutos.
El titular fácil sería «un atacante envenenó PyPI». Pero la historia que los mantenedores de LiteLLM publicaron el 23 de octubre es bastante más incómoda: no hubo un fallo en el producto, ni un cero día, ni una intrusión espectacular. Hubo un fichero JSON con una clave, subido por error a un repositorio público, y una lista de tareas pendientes que nunca se completaron.
El 27 de mayo de 2026 a las 18:53 UTC, alguien hizo commit de un fichero llamado `opencode.json` en el repositorio público `LiteLLM-Labs/lite-harness`. Contenía, entre otras cosas, la master key de un despliegue de LiteLLM Agent Platform, la herramienta interna que usan para orquestar agentes que abren pull requests en GitHub. El error se detectó enseguida: se sobrescribió el commit con un force-push y se identificó que las claves expuestas necesitaban revocación.
La master key no se revocó. Nunca se revocó.
Ciento treinta y seis días después, el 10 de octubre, alguien la usó desde la IP 69.48.229.143 para llamar a la API de la plataforma y leer la configuración de todos los agentes gestionados. Entre esas configuraciones estaba el token OAuth de la GitHub CLI de uno de los dos propietarios de la organización de LiteLLM en GitHub. Desde ese momento, el atacante tenía acceso a nivel de propietario sobre todos sus repositorios.
Once días más tarde, el 21 de octubre, un `pip install litellm` normal, en un entorno que no fijaba versión, descargaba un paquete con un robador de credenciales y un cargador de ejecución remota. PyPI estuvo sirviéndolo 103 minutos; Docker Hub, 153. En la práctica: todos los que instalaron LiteLLM sin fijar versión entre las 11:39 y las 13:23 UTC de ese día.
El valor de este caso no está en el malware. Está en que la cadena estaba formada por decisiones que, por separado, parecían razonables: detectar una clave expuesta y anotarla para revocarla, guardarla en la configuración de un agente en vez de en un gestor de secretos, y relajar un control de aprobación durante una semana para publicar más rápido. Cualquiera de las tres, por sí sola, rompe la cadena. El atacante solo necesitó que las tres estuvieran presentes a la vez.
LiteLLM en dos minutos: por qué un gateway de IA es una diana de primer orden
LiteLLM es, en la práctica, la capa por la que buena parte del sector pasa cuando una aplicación necesita hablar con un modelo. Se despliega como un proxy: recibe las peticiones de tus servicios, añade las claves del proveedor que configuraste, habla con OpenAI, Anthropic, Bedrock, Vertex, Azure, Ollama o el que sea, y devuelve la respuesta. Por el camino añade cuotas, reintentos, registro de costes, caché, balanceo de carga y, en las ediciones Enterprise, una interfaz de administración con usuarios y políticas.
Eso significa que un gateway de IA concentra, por diseño, todos los secretos que la organización usa para hablar con los modelos: claves de proveedor, cadenas de conexión a la base de datos, identidades de carga de trabajo, configuración de enrutamiento y datos de política por tenant. Un despliegue bien protegido de un gateway es, funcionalmente, un cofre con las llaves de todos los modelos.
Y el paquete es, además, una dependencia de Python normal y corriente. La misma wheel se instala en estaciones de trabajo, en agentes de CI, en entornos de test y en contenedores. Quien la instala hereda lo que trae dentro. En una organización con cien personas usando LiteLLM, el radio de exposición no es «el gateway»: es «todo el mundo que lo tiene instalado».
Hay un segundo plano en el mismo producto que resulta central en esta historia: LiteLLM Agent Platform, un servicio en alpha para que agentes autónomos en la nube abran pull requests en GitHub por su cuenta. Estaba configurado con un token de autenticación de GitHub, ni una cuenta de servicio ni con scopes, porque necesitaba poder escribir en repositorios. Esa pieza aparece en el centro exacto de la cadena de ataque.
Día 1: un `opencode.json` en un repositorio público
El 27 de mayo de 2026, a las 18:53:14 UTC, se hizo commit del fichero `harnesses/opencode/opencode.json` en `LiteLLM-Labs/lite-harness`, un repositorio público de arneses de pruebas. El fichero contenía la master key de un despliegue de Agent Platform.
La respuesta fue rápida y, en apariencia, correcta: se sobrescribió el commit con un force-push y se identificó que las claves expuestas necesitaban revocación. Lo que falta en ese relato es el último paso. Una clave que se ha publicado en un repositorio público ya no es una clave: es una credencial pública, con independencia de que nadie la haya usado todavía. Detectar una exposición no es revocarla.
Entre el 27 de mayo y el 10 de octubre pasaron 136 días. Durante todo ese tiempo, la clave estuvo ahí, en un repositorio accesible sin autenticación, indexada por los buscadores y por los propios rastreadores de GitHub. Nadie la usó, hasta que alguien la usó.
136 días después: la master key devuelve un token de GitHub
El 10 de octubre, entre las 21:13 y las 21:28 UTC, el atacante hizo una serie de peticiones autenticadas contra la API de Agent Platform desde 69.48.229.143. A las 21:27:10, una petición a `GET /api/v1/managed_agents/agents?limit=100` devolvió la configuración de todos los agentes gestionados.
Esa respuesta incluía el token OAuth de la GitHub CLI del propietario de la organización. No hubo que vulnerar nada: la API respondía fielmente a lo que pedía una clave con permisos de administración. El atacante pasó de tener una master key de un servicio experimental a tener acceso de propietario sobre todos los repositorios de la organización. Una API que devuelve la configuración de todos los agentes a cualquiera que pregunte es, por encima de cualquier consideración de cifrado, un fallo de control de acceso.
Y aquí aparece un matiz de las credenciales de la GitHub CLI que merece atención: son tokens post-2FA. GitHub los emite después de superar el segundo factor y no están sujetos a los flujos de reautenticación de superusuario que la plataforma exige para algunas acciones sensibles en organizaciones y repositorios. Es decir, la protección que en un flujo normal exigiría una segunda confirmación humana no estaba ahí. Para el atacante, el segundo factor ya estaba resuelto en mayo, el día que alguien lo usó para trabajar.
Y no era un token de servicio ni estaba acotado a un repositorio: estaba asociado a la cuenta de una persona.
La CI como canal de exfiltración
Con el token del propietario, el atacante hizo lo más elegante y lo más difícil de detectar: usar los propios pipelines de la víctima para exfiltrar sus propios secretos.
A las 22:11:45 del 10 de octubre subió el commit b69bc36 a una rama llamada `ci-tmp-a351cc` en `BerriAI/litellm`. El workflow de esa rama se ejecutó y publicó seis secretos del repositorio a un endpoint `webhook.site` controlado por el atacante. Uno era `GH_TOKEN`, que contenía el token OAuth de la GitHub CLI de un segundo mantenedor con permisos. A las 22:14:14, el token del propietario borraba el registro de esa ejecución.
Veinticuatro segundos después, la misma técnica con `RELEASE_PLEASE_TOKEN` en `BerriAI/litellm-auto`, que contenía el token de un tercer mantenedor. El 12 de octubre hubo una segunda ronda sobre varios repositorios privados: secretos de repositorio y algunas claves de proveedores de inferencia usadas en los tests de CI. Esos últimos no participaron en el camino hacia los artefactos envenenados, pero también quedaron comprometidos.
La lección de este tramo es que un token con permiso de escritura sobre tu repositorio ya es, por sí solo, una credencial de exfiltración de credenciales: puede escribir un workflow que vacíe los secretos de CI. No hace falta entrar en la infraestructura de CI para llevarse sus secretos; basta con que el CI los use por ti.
Once días después: el pipeline de release apunta a un fork
El 21 de octubre el atacante ya tenía tres identidades de GitHub con distinto nivel de privilegio. Las releases de LiteLLM se construyen desde un repositorio privado, `BerriAI/project-releaser`, con un proceso cuidado: se cualifica un release candidate, se generan los artefactos en un workflow que corre en ese repositorio privado y se publica con GitHub Trusted Publishers, que exige que la publicación se dispare desde un workflow y una cuenta concretos. Además, las imágenes Docker se firmaban y sus tags se habían hecho inmutables tras el incidente de marzo.
El 21 de octubre a las 10:08:29, un push del commit 9cf97e9, titulado «ci: release from a fork source repository», a `main` de `project-releaser` fue rechazado: la protección de ramas exigía pull request, dos checks en verde y aprobación del codeowner. Cinco segundos después, a las 10:08:34, el mismo push se aplicó usando el token del propietario, desactivando la protección de ramas y haciendo force-push. Ese commit hacía una sola cosa: permitir que los workflows de release construyeran el paquete desde un repositorio de origen distinto.
A las 10:46:39 se lanzaba el workflow de staging con el origen apuntando a un repositorio gemelo: `Berri-AI/litellm` en lugar de `BerriAI/litellm`. Un guion de diferencia. El staging terminó a las 11:08:09 y a las 11:08:53 arrancaba la pipeline de producción. A las 11:11:18, el token del segundo mantenedor aprobaba la puerta de entorno de producción.
Tres detalles anatómicos de este tramo valen por sí solos.
- La puerta de producción exigía normalmente que la ejecutara una cuenta bloqueada a un portátil físico situado dentro de la oficina. Esa restricción se había relajado esa misma semana para poder publicar backports de forma más frecuente, de modo que bastaba con que dos ingenieros pudieran disparar y aprobar. Una decisión operativa de una semana, tomada sin mala intención, y que quitó el último anclaje humano del proceso.
- El commit `20535d8f` del fork se abre en `github.com/BerriAI/litellm/commit/20535d8f`, porque GitHub sirve los commits de un fork a través del repositorio del que bifurcó. Una revisión de código que mire «el repositorio correcto» no ve nada raro, porque la URL que se abre es la del repositorio correcto.
- El atacante podía haber relajado la restricción de la puerta de producción él mismo, con el token del propietario. No lo hizo: usó identidades distintas para disparar y para aprobar, para que el registro de auditoría pareciera un flujo normal de aprobaciones y no una toma de control evidente. No optimizó por velocidad, optimizó por ruido bajo.
Qué contenía el paquete: por qué un `.pth` es peor que una puerta trasera
El paquete publicado en PyPI a las 11:39:51 no era una versión de LiteLLM con código troyanizado en una función. Era un artefacto perfectamente formado, con wheels para siete plataformas, que además de la librería contenía tres ficheros que no tenían nada que ver con ella: `cook.pth`, `cook.py` y `sitecustomize.py`. Se empaquetaban bajo `litellm-1.107.0.data/purelib/` y, al instalarse, pip los colocaba en la raíz de site-packages, no dentro del directorio del paquete.
Por qué importa la ubicación: `cook.pth` contiene una única línea ejecutable, `import cook`. Python procesa los ficheros `.pth` durante la inicialización del intérprete, antes de que se ejecute una sola línea de tu programa. Es decir, bastaba con arrancar Python en un entorno con el paquete instalado para ejecutar el payload. No hacía falta importar LiteLLM, ni usar el gateway, ni tener ninguna aplicación en marcha. También lo disparaba un `pip install` de cualquier cosa. En una máquina de Windows, donde `sitecustomize.py` no puede bifurcarse y falla en silencio, `cook.pth` seguía funcionando.
El sdist traía además un `pyproject.toml` con una línea que no estaba en el árbol etiquetado oficial (`66c8f5b1`): `data = "cook"` bajo `[tool.maturin]`. maturin 1.9.4 construyó los wheels a las 11:21 UTC, con las mismas marcas de tiempo y metadatos zip que los ficheros legítimos, lo que significa que el árbol de build se modificó antes de compilar y no se reempaquetó a mano después. El tag oficial `v1.107.0` en GitHub está limpio: el código malicioso vivía en otro repositorio.
Y la lección de fondo es incómoda: la firma del artefacto no protege el contenido. El paquete salió firmado con Trusted Publishing y con la cadena de confianza de GitHub intacta, desde un workflow legítimo. Lo que se firmaba era el binario que ese workflow había compilado; cambiar el origen del build es cambiar el contenido firmado. La confianza estaba anclada en el repositorio de origen del workflow, no en el artefacto final.
El recolector: qué credenciales se llevan y a dónde
El fichero `cook.py` es un stealer comprimido con zlib y codificado en base64 que, al descomprimirse, se identifica a sí mismo como `PRIVCOLLECT`. Se ejecuta en un hilo daemon y comprueba la variable de entorno `PRIVCOLLECT_ACTIVE` antes de arrancar, para no recolectar dos veces en la misma cadena de procesos.
La lista de objetivos es amplia y, para cualquiera que tenga una máquina de desarrollo, demoledora:
- Contraseñas guardadas, cookies y datos de navegación de navegadores Chromium; `logins.json` y `key4.db` de Firefox.
- Claves SSH, historial de shell, de bases de datos y de REPLs, ficheros `.env`, `.pem`, `.key` y `*.pfx`.
- Credenciales de AWS, Azure CLI, Google Cloud, Kubernetes (incluido el token de la cuenta de servicio), Docker, `gh`, rclone y `.netrc`.
- Configuración de Maven, Gradle, Cargo, npm, gem y Composer, además de tokens de GitLab Runner, Jenkins, Vault, Terraform y Packer.
- Credenciales de herramientas de IA: Claude, Codex, Gemini, Qwen, Cursor, Windsurf, Kiro, Copilot y el MCP de VS Code, además de tokens de Hugging Face.
- Las variables de entorno de todos los procesos legibles vía `/proc/*/environ` en Linux, que es donde a menudo viven secretos que nadie recuerda haber puesto.
- Las claves de proveedor almacenadas en la base de datos del proxy: si está definido `DATABASE_URL`, el payload arranca Prisma y vuelca las tablas `LiteLLM_CredentialsTable` y `LiteLLM_ProxyModelTable` con un límite de 500 filas por consulta. Es decir, todas las claves de todos los proveedores que el gateway tenía guardadas.
- Credenciales de metadatos de nube: IMDSv2 de AWS y credenciales de tarea de ECS, validadas con `GetCallerIdentity` de STS, tokens de cuenta de servicio de GCP y el IMDS de Azure.
La exfiltración está diseñada con un nivel de detalle poco común. Cada ejecución genera una clave AES de 32 bytes y un vector de inicialización de 16, cifra el documento con AES-256-CBC y envuelve la clave AES con una clave pública RSA de 4096 bits usando OAEP, probando SHA-256 y luego SHA-1. Después monta un tar.gz con tres miembros: `data.bin` (vector y datos cifrados), `key.bin` (clave envuelta) y `hdr.json` (metadatos en claro), y lo sube por HTTP POST a `http://www.litellm.dev/healthcheck` con `Content-Type: application/octet-stream`, un `User-Agent` que imita a Internet Explorer 7 y una cabecera `X-SHA256` con el checksum del cuerpo. Solo un HTTP 200 cuenta como éxito, y el payload reintenta hasta conseguirlo.
Dos detalles sobre ese destino. El primero: `litellm.dev` no es el dominio del proyecto, que es `litellm.ai`; el dominio `.dev` se registró el 13 de octubre de 2026, ocho días antes del ataque, con nameservers de Cloudflare. El segundo: el marcador de éxito, el fichero `/tmp/.pc_once_72129118c59f5636c0bb`, solo se escribe tras un POST exitoso, así que su presencia confirma la exfiltración, pero su ausencia no la descarta, porque los contenedores y las limpiezas de `/tmp` se llevan el rastro.
El segundo stage que sigue dormido
El tercer fichero, `sitecustomize.py`, es otro mecanismo de arranque y tiene una ambition distinta: no roba, ejecuta. En Unix se bifurca con `setsid`, cierra los descriptores estándar hacia `/dev/null`, toma un cerrojo exclusivo sobre `/tmp/remote_code_loader.lock` para que solo haya una instancia por host y entra en un ciclo horario.
Cada ciclo lee un fichero `domain.txt` alojado en GitHub que hace de puntero mutable hacia un servidor del atacante. Si el fichero contiene un dominio, el loader hace `GET http://<dominio>/codepy` y ejecuta lo que devuelva con `exec()`, sin comprobar ninguna firma. Cuando los investigadores lo miraron, `domain.txt` estaba vacío desde el 20 de octubre, así que la vía estaba dormida y no se confirmó ninguna recuperación de código remoto. Pero el puntero es un fichero en un repositorio de GitHub: reactivarlo cuesta un commit.
Un payload que puede permanecer dormido semanas y reactivarse con una sola escritura no es una fase de ataque. Es una suscripción.
La superficie nueva: los logs del gateway como almacén de secretos
Hay un detalle de este caso que va más allá de LiteLLM y que afecta a cualquiera que despliegue un proxy de modelos. En su primer análisis, los mantenedores sospecharon que los tokens iniciales procedían de los logs de su despliegue interno, porque Claude Code, en ciertas situaciones, incrusta el token OAuth de la GitHub CLI del usuario dentro del cuerpo de sus respuestas y, cuando el cuerpo de la respuesta se registra, ese token acaba en el log del gateway. El análisis canónico corrigió esa hipótesis, el primer token vino de la API de Agent Platform, pero el hecho verificado por los propios mantenedores sigue ahí: los logs de un gateway de IA son un almacén de secretos.
Y el caso solo refuerza esa idea. El gateway de IA es, por diseño, el componente de tu arquitectura que ve todas las claves de todos los proveedores, todos los prompts, todas las cabeceras y todas las respuestas. Es el único sitio donde esos secretos se concentran, lo que lo convierte en el objetivo natural: no hay que robar cien claves, basta con robar el sitio donde están guardadas.
La tentación es decir «es que los gateways de IA están mal hechos», y sería injusto. El problema no es que el gateway guarde claves: es que el despliegue de LiteLLM estaba accesible desde internet, con una master key de administración que llevaba meses expuesta, y sin una política de rotación que disparase la revocación al detectarse la exposición.
Qué hacer si tienes LiteLLM o cualquier gateway de IA
- No arranques Python en el entorno sospechoso. Ni `python`, ni `pip`, ni `pip show litellm`: cualquier arranque ejecuta `cook.pth`. Inspecciona desde un sistema limpio, con herramientas de confianza, y busca `cook.pth`, `cook.py`, `sitecustomize.py` y `litellm-1.107.0.dist-info` en todos los site-packages.
- Reconstruye, no borres. Elimina los tres ficheros de cada site-packages, mata el daemon que tiene `/tmp/remote_code_loader.lock` y reconstruye el entorno o la imagen desde cero. Borrar solo el directorio `litellm/` no sirve: los ficheros de arranque viven fuera de él.
- Fija versiones y verifica artefactos. Vuelve a `1.106.2` con hash; usa digests de imagen en lugar de tags mutables, porque `latest`, `main-latest` y `main-stable` estaban envenenados; y admite paquetes en tu espejo interno solo después de revisarlos.
- Rota todo lo que el host podía alcanzar: claves de proveedor guardadas en la base de datos del proxy, master key y claves virtuales, la contraseña del `DATABASE_URL`, el rol IAM de la instancia, los secretos de CI/CD, los tokens de GitHub revocando sesiones y no solo rotando, las claves SSH y GPG, las sesiones del navegador y los tokens de herramientas de IA.
- Busca exfiltración: POST salientes a `www.litellm.dev/healthcheck`, llamadas a `GetCallerIdentity` de STS cerca de la hora de instalación, el marcador `/tmp/.pc_once_*`, el fichero `domain.txt` en `raw.githubusercontent.com/letters9813/pythonclient-test` y procesos Python huérfanos con el directorio de trabajo en la raíz. La ausencia de marcadores no prueba nada.
- Revoca cualquier clave que se haya expuesto, aunque no pruebes que se usó. Una clave que apareció en un repositorio público, en un log o en la transcripción de un asistente es una clave pública. El coste de rotar de más es una tarde; el coste de no rotar es la cadena de suministro entera.
- Protege el gateway como lo que es: un almacén de secretos con API HTTP. No debería estar expuesto a internet. Ponlo detrás de autenticación fuerte o mTLS, segmenta su red y rota la master key de forma programada y ante cada evento de exposición.
- Audita las relajaciones temporales. LiteLLM aflojó esa semana la puerta de producción de las releases. Cualquier control que se abra «solo por esta semana» es un agujero permanente hasta que se demuestre lo contrario, y hay que cerrarlo aunque nadie lo haya explotado.
- Pide attestations. LiteLLM nunca publicó attestations de PyPI, así que un usuario no tenía forma de distinguir un build manipulado de uno legítimo. Trusted Publishing sin attestation deja exactamente el mismo hueco, pero con mejor marketing.
Lo que deja el caso
Tres controles fallaron en secuencia y cada uno, aislado, era razonable. Alguien detectó una clave expuesta y la anotó para revocar, sin revocar. Alguien guardó el token de GitHub de una persona en la configuración de un agente en vez de en un gestor de secretos con scopes. Y alguien relajó, durante una semana, el control que exigía un humano y un dispositivo físico. El atacante no necesitó que ninguno de los tres controles hiciera nada raro; solo que ninguno estuviera en su sitio.
El dato más incómodo de todo el relato es que el atacante no aceleró nada. Podía haber desactivado la protección de ramas y la puerta de producción con el token del propietario, y desactivó la primera, pero usó identidades distintas para disparar y para aprobar, precisamente para que el registro de auditoría pareciera un flujo normal de aprobaciones. La detección no puede depender de que el ataque se comporte mal: tiene que depender de observar que un comportamiento normal se está usando en un contexto imposible.
Y queda la lección de fondo para el sector de la IA, que es la razón por la que este caso importa más allá de LiteLLM: la capa de infraestructura de la IA se ha convertido en su propia cadena de suministro. Cuando el proxy que habla con los modelos, el framework del agente y el cliente de escritorio se instalan como un paquete más, heredan de golpe la confianza, el radio de impacto y la superficie de ataque de todo lo que hay alrededor. La herramienta que usas para hablar con los modelos merece el mismo escrutinio que tu pipeline de CI. Lo que pasa es que nadie lo hace.
Fuentes
- LiteLLM, análisis de causa raíz (documento canónico, 23 de octubre de 2026): la cadena de ataque detrás del release v1.107.0.
- LiteLLM, post de incidente (21 de octubre de 2026): incidente de seguridad de LiteLLM.
- GitHub issue #47826 (Upwind, con IOCs y hashes de los artefactos): litellm 1.107.0 comprometido.
- Microsoft Security, análisis del payload: Inside the LiteLLM 1.107.0 supply chain compromise.
- The Hacker News (24 de octubre de 2026): Leaked LiteLLM Master Key Let Attackers Steal GitHub Tokens and Hijack Release Pipeline.
- Socket, investigación independiente sobre los workflows de exfiltración: LiteLLM attack timeline.