← Volver a todas las publicaciones
Publicado el · por Renaud Deraison

La versión tenía dos horas

El 21 de octubre, unos atacantes con los tokens de GitHub CLI robados a tres desarrolladores de LiteLLM publicaron litellm 1.107.0 en PyPI. Cada wheel llevaba un ladrón de credenciales que se ejecuta en cada arranque de Python y un cargador de código remoto que se activa cada hora. PyPI la retiró 1 hora y 43 minutos después, tras unas 49.000 descargas estimadas. En Bromure Agentic Coding, el filtro de antigüedad, activado por defecto, oculta cualquier versión con menos de dos días, así que pip en una máquina Bromure nunca vio la 1.107.0.

La versión maliciosa estuvo en PyPI 1 hora y 43 minutos. Con la configuración por defecto de Bromure, pip en la máquina de un agente no la habría visto hasta dos días después de su publicación.

A las 11:39 UTC del martes 21 de octubre llegaron a PyPI siete wheels de litellm==1.107.0. LiteLLM es la biblioteca de Python y el proxy que muchos equipos usan para enrutar el tráfico hacia los modelos, y viene incluida en muchas herramientas de IA. La nueva versión parecía legítima: el workflow de publicación del proyecto la había firmado y venía con procedencia SLSA. También contenía un ladrón de credenciales y una puerta trasera.

PyPI la retiró a las 13:23 UTC. Las imágenes de Docker infectadas, con las etiquetas v1.107.0, latest, main-latest y main-stable, siguieron en Docker Hub hasta las 14:20. Wiz estima unas 49.000 descargas de la versión maliciosa en ese intervalo. El informe del incidente de LiteLLM pide a quien la haya instalado en un equipo con salida a Internet que dé por hecho que la exfiltración tuvo éxito.

Es el segundo compromiso del proyecto este año. El primero, en marzo, llegó a través de un escáner de seguridad envenenado en su CI. Escribimos sobre cómo los equipos de respuesta revisaron la ventana equivocada aquella vez.

Cómo consiguieron las llaves los atacantes

El equipo de LiteLLM ha publicado una cronología minuto a minuto. Los atacantes tenían tokens OAuth de GitHub CLI de tres desarrolladores. Ese lunes se habían ampliado los permisos de publicación para que dos ingenieros pudieran sacar backports de correcciones. El martes a las 10:08, la protección de rama rechazó un cambio en el workflow privado de publicación. Cinco segundos después, el token de uno de los dos propietarios de la organización anuló la protección y subió el cambio. El workflow modificado compilaba desde Berri-AI/litellm, una imitación del auténtico BerriAI/litellm, y el token de un segundo desarrollador superó la aprobación de producción. A las 11:53, el token de un tercer desarrollador borró los registros de ejecución del workflow.

LiteLLM atribuye los tokens a dos hallazgos. Uno fue una «cuenta de usuario inesperada, de nombre inocuo» en su despliegue interno de LiteLLM, expuesto a Internet. El otro tiene que ver con los agentes de código. Según el informe, LiteLLM ha «observado situaciones concretas en las que Claude Code incrusta el token OAuth de GitHub CLI de un usuario en el cuerpo de una respuesta, y cuando los cuerpos de respuesta se registran, esos tokens pueden aparecer en los logs de LiteLLM».

Una pasarela que registra el tráfico de los modelos guarda una copia de todo lo que escribió el agente. En este caso, la copia incluía el token que podía publicar el paquete.

Qué hacían los wheels

El análisis de StepSecurity explica por qué el código fuente en GitHub parecía limpio. Los atacantes añadieron una sola línea a la configuración de compilación, data = "cook" bajo [tool.maturin]. Copiaba un directorio cook/ dentro de cada wheel. El directorio nunca apareció en la distribución de código fuente, y la etiqueta v1.107.0 en GitHub no tiene rastro de él.

Cada wheel instala tres archivos en site-packages:

  • cook.pth, 12 bytes: import cook. El módulo site de Python ejecuta las líneas de import de los archivos .pth en cada arranque del intérprete. Nadie tiene que hacer nunca import litellm. Basta con arrancar pip.
  • cook.py, una línea ofuscada que se decodifica en unos 61.000 bytes. Es un ladrón que etiqueta su salida con "tool": "privcollect" y ejecuta 16 módulos de recolección.
  • sitecustomize.py, un cargador que se bifurca en un demonio independiente. Cada hora lee domain.txt de un repositorio de GitHub controlado por el atacante, descarga http://<host>/codepy y ejecuta el resultado con exec().

