Seguridad

LiteLLM 1.107.0 comprometido: malware en PyPI y Docker que roba credenciales

El 21 de octubre de 2026, a las 04:00 PST, alguien entró en la cadena de release de LiteLLM y publicó un paquete de PyPI con un ladrón de credenciales y una puerta trasera camuflados dentro del instalador de un gateway de IA. Unas cuatro horas y media después, esas credenciales ya se estaban usando en runners de CI y nodos de Kubernetes de otras empresas: roles de instancia de AWS validados con sts:GetCallerIdentity y hasta modelos servidos a costa de las víctimas.

Es el segundo ataque de cadena de suministro contra LiteLLM en 2026, y el primero que se propaga a la vez por PyPI, Docker Hub y GHCR, con un loader de acceso remoto además del ladrón de credenciales. Lo que lo hace instructivo no es el malware en sí: es que la cadena de release de LiteLLM ya estaba endurecida con Trusted Publishers, tags inmutables y firmas de imagen, y aun así el atacante tuvo que vencer varios controles independientes. El punto de entrada fue una sola clase de error: un token de GitHub CLI escrito dentro de un log.

TL;DR — Lo que vas a tener al final

  • Tiempo de setup: 45 minutos para auditar, 2 horas para limpiar y rotar si hubo exposición.
  • Requisitos: shell en el host afectado, permisos para leer site-packages, y acceso al proveedor de nube donde vive tu instancia.
  • Resultado: tres archivos borrados, la versión 1.106.2 fijarada, todas las credenciales del host rotadas y evidencia forense guardada antes de borrar nada.
  • Troubleshooting: el indicador clave es cook.pth en site-packages; si no lo encuentras no estás limpio, solo no encontraste la ruta.

Qué es LiteLLM y por qué 23 millones de descargas semanales amplifican el daño

LiteLLM es un proxy y gateway open source para proveedores de LLM escrito en Python. Su trabajo es simple en apariencia y complejo en la práctica: recibe una llamada con formato OpenAI, decide a qué proveedor real enviarla, aplica límites de gasto, caché, reintentos y registro, y expone todo eso detrás de una sola API. Es la pieza que muchas empresas instalan para no tener que mantener credenciales de diez proveedores dispersas por el código.

Ese diseño es exactamente lo que convierte un ataque en un incidente grande. Socket calcula alrededor de 23 millones de descargas semanales, y cada instalación guarda las API keys de los proveedores upstream en una base de datos PostgreSQL, en la tabla LiteLLM_CredentialsTable. Un host con el proxy comprometido no expone una clave: expone todas las claves que ese gateway sirve, más las del rol de instancia de la nube donde corre, más las variables de entorno de cada proceso que corría en ese host.

Antes de asumir nada, lee el archivo de configuración del proxy para entender exactamente qué credenciales expone y cuáles no. Es un paso que casi todo el mundo salta, y es el que decide si tu caso es grave o manejable.

Qué pasó exactamente: la línea de tiempo del 21 de octubre

El reporte oficial de LiteLLM publica una cronología al minuto. La resumo porque cada marca importa cuando tienes que reconstruir qué hizo tu infraestructura en esa franja.

Hora (UTC) Evento
10:08:29 La protección de rama del repositorio privado de release rechaza un cambio no autorizado.
10:08:34 Una credencial de una cuenta privilegiada desactiva esas protecciones y fuerza el push del commit 9cf97e93 del workflow.
10:46:39 El workflow modificado arranca la corrida de staging oficial usando el repositorio atacante Berri-AI/litellm en el commit 20535d8f.
11:08:09 La corrida de staging termina con éxito.
11:08:53 Arranca la pipeline de release de producción.
11:11:18 Una segunda credencial satisface el gate de aprobación de producción.
11:39:51 Se suben a PyPI los siete wheels maliciosos de litellm 1.107.0.
11:46:56 Arrancan los jobs de publicación de contenedores; las imágenes arrastran el contenido malicioso.
11:53:49 Una tercera credencial borra los registros de los workflows de producción y staging, lo que recorta los logs disponibles para la investigación.
12:09:38–12:24:52 Se retira la revisión no autorizada del workflow y el release workflow vuelve a su revisión anterior.
13:23 PyPI retira litellm 1.107.0.
13:51 Los tags afectados en GHCR quedan indisponibles. Los de Docker Hub todavía resuelven a la imagen comprometida.
14:20 Todos los tags afectados de Docker Hub se eliminan o se repuntan a artefactos limpios.

