# LiteLLM 1.107.0 envenenado: robaba claves al arrancar Python

> LiteLLM 1.107.0 pasó dos horas en PyPI y casi tres en las imágenes oficiales de Docker con un ladrón de credenciales que se activaba al arrancar Python.
> Por José Maldonado · Ciberseguridad · 2026-10-27

La versión 1.107.0 de LiteLLM pasó menos de dos horas en PyPI, entre las 11:39 y las 13:23 UTC del 21 de octubre de 2026, y en esa ventana se publicaron siete wheels con un ladrón de credenciales dentro. El daño no se quedó en el índice de Python: [las imágenes oficiales de Docker Hub y de GHCR](https://www.microsoft.com/en-us/security/blog/2026/10/23/inside-the-litellm-1-107-0-supply-chain-compromise-credential-theft-through-python-startup-hooks/) siguieron sirviendo el mismo artefacto casi tres horas después de su publicación.

Lo que separa este incidente de un malware corriente es el disparador. Los atacantes añadieron tres ficheros que Python ejecuta al inicializar el intérprete, así que ninguna aplicación necesitaba importar LiteLLM para quedar expuesta. Bastaba con arrancar Python en un entorno donde la versión estuviera instalada, y eso incluye a quien abre una terminal y lanza pip para comprobar si tiene el problema. Microsoft documentó un caso en Windows donde una instalación rutinaria de otra dependencia activó el recolector.

LiteLLM es la pieza que muchas empresas colocan delante de OpenAI, Anthropic o Bedrock para unificar el acceso a modelos, y esa posición la deja con las claves de todos los proveedores a la vez. Quien la compromete no roba una cuenta suelta, roba la puerta de entrada a la facturación de IA de un cliente entero.

## Una ventana de dos horas y siete ruedas contaminadas

El paquete malicioso llegó al índice público a las 11:39:51 UTC y a las 13:23 ya estaba fuera, según [la cronología del propio proyecto](https://docs.litellm.ai/blog/security-incident-oct-21-2026). Eran siete wheels para macOS, Linux y Windows, así que ninguna combinación de sistema y arquitectura quedó a salvo por casualidad.

Las etiquetas de contenedor fueron lo peor. Docker Hub mantuvo \`v1.107.0\`, \`latest\`, \`main-latest\` y \`main-stable\` apuntando al índice comprometido hasta las 14:20 UTC, y GitHub Container Registry tardó casi dos horas en retirar las suyas.

Artefacto

Ventana del 21 de octubre, en UTC

Estado final

PyPI, litellm 1.107.0 (siete wheels)

11:39 a 13:23

Retirado del índice

Docker Hub, cuatro etiquetas móviles

11:46 a 14:20

Retiradas o repuntadas

GHCR, las mismas cuatro etiquetas

11:46 a 13:51

No disponibles

Etiqueta de código fuente en GitHub

Sin cambios

Nunca llevó el payload

La última fila explica por qué el sabotaje tardó en detectarse. La etiqueta de la versión y el tarball estaban limpios: [el envenenamiento vivía en el artefacto construido](https://research.jfrog.com/post/litellm-hijacked-again/), no en el código que cualquiera lee en GitHub.

## El arranque de Python como puerta de entrada

El primer mecanismo es \`cook.pth\`, un fichero de dos palabras cuyo contenido completo es \`import cook\`. El módulo \`site\` recorre los \`.pth\` de cada entorno en el arranque del intérprete y ejecuta las líneas que empiezan por \`import\`, una función pensada para registrar rutas que aquí servía de lanzadera. El resultado es que \`cook.py\` corría siempre, sin importar lo que hiciera la aplicación.

\`cook.py\` decodifica en Base64, descomprime con zlib y entrega el resultado a \`exec()\`. JFrog descifró el contenido sin ejecutarlo y obtuvo unos 61 KB que se identifican como \`privcollect\`. El programa arranca un hilo demonio, marca \`PRIVCOLLECT\_ACTIVE\` para no repetirse en procesos hijos y sólo deja el testigo \`/tmp/.pc\_once\_72129118c59f5636c0bb\` cuando la subida devuelve un 200.

El segundo gancho es \`sitecustomize.py\`, que Python intenta importar por su cuenta tras procesar las rutas. Este añade carga de código remoto: toma un bloqueo en \`/tmp/remote\_code\_loader.lock\`, se separa del proceso padre y pide \`/codepy\` al servidor que indique un fichero alojado en GitHub, para compilarlo y ejecutarlo. Microsoft no confirmó ninguna descarga de segunda fase, porque el puntero estaba vacío, pero el mecanismo se reactiva cambiando ese fichero.

En Windows ese camino falla, porque el código depende de \`os.fork()\`. El recolector no, ya que \`cook.pth\` se ejecuta igual, y esa asimetría deja expuestas las estaciones de trabajo con Windows aunque el cargador no llegue a arrancar.

## ¿Qué se llevaba el recolector?

El inventario que reconstruyeron Microsoft y JFrog describe el objetivo: entornos de desarrollo, agentes de integración continua y pasarelas de IA en producción. El payload recorre módulos que manejan sus errores por separado, así que un fallo en un objetivo no detiene el resto.

*   Claves SSH, GPG y certificados TLS del usuario que ejecuta el proceso
*   Credenciales de AWS, Azure y Google Cloud, con metadatos de instancia y Kubernetes
*   Secretos de GitLab Runner, Jenkins, Vault, Terraform y Packer
*   Bases de contraseñas e historial de los navegadores Chromium y Firefox
*   Tokens de herramientas de IA como Cursor, Claude Code o Copilot
*   Variables de entorno propias y las de cualquier \`/proc/<pid>/environ\` legible
*   Las tablas \`LiteLLM\_CredentialsTable\` y \`LiteLLM\_ProxyModelTable\`, con 500 filas por consulta

La última entrada es la más dolorosa para una empresa. Una pasarela LiteLLM guarda a menudo las claves de todos los proveedores de modelos de la organización, así que un solo host comprometido entrega el acceso a la facturación de IA completa. El conjunto viaja cifrado con AES-256-CBC y la clave envuelta con una RSA de 4.096 bits hacia \`www.litellm.dev/healthcheck\`, un dominio registrado el 13 de octubre de 2026 que imita el oficial \`litellm.ai\`.

## Del token de GitHub al paquete firmado

Nada de esto habría pasado sin una clave que nunca se rotó. El 27 de mayo de 2026 alguien subió a un repositorio público un \`opencode.json\` con la clave maestra de la plataforma de agentes interna del proyecto. El error se corrigió, pero la revocación quedó pendiente. El 10 de octubre, desde \`69.48.229.143\`, el atacante la usó para obtener el token OAuth de GitHub CLI de uno de los dos propietarios de la organización.

Con esa identidad llegaron las ramas desechables. \`ci-tmp-a351cc\` en \`BerriAI/litellm\` publicó seis secretos de integración continua en un endpoint de webhook.site, entre ellos \`GH\_TOKEN\`, el token de un segundo mantenedor. Veinticuatro segundos después tocó el turno de \`ci-tmp-be33e9\` en \`BerriAI/litellm-auto\`, que envió \`RELEASE\_PLEASE\_TOKEN\` y con él la credencial de un tercero.

El 21 de octubre el movimiento final fue un cambio de origen: el atacante forzó el flujo de publicación para compilar desde el clon \`Berri-AI/litellm\`, en el commit \`20535d8f\`, y aprobó la puerta de producción con el token del segundo mantenedor.

El pipeline legítimo firmó y publicó el artefacto envenenado. El proyecto reconoce en su [análisis de causa raíz](https://docs.litellm.ai/blog/security-incident-oct-21-2026-attack-chain) que la restricción de dos firmas se había relajado esa semana y que un token de propietario podía haberla desactivado. Revisar el código o la etiqueta de Git no habría enseñado nada: el malware sólo existía en la rueda construida.

## ¿Qué rotar y en qué orden?

El primer paso es contraintuitivo y por eso conviene escribirlo: no abras pip en el entorno sospechoso. Cualquier arranque de Python vuelve a ejecutar los ganchos, así que la comprobación con \`pip show litellm\` puede disparar el robo que intentabas evitar.

1.  Borra \`cook.pth\`, \`cook.py\`, \`sitecustomize.py\` y los \`\_\_pycache\_\_/cook*.pyc\` y \`sitecustomize*.pyc\` de cada \`site-packages\` afectado.
2.  Mata el demonio que sostiene \`/tmp/remote\_code\_loader.lock\` y revisa los rastros \`.pc\_e\_\` y \`.pc\_sq\_\`.
3.  Reconstruye el entorno desde cero con \`litellm==1.106.2\` en lugar de borrar el directorio de la librería.
4.  Rota desde una máquina limpia las claves de proveedores, las del proxy, la contraseña de \`DATABASE\_URL\` y el rol de IAM.
5.  Busca en CloudTrail llamadas a \`GetCallerIdentity\` cerca de la hora de instalación y su uso posterior desde fuera de tu red.

La ausencia del testigo en \`/tmp\` no demuestra nada. Sólo aparece cuando la exfiltración devolvió un 200, y los contenedores se limpian al reiniciar. LiteLLM lo resume en su aviso: si la versión estuvo instalada en un host con salida a internet, asume que el envío salió.

## El contenedor oficial tampoco te salva

Durante años el consejo fue desconfiar de los paquetes y fiarse de la imagen oficial. Aquí las dos vías estaban comprometidas, con el mismo índice \`sha256:1bbcb1443bc9…\` en Docker Hub y en GHCR. La lección está en cómo se fija una dependencia: \`latest\` señala a lo que el registro quiera servir, un digest señala contenido inmutable. Microsoft añade revisar cachés de pip y uv, espejos internos y ficheros de bloqueo, donde sobrevive una versión ya retirada.

El caso encaja con la cuarentena de dependencias que otros ecosistemas ya tratan como control básico, tal como se planteó con [la cuarentena de Cargo que llegó con Rust 1.100](https://observatorioblockchain.com/ciberseguridad/rust-1-100-cargo-cuarentena-dependencias/). Un retraso de horas habría dejado esta ventana fuera del alcance de casi cualquier equipo.

## Un patrón que va por su segunda vez este año

JFrog documentó en marzo de 2026 el primer secuestro de LiteLLM, con las versiones 1.82.7 y 1.82.8 publicadas a través de una versión de Trivy sin fijar, la 0.69.4, que expuso credenciales de CI/CD. Aquel episodio se atribuyó al grupo TeamPCP y distribuía backdoors por nodos de Kubernetes. El de ahora usa otros ficheros de arranque y otra infraestructura, y no se ha encontrado vínculo entre ambos.

La coincidencia incómoda es el patrón de fondo. Dos compromisos del mismo paquete en siete meses, ambos con un robo de credenciales de desarrollador y un pipeline que firmó sin protestar. La pila de IA se despliega con los hábitos de 2015: etiqueta móvil, token eterno, secreto en un \`.env\` y nadie mirando el \`RECORD\` de la rueda.

Quien tenga LiteLLM en producción puede comprobar hoy si su digest coincide con el comprometido, si algún \`site-packages\` guarda un \`cook.pth\` y si sus claves siguen siendo las mismas desde el 21 de octubre. Las tres respuestas caben en veinte minutos y pesan más que cualquier parche de esta semana.