El ladrón se lleva claves SSH y GPG, credenciales de la nube y metadatos de instancia, ~/.kube/config, la configuración de Docker, tokens de GitHub CLI, .git-credentials, .netrc, tokens de registro para npm, PyPI, Cargo y otros, archivos .env hasta dos niveles de profundidad, inicios de sesión y cookies del navegador, historial del shell y de bases de datos, y el entorno de cada proceso legible desde /proc/<pid>/environ. También enumera los archivos de autenticación y configuración de Claude Code, Claude Desktop, Cursor, Windsurf, Kiro, GitHub Copilot, Gemini CLI, Codex, Continue, Aider, Goose y una docena más de herramientas de IA. En un equipo con el proxy de LiteLLM, lee las claves de API de los proveedores en la base de datos del proxy.

El ladrón cifra el paquete con AES-256, envuelve la clave con la clave pública RSA-4096 del atacante y envía el resultado por HTTP sin cifrar a http://www.litellm.dev/healthcheck. El atacante registró el dominio el 13 de octubre. Mientras el servidor no responda 200, el ladrón vuelve a ejecutarse en el siguiente arranque de Python.

El domain.txt del cargador estaba vacío en el momento de la publicación, así que la segunda fase nunca llegó a activarse. La cuenta de GitHub que lo alojaba ya se ha borrado, pero los equipos infectados siguen consultando esa URL cada hora. Si alguien vuelve a registrar ese nombre de usuario, todos esos equipos ejecutarán su código.

Por qué los agentes de código estaban expuestos

Un agente que trabaja en un proyecto de Python ejecuta pip install -U litellm porque un README o un mensaje de error lo sugiere. Ejecuta pip install -r requirements.txt con una línea litellm sin versión fijada. Crea un virtualenv nuevo e instala lo más reciente, a cualquier hora, y nadie lee qué versión se ha instalado.

El martes, entre las 11:39 y las 13:23 UTC, lo más reciente era la 1.107.0. En la máquina de un agente, cook.pth se ejecutaba entonces en cada llamada a python, pytest y pip que hacía el agente durante el resto de la sesión. Las credenciales del desarrollador estaban al alcance del ladrón, y los inicios de sesión del agente figuraban en su lista de objetivos.

Bromure nunca sirvió la 1.107.0

Bromure Agentic Coding ejecuta Claude Code, Codex, Grok, Kimi y omp en máquinas virtuales Linux en su Mac. Cada descarga de paquetes desde la VM pasa por un proxy en el Mac, que aplica la política de cadena de suministro de la máquina antes de que la respuesta llegue a pip. La VM no puede cambiar esa política. El pip.conf dentro de la VM solo puede restringirla más.

Una capa de esa política está activa en cada máquina nueva: el filtro de antigüedad, con una antigüedad mínima de 2 días. Cuando pip pregunta al índice simple de PyPI qué versiones de litellm existen, el proxy elimina de la lista todos los archivos más recientes que el umbral antes de que pip la lea. A las 11:45 UTC del martes, una máquina Bromure que ejecutaba pip install -U litellm recibía una lista que terminaba en la 1.106.2, la última versión limpia, y la instalaba. Para el agente, la 1.107.0 todavía no existía, y para cuando el filtro la habría dejado pasar, PyPI ya la había borrado.

Si el agente fija la versión, la descarga del artefacto es el segundo control. El proxy consulta en PyPI la fecha de publicación del archivo y rechaza la descarga con un HTTP 451:

$ pip install litellm==1.107.0
ERROR: HTTP error 451 while getting https://files.pythonhosted.org/.../litellm-1.107.0-...whl
Bromure Supply-Chain Security blocked this request: pypi package [email protected]
published 1 hour ago — policy requires 2 days minimum

pip muestra el motivo tal cual, así que el agente también lo lee, y a menudo fija por su cuenta una versión anterior.

La versión ya no estaba mucho antes de que el filtro se abriera21 oct 08:0022 oct 00:0023 oct 00:0023 oct 14:00litellm 1.107.0 en PyPI11:39 a 13:23 · unas 49.000 descargasetiquetas en Docker Hub hasta las 14:20Filtro de antigüedad de Bromure, 2 días por defectopip en una máquina Bromure ve la 1.106.2 como el litellm más reciente23 oct 11:39: el filtro se abriría. Ya no queda nada.retiradaHoras del informe de incidente de LiteLLM. Estimación de descargas de Wiz.
litellm 1.107.0 en PyPI, del 21 al 23 de octubre, en UTC. La versión maliciosa estuvo publicada 1 hora y 43 minutos y había desaparecido más de 46 horas antes de que el filtro de antigüedad por defecto de Bromure dejara que pip la viera.