El detalle que más me preocupa como sysadmin no es el malware: es el paso de las 11:53. Borrar los registros del workflow no es un movimiento de salida, es anti-forense. Y funciona: parte de la investigación se quedó sin logs porque alguien los borró antes de que llegara el equipo.

El origen del ataque: un token de GitHub CLI dentro de un log

La cadena de release de LiteLLM ya estaba endurecida. Las releases salen de un repositorio privado, se cualifican con pruebas de integración en CircleCI y Buildkite, se construyen en un workflow con Trusted GitHub Publishers y se publican con imágenes Docker firmadas e inmutables. Publicar solo puede hacerlo una cuenta bloqueada a un portátil físico que se encuentra dentro de la oficina.

El 19 de octubre, un día antes del ataque, LiteLLM amplió temporalmente los permisos de release para que dos ingenieros internos publicaran backports diarios de correcciones a clientes. Esa ampliación fue la puerta que el atacante necesitaba. Con acceso a tres conjuntos de credenciales de GitHub CLI, uno de los cuales pertenecía a uno de los dos propietarios de la organización, pudo saltarse la protección de rama, disparar el workflow, aprobar la producción y borrar los logs.

Pero el token no llegó por phishing. Según el reporte oficial, LiteLLM encontró en su despliegue interno una cuenta de usuario con un nombre inocuo que estaba expuesta a internet, y el equipo cree que el atacante entró por ahí. Luego encontró los tokens de GitHub CLI en los logs de ese despliegue. Y la causa raíz de que estuvieran en los logs es la más incómoda de toda la cadena: Claude Code, en ciertas situaciones, incrusta el token OAuth de GitHub CLI en el body de la respuesta, y ese body se estaba logueando.

Un detalle que conviene retener: el equipo todavía no confirmó cómo entró el atacante a ese despliegue interno. La hipótesis que manejan es una vulnerabilidad recién parchada. Esa frase, la de “todavía no confirmado”, aparece en un incidente donde el resto del reporte sí es concluyente, y es una señal de que la investigación seguía abierta cuando se publicó.

Es decir: una herramienta de código con LLM filtró las credenciales del repositorio hacia los logs del servicio que la proxea, y ese mismo servicio era la puerta de vuelta al repositorio. Si usas un gateway de LLM para registrar respuestas de agentes, esa intersección es un riesgo real, no una curiosidad.

Sobre el repositorio atacante hay un detalle que vale la pena mirar de cerca. El fork malicioso se llamaba Berri-AI/litellm, con un guion, frente al BerriAI/litellm legítimo, sin guion. La build provenance resultante registró el fork como fuente de manera fiel, con toda la apariencia de ser legítima. Y un detalle más: el tag oficial v1.107.0 en GitHub nunca contuvo los archivos maliciosos. El repo fuente limpio dice muy poco sobre el artefacto que instalas.

Los dos payloads: qué dejaron dentro de tu site-packages

El wheel malicioso depositaba tres archivos directamente en la raíz de site-packages: cook.pth, cook.py y sitecustomize.py. Los tres se ejecutan automáticamente en cada arranque del intérprete de Python, porque .pth y sitecustomize.py son parte del mecanismo de arranque estándar. La consecuencia práctica es la que hay que internalizar: el malware corre aunque tu aplicación nunca importe litellm. Cualquier proceso de Python del host, un cron, una notebook, un script de migración, queda expuesto.

El primer payload, cook.py, se desofusca y ejecuta una rutina llamada privcollect que recorre 16 módulos de recolección. Escribe un marcador de ejecución única en /tmp/.pc_once_72129118c59f5636c0bb, cifra todo con AES-256-CBC y una clave envuelta con RSA-4096, y lo exfiltra por HTTP POST a www.litellm[.]dev/healthcheck, un dominio que imita la infraestructura real, sin cifrar en el transporte. Si tienes un proxy donde esa consulta pase, bloquea el dominio y revisa el registro de salida en busca de esa llamada.

