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ódulositede Python ejecuta las líneas de import de los archivos.pthen cada arranque del intérprete. Nadie tiene que hacer nuncaimport 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 leedomain.txtde un repositorio de GitHub controlado por el atacante, descargahttp://<host>/codepyy ejecuta el resultado conexec().
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.
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_KEYy 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/configy 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
~/.sshno 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.
¿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.