El filtro no necesitó ningún aviso de seguridad, ninguna firma de malware ni que nadie señalara el paquete. Los registros y los investigadores suelen detectar y retirar una versión secuestrada en cuestión de horas, y esperar dos días apenas cuesta nada en el desarrollo normal. Si su propio equipo publica un paquete que tiene que instalarse en cuanto sale, añádalo en Exempt packages, en el panel Supply Chain de la máquina, como pypi:yourpkg. El resto de PyPI sigue esperando dos días.

El mismo panel añade más capas. El control de OSV rechaza cualquier versión con un aviso publicado de la gravedad que usted elija o superior. El filtro de socket.dev bloquea los paquetes marcados como malware. Si una fuente de reputación que haya activado no puede dar un veredicto, la descarga se detiene y Bromure le pregunta por ese paquete y esa versión concretos.

El token del log era un sustituto

La publicación solo fue posible porque los atacantes tenían tokens reales de GitHub. LiteLLM cree que los obtuvieron de un despliegue que registraba el tráfico de los modelos, donde Claude Code había escrito el token de GitHub CLI de un desarrollador en una respuesta.

En una máquina Bromure, ese token es un sustituto. Cuando arranca la VM, Bromure escribe un token ghp_ falso en la configuración de gh y en ~/.git-credentials y lo exporta como GH_TOKEN. El falso tiene el prefijo y la longitud correctos, así que gh lo acepta. El token real se queda en el Mac. Cuando una petición sale de la VM hacia GitHub, el proxy cambia el falso por el valor real en la conexión, solo para los hosts de GitHub.

Un agente todavía puede pegar el token en una respuesta. Solo tiene el sustituto para pegar. El proveedor del modelo y cualquier log de pasarela intermedio registran también el sustituto. Si alguien lo reutiliza desde otro portátil, GitHub lo rechaza, porque allí no hay ningún proxy de Bromure que lo cambie.

Los permisos de publicación reciben la misma protección. En el panel Guardrails de una máquina, cada token de forja tiene una política de escritura, y las máquinas nuevas empiezan con Prompt before write. Un force-push a un workflow de publicación, o una llamada a la API que desactiva la protección de rama, espera a un diálogo en su Mac que muestra METHOD /path, y usted decide.

Lo que privcollect habría encontrado

Supongamos que la 1.107.0 sí llegara a una máquina, porque alguien desactivó el filtro o añadió una excepción. La lista del ladrón es larga. En una VM de Bromure, casi todo lo que puede abrir contiene sustitutos:

  • GH_TOKEN, ANTHROPIC_API_KEY y todas las demás credenciales que recibió la máquina, en el entorno de cada proceso que el ladrón lee a través de /proc, son falsas.
  • ~/.git-credentials, ~/.docker/config.json, ~/.kube/config, ~/.aws/config y las configuraciones de MCP contienen falsos. También los archivos de inicio de sesión de los propios agentes: las suscripciones de Claude, ChatGPT, Grok y Kimi se inician y se renuevan en el Mac.
  • Las claves de los proveedores de modelos nunca entran en la VM, así que no hay ninguna base de datos de proxy llena de claves que leer. El proxy de Bromure las añade en el Mac, como explicamos en El provider se ejecuta donde están las claves.
  • Las claves SSH firman a través de un puente de agente hacia el Mac. Los bytes de la clave privada nunca entran en la VM, así que en ~/.ssh no hay nada que llevarse.

Un sustituto solo funciona a través del proxy de esa máquina, hacia el host para el que se emitió. El atacante puede descifrar el archivo y reutilizar cada valor que contiene, y GitHub, AWS y Anthropic rechazarán cada uno.

El POST no tenía adónde ir

El paquete sale como un POST HTTP sin cifrar hacia www.litellm.dev, un dominio que la máquina nunca ha necesitado. Con Unmatched traffic en Deny en el panel Guardrails, la VM solo llega a los hosts que usted enumere:

allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
allow web api.anthropic.com
default deny

El switch del Mac rechaza la conexión, y la Security Timeline muestra una fila roja deny con el host y el puerto. La segunda fase del cargador falla de la misma manera: sea cual sea el host que indique domain.txt, http://<host>/codepy no está en la lista, así que el exec() de cada hora no tiene nada que ejecutar.