El segundo payload, sitecustomize.py, es una puerta trasera de acceso remoto. Solo corre en Linux y macOS. Lanza un daemon desacoplado con un lock en /tmp/remote_code_loader.lock y entra en un bucle infinito: cada hora lee el dominio de C2 actual desde un archivo muerto en GitHub, en raw.githubusercontent[.]com/letters9813/pythonclient-test/main/domain.txt, y descarga y ejecuta Python desde http://{dominio}/codepy. Ese repositorio ya no existe; GitHub lo bajó el 21 de octubre a las 13:30 UTC. En el momento del análisis el archivo solo contenía valores de prueba, así que no se observó una segunda etapa activa, pero el mecanismo estaba completo.

Wiz señala algo que merece atención: usar .pth y sitecustomize.py de forma simultánea parece ser la primera vez que se documenta esa técnica doble en un ataque real. También quedan las huellas del desarrollo asistido por LLM, con nombres de variables excesivamente descriptivos, manejo de errores demasiado defensivo y rutas específicas de plataforma que fallan en silencio en sistemas no probados. Un payload que se rompe en Windows porque /tmp no existe, y que llama a fcntl y fork sin probarlo, no parece código escrito con un mapa completo del entorno objetivo.

Qué credenciales salen del host: el inventario del infostealer

Esto es lo que el privcollect de 16 módulos se lleva, según los análisis de Wiz, Datadog y Aikido publicados en paralelo:

  • Nube: credenciales de AWS desde ~/.aws/, variables de entorno y el metadata de instancia de EC2 y ECS; claves de cuenta de servicio y credenciales por defecto de aplicación de GCP; tokens de Azure desde la caché de la CLI.
  • Infraestructura: secretos de Kubernetes, Docker, Vault y Terraform.
  • Desarrollo: claves privadas SSH, claves GPG, claves TLS, tokens de git, npm y PyPI, archivos .env e historial de shell.
  • IA: API keys de OpenAI, Anthropic, Cohere y otros proveedores LLM guardadas en variables de entorno o archivos de configuración, más los archivos de autenticación de Claude, Codex, Gemini, Cursor y Copilot.
  • Navegador: contraseñas guardadas y cookies de Chrome, Firefox, Edge y Safari, e historial de navegación.
  • El propio proxy: si hay base de datos configurada, consulta la tabla donde el proxy guarda las credenciales de los proveedores upstream. La consulta que ejecutaba el stealer era SELECT * FROM "LiteLLM_CredentialsTable" LIMIT 500. Ajusta el nombre de la tabla a tu esquema y versión, porque no es idéntico en todos los despliegues.

Un matiz que las propias firmas de análisis subrayan: que el malware apuntara a la base de datos demuestra intención, no éxito. Nadie puede afirmar, desde fuera, que un despliegue concreto cedió sus claves. Eso es justamente lo que hay que verificar en tus propios logs.

Verifica si tu servidor está comprometido

El primer paso es saber si estás expuesto. La versión instalada se comprueba en segundos:

pip show litellm | grep -iE '^(name|version|location)'
python3 -m pip list 2>/dev/null | grep -i litellm

Si la versión que aparece es 1.107.0, o si instalaste sin fijar versión entre las 11:39 y las 13:23 UTC del 21 de octubre, el entorno está comprometido. La segunda comprobación es buscar los tres archivos de persistencia en todos los site-packages del host, no solo en el entorno virtual de la aplicación, y de paso el directorio litellm-1.107.0.dist-info que deja la versión maliciosa:

find / -xdev -type d -name 'site-packages' 2>/dev/null | while read -r d; do
  find "$d" -maxdepth 1 -type f -name 'cook.pth' -print
  find "$d" -maxdepth 1 -type f -name 'cook.py' -print
  find "$d" -maxdepth 1 -type f -name 'sitecustomize.py' -print
  find "$d" -maxdepth 1 -type d -name 'litellm-1.107.0.dist-info' -print