En un workspace con el motor de sandbox OpenShell + Sentry, Show the Whole Chain en esa fila abre el linaje del flujo bloqueado. Muestra la llamada a la herramienta pip install del agente y el razonamiento que dio el modelo para hacerla, luego el proceso pip en el que cook.pth arrancó el ladrón, y luego la conexión rechazada. Ve la instrucción que trajo el paquete y el motivo que el modelo dio para ejecutarla.

Cuatro pasos, cuatro controles en el MacEl ataque, 21 de octubre1 · Tokens en un logtokens OAuth de gh copiadosdel tráfico de modelos2 · Workflow con force-pushprotección anulada,build desde Berri-AI/litellm3 · Wheels en PyPIlitellm==1.107.011:39 a 13:23 UTC4 · cook.pth se ejecutarecolecta y hace POST awww.litellm.devEn una máquina BromureToken sustitutola VM tiene un ghp_ falso;el log guarda el falso.GitHub lo rechaza fuera.Política de escrituralos push y cambios en laprotección de rama esperanun diálogo en el Mac.Antigüedad, por defectopip resuelve la 1.106.2;una 1.107.0 fijadarecibe un HTTP 451.Falsos y denegaciónel botín son sustitutos;el POST y /codepy serechazan en el switch.
La cadena de litellm 1.107.0, y dónde la corta una máquina Bromure. Cada uno de los cuatro controles se ejecuta en el Mac, fuera de la VM en la que se instala el paquete.

¿La instalamos? Basta un filtro

El martes por la tarde, todos los equipos de respuesta tenían la misma primera pregunta: ¿cuáles de nuestras máquinas ejecutaron la 1.107.0? LiteLLM advierte que no encontrar cook.pth no demuestra nada: su ausencia no significa que un equipo esté limpio.

Bromure registra cada descarga de paquetes que ve el proxy, diga lo que diga la política y haya o no algún control activo. Abra Window → Security Timeline…, elija Supply chain en el filtro de motor y busque litellm. Cada fila indica el paquete y la versión, pypi · [email protected], y qué pasó con ellos. El registro se conserva en el Mac durante 90 días, y Export… lo guarda en CSV para el ticket del incidente. En un Mac registrado en bromure.io, cada descarga es también un evento supply_chain.fetch con el ecosistema, el paquete, la versión y el resultado. Un administrador puede listar todas las máquinas de la organización que descargaron [email protected] sin preguntar a nadie.

Limpiar después de un impacto es rápido. La guía de LiteLLM dice que hay que matar el demonio que tiene /tmp/remote_code_loader.lock antes de ejecutar pip, borrar los archivos de la carga útil de cada site-packages, reconstruir y rotar. En Bromure, Reset disk vuelve a clonar el disco del sistema a partir de la imagen base, y Rewind home… devuelve el home a un punto de control anterior a la instalación. Las claves que de otro modo habría rotado a toda prisa eran sustitutos. Las reales nunca salieron del Mac.

Las versiones nuevas esperan dos días

El filtro de antigüedad está activo en cada máquina nueva. Las instalaciones desde PyPI, npm, Cargo, RubyGems y Packagist se resuelven a la versión más reciente anterior al umbral. Una versión reciente fijada recibe un 451 con el motivo.

El token del log es falso

Las credenciales de GitHub, la nube, los registros y los modelos entran en la VM como sustitutos. El proxy pone el valor real en el Mac, para el host propio de cada credencial.

La exfiltración necesita una ruta

Con el tráfico no listado en deny, un POST a un dominio registrado la semana pasada muere en el switch y aparece como una fila roja.

Cada instalación queda registrada

La Security Timeline lista cada paquete y versión que descargó cada máquina, durante 90 días. Los Mac registrados envían los mismos eventos a la organización.

Deje que el registro se ponga al día primero

Los atacantes necesitaron minutos para publicar la 1.107.0. PyPI necesitó 1 hora y 43 minutos para retirarla, lo cual es rápido para un registro. Su agente instala paquetes todo el día, a cualquier hora, y toma lo más reciente, así que cae dentro de ventanas como esa. En una máquina Bromure espera dos días. Para entonces, PyPI, Wiz, StepSecurity y el propio equipo de LiteLLM ya han revisado la versión, y una mala ya no está.

Sobre el ladrón que fue a por las credenciales de los agentes de código a principios de este mes, lea El stealer se llevó los sustitutos. Sobre cómo los tokens acaban en transcripciones y logs de modelos, lea La transcripción se quedó con el token.