done

Los marcadores de ejecución son el segundo indicador, y viven en /tmp, así que un reinicio los borra. Su ausencia no prueba nada:

ls -la /tmp/.pc_once_72129118c59f5636c0bb /tmp/remote_code_loader.lock 2>/dev/null
ps aux | grep -i remote_code_loader | grep -v grep

Para ver todo lo que el interprete carga al arrancar, revisa los archivos .pth de cada site-packages que exista en el host. Un .pth legítimo en un venv de producción es raro, y ninguno debería contener una línea que importe un módulo llamado cook:

ls -la /usr/lib/python3*/site-packages/*.pth 2>/dev/null
ls -la .venv/lib/python3*/site-packages/*.pth 2>/dev/null

Restaura el entorno: borra los payloads y vuelve a 1.106.2

El orden importa, y hay una trampa que el reporte oficial advierte con todas las letras: no empieces por pip install. Cualquier arranque de Python en el entorno afectado ejecuta cook.pth y sitecustomize.py otra vez, y arrancar pip cuenta como arranque de Python. Si limpias al revés, reinstalas el malware con tus propias manos.

Primero, evidencia. Copia lo que vas a borrar a un lugar fuera del host, con marca de tiempo, antes de tocar nada:

sudo mkdir -p /var/tmp/litellm-forensics
sudo cp -a /path/a/site-packages/cook.pth /path/a/site-packages/cook.py /path/a/site-packages/sitecustomize.py /var/tmp/litellm-forensics/ 2>/dev/null
sudo sha256sum /var/tmp/litellm-forensics/cook.py /var/tmp/litellm-forensics/sitecustomize.py

Segundo, mata el daemon y borra los payloads de todos los entornos del host, no solo del que instalaste el paquete:

sudo pkill -f remote_code_loader
sudo rm -f /tmp/remote_code_loader.lock /tmp/.pc_once_72129118c59f5636c0bb
find / -xdev -type d -name 'site-packages' 2>/dev/null | while read -r d; do
  rm -f "$d/cook.pth" "$d/cook.py" "$d/sitecustomize.py"
  rm -f "$d"/__pycache__/cook*.pyc "$d"/__pycache__/sitecustomize*.pyc
done

Tercero, reconstruye. La recomendación del equipo de LiteLLM es crear el entorno virtual desde cero en lugar de reinstalar encima, porque un site-packages que ya estuvo comprometido es un entorno del que no puedes garantizar el estado:

python3 -m venv /opt/litellm-clean
/opt/litellm-clean/bin/pip install 'litellm==1.106.2'
/opt/litellm-clean/bin/pip show litellm | grep -i version

Cuarto, verifica. Un .pth fuera de tu lista blanca es el equivalente en Python de un binario desconocido en el PATH:

ls -la /opt/litellm-clean/lib/python3*/site-packages/*.pth 2>/dev/null
python3 -c 'import site, glob, os; print(glob.glob(os.path.join(site.getsitepackages()[0], "*.pth")))'

Reemplaza las credenciales: el orden que importa

La limpieza de archivos no sirve de nada si no rotas las credenciales. La regla del reporte oficial es tajante: si 1.107.0 estuvo instalado en un host con salida a internet, da por hecho que la exfiltración funcionó. La ausencia del marcador de ejecución no demuestra lo contrario, porque los contenedores y la limpieza de /tmp se lo llevan.

El orden de rotación que propongo, del mayor al menor radio de impacto:

  1. El rol IAM de la instancia o tarea. Si la app corre en una VM con un rol de instancia, ese rol puede ser el activo más valioso del host, porque permite asumir cualquier permiso que tenga. Rotarlo exige cambios en la infraestructura, no en la aplicación.
  2. Las claves de proveedores guardadas en la base del proxy, más las master keys y las virtual keys. El stealer apuntaba justamente a esa tabla.
  3. La contraseña del DATABASE_URL, y con ella cualquier cadena de conexión que aparezca en el historial de shell.
  4. Todo secreto que estuviera en el entorno de cualquier proceso del host. Variables de entorno de servicios, tokens de webhook, credenciales de base de datos, claves de firma.
  5. Tokens de git, npm y PyPI, claves SSH y GPG, y las credenciales de las herramientas de IA que usas en esa máquina.

Y una verificación que muchos equipos se saltan: la tabla de credenciales del proxy, para saber qué rotar. No imprimas los valores, solo cuenta y enumera nombres de proveedor:

psql "$DATABASE_URL" -c 'SELECT count(*) FROM "LiteLLM_CredentialsTable";'

Si esa consulta te devuelve un número alto de filas, ya sabes cuántas claves upstream tienes que rotar. Recuerda rotar desde una máquina limpia, no desde el host comprometido: si tu sesión actual tiene una API key en la variable de entorno, esa key ya está en manos del atacante y tu terminal no es un lugar seguro para emitir el reemplazo.

Verifica el uso posterior: CloudTrail y LLMjacking

Wiz observó actividad posterior con cuatro horas y media de retraso. Entre las 16:20 y las 17:01 UTC, el atacante validó credenciales de rol de instancia de AWS robadas desde runners de CI y nodos de Kubernetes, usando sts:GetCallerIdentity y luego llamadas a bedrock:ListFoundationModels y ListModels. Ese patrón es consistente con LLMjacking: usan tu cuenta para servir modelos con tu presupuesto. Las IPs observadas fueron 168.100.8[.]57 y 165.227.201[.]8.

aws sts get-caller-identity
aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=GetCallerIdentity --max-results 20

En un número pequeño de entornos el atacante fue más allá: enumeró roles, usuarios y access keys con ListRoles, ListUsers y ListAccessKeys, y consultó detalles de cuenta y de logging con ListAccounts y DescribeTrails. Si ves eso en tu trail, el alcance de la intrusión es mayor que la rotación de claves.

Audita las imágenes Docker y los tags maliciosos

Los tags v1.107.0, latest, main-latest y main-stable sirvieron la imagen comprometida desde las 11:46:56 hasta las 14:20 UTC en Docker Hub, y en GHCR hasta las 13:51. Todas fueron retiradas o repuntadas a artefactos limpios. Lo relevante para tu inventario es que el índice de imagen comprometido es sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e, y que latest fue uno de los tags afectados: un docker compose pull automático entre esas dos fechas te trajo la imagen mala sin que cambiaras nada.

docker images --digests --format '{{.Repository}} {{.Tag}} {{.Digest}}' | grep -i litellm
docker ps -a --filter ancestor=litellm/litellm:v1.107.0 --format '{{.ID}} {{.Image}} {{.Status}}'

Si te aparece el digest comprometido en cualquier imagen local, el contenedor hay que tratarlo como comprometido, no solo reinstalado. Lo mismo aplica a los tags litellm-database y litellm-non_root en GHCR, que fueron publicados por el mismo workflow. Y recuerda que el release privado enterprise nunca estuvo expuesto, así que si usas el registro privado, no formas parte del incidente.

Lo que este incidente deja como lección de diseño

Hay un segundo ataque de cadena de suministro contra LiteLLM en 2026: el de marzo, que afectó las versiones 1.82.7 y 1.82.8 y fue atribuido a la campaña TeamPCP por Datadog Security Research. No se ha establecido conexión entre ambos casos hasta ahora, así que conviene no inventar un vínculo. Lo que sí es un hecho es que Hudson Rock analizó en agosto de 2026 un archivo de 153 GB robado en aquel ataque de marzo y lo vinculó con 2.488 dominios corporativos, incluyendo AWS, Samsung, Cisco y Salesforce; CloudSEK cerró el número en cerca de 2.500 organizaciones.

Ese contraste, un mismo proyecto comprometido dos veces en el mismo año, es lo que hace este caso distinto de un malware más. Pero el punto técnico que de verdad me llevo es otro, y es más chico y más portable: un token de GitHub CLI terminó en un log porque una respuesta de un modelo de lenguaje lo contenía y alguien logueó el body de la respuesta.

La mayoría de los equipos que tienen un gateway de LLM delante de sus agentes cree que el gateway es un proxy de tráfico, no un recolector de secretos. Loguear bodies completos de respuestas es, en la práctica, loguear cualquier credencial que el modelo haya mantenido en el contexto, y en un agente con shell la lista de credenciales que pasan por el contexto es más larga de lo que parece.

Dos medidas concretas que salen de ahí, y que no dependen de que fuerce a LiteLLM a arreglar nada: excluye de los logs los bodies de respuestas de tus agentes y de tu gateway de LLM, y trata cualquier token de GitHub CLI que haya pasado por un log como un token filtrado, aunque creas que el log era interno. La rotación de ese token cuesta un minuto. Lo caro es haberlo dado por bueno durante seis meses.

Preguntas frecuentes

¿Cómo sé si mi instalación de LiteLLM quedó comprometida?

Si el host tiene litellm 1.107.0 instalado, o si instalaste una versión sin fijar entre las 11:39 y las 13:23 UTC del 21 de octubre de 2026, está comprometido. El indicador físico es la presencia de cook.pth, cook.py o sitecustomize.py en cualquier site-packages del host. Si no aparecen esos archivos pero instalaste la versión maliciosa, sigue tratando el host como comprometido: el malware borra sus rastros.

¿Basta con actualizar a la versión 1.106.2 para limpiar el entorno?

No, y este es el error más caro del caso. No debes empezar por pip install: arrancar pip es arrancar Python, y eso ejecuta de nuevo cook.pth y sitecustomize.py. Primero borra los tres archivos a mano de todos los site-packages, mata el daemon que sostiene el lock en /tmp/remote_code_loader.lock y recién entonces reconstruye el entorno virtual desde cero con litellm==1.106.2.

¿Qué credenciales debo rotar primero si el host estuvo expuesto?

Por radio de impacto: el rol IAM de la instancia o tarea primero, porque puede asumir cualquier permiso que tenga el rol; después todas las claves de proveedores que estaban guardadas en la base del proxy, incluyendo master keys y virtual keys; luego la contraseña del DATABASE_URL y todo secreto presente en el entorno de cualquier proceso del host. La rotación debe hacerse desde una máquina limpia, no desde el host comprometido.

¿Cómo compruebo si el atacante ya usó mis credenciales de AWS?

Busca llamadas sts:GetCallerIdentity en CloudTrail en la ventana posterior a la instalación, y luego uso de las credenciales del rol de instancia desde fuera de tu infraestructura. En los casos observados el patrón incluye llamadas a bedrock:ListFoundationModels, que indican LLMjacking: uso de tu cuenta para servir modelos a costa tuya. Revisa también las IPs 168.100.8[.]57 y 165.227.201[.]8 como indicadores conocidos.

¿Las imágenes Docker de LiteLLM siguen siendo seguras hoy?

Los tags v1.107.0, latest, main-latest y main-stable fueron retirados o repuntados a artefactos limpios el 21 de octubre, y el registro privado enterprise nunca estuvo expuesto. Lo que no es seguro es confiar en un tag móvil como latest en producción: fija versiones y, sobre todo, digests inmutables en tus manifiestos para que un tag comprometido no entre por la puerta de atrás.

Lee también

Alfonso Moreno | Sysadmin Senior

Soy Alfonso, sysadmin senior con RHCE y más de 10 años administrando sistemas Linux para empresas y proyectos personales. Me especializo en infraestructura, redes, virtualización, monitoring, automation y self-hosting. Sobre SoyLinux.com SoyLinux.com es un blog técnico en español sobre Linux, software libre y self-hosting. Lo mantengo en mi tiempo libre. Áreas de expertise - Linux server administration: RHEL, CentOS, Rocky, Alma, Fedora, Debian, Ubuntu - Network services: Postfix, BIND, Samba, NFS, Squid - Virtualización y containers: KVM, Proxmox, Docker, Podman - Monitoring: Prometheus, Grafana, Zabbix - Automation: Ansible, Bash scripting - Self-hosting: Vaultwarden, Nextcloud, Jellyfin, Immich, Homelab Contacto - Email: [email protected]

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *