LiteLLM 1.107.0 y el .pth que Python aún ejecuta al arrancar
Ver imagen del artículo

python -c pass no hace nada: arranca el intérprete, no ejecuta ninguna instrucción y sale. En un entorno con litellm 1.107.0 instalado, esa orden bastaba para lanzar el ladrón de credenciales que alguien coló en PyPI el 21 de octubre. pip --version, también.
No he tenido ese paquete en las manos: PyPI lo puso en cuarentena antes de las 13:23 UTC. He reproducido el mecanismo con un fichero inofensivo, he medido qué lo para en Python 3.12 y 3.15.1 y he buscado en mi servidor qué ficheros hacen lo mismo con todo el derecho.

Lo Probado es del 25 de octubre de 2026, entre las 06:14 y las 06:53 UTC, con el Python 3.12.3 de Ubuntu 24.04, un CPython 3.15.1 compilado aparte, entornos virtuales nuevos y find y grep de solo lectura. Los avisos y la documentación van como Revisado (así los distingo).
Qué pasó con LiteLLM
LiteLLM es, según su repositorio, una pasarela para llamar a más de cien API de modelos de lenguaje con el formato de OpenAI. Su equipo publicó el 21 el aviso del incidente y el 23 la cadena completa del ataque, que corrige al primero.
El primer token de GitHub no salió de los registros de un despliegue interno, como sospechaban el 21, sino de la API de su plataforma de agentes. Esto es lo que cuenta, en hora UTC:
De una clave olvidada a PyPI
2026-05-27
Una clave que nadie revocó
Un fichero con la clave maestra de su plataforma de agentes llega a un repositorio público. Reescriben la historia para taparlo, pero, según LiteLLM, «key revocation did not occur for the master key».
2026-10-10
Del token a los secretos
Con esa clave, el atacante pide a la API de la plataforma su lista de agentes, y la respuesta trae el token de GitHub CLI de un propietario de la organización. Esa noche, flujos de CI envían secretos a webhook.site.
2026-10-21
La versión envenenada
De 11:39:51 a 11:40:13 UTC suben a PyPI siete ficheros wheel de litellm 1.107.0, construidos desde Berri-AI/litellm, una copia que imita el nombre de BerriAI. A las 11:46 salen las imágenes de Docker Hub y GHCR. PyPI pone la versión en cuarentena antes de las 13:23; Docker Hub queda limpio a las 14:20.
2026-10-23
La cadena, contada
LiteLLM publica su análisis completo y lo declara el registro canónico: algunas suposiciones de sus avisos del día 21 «were later proven to be inaccurate or incomplete».
Lo que llevaba la 1.107.0
Los paquetes traían tres ficheros que no son de LiteLLM: cook.pth, cook.py y sitecustomize.py. Según el análisis de Socket, cook.pth entero es una línea, import cook, que carga el recolector de credenciales; sitecustomize.py arranca aparte un cargador que pide código a un servidor remoto.
Lo que me saca de quicio no es el .pth, aunque en marzo le colaron otro (la 1.82.8 traía un litellm_init.pth, según su aviso). Es la clave de mayo, que se tapó reescribiendo la historia y en octubre seguía abriendo puertas. Una clave publicada hay que darla por copiada; solo la limpia revocarla.
[WARN] Si instalaste la 1.107.0 o arrancaste esas imágenes, no lances python ni pip ahí: cada arranque vuelve a ejecutar sus ficheros. LiteLLM pide borrarlos desde la terminal (con sus .pyc), matar el proceso que tiene /tmp/remote_code_loader.lock, rehacer el entorno y rotar todo secreto al que llegara la máquina. Si tenía salida a internet, da el robo por hecho.
Qué ejecuta Python antes de tu programa
Cada vez que arranca, Python importa site, que recorre los directorios de paquetes. En un .pth, cada línea es una ruta que se añade a sys.path. Pero, dice la documentación, «for backward compatibility, lines starting with import (followed by space or tab) are executed with exec()». Sin importar el paquete y sin que nadie lo llame.
Para verlo sin peligro, metí en un entorno virtual un .pth cuya única línea escribe en un fichero los argumentos con los que arrancó Python. Esto ejecuté y esto salió:
# Entorno nuevo con el Python 3.12.3 de Ubuntu 24.04
LAB=$PWD
python3 -m venv v312
SP=$(v312/bin/python -I -c 'import sysconfig; print(sysconfig.get_path("purelib"))')
# Una línea «import» en un .pth: no roba nada, solo apunta quién arrancó
echo "import sys; f = open('$LAB/marcas.log', 'a'); f.write(' '.join(sys.orig_argv[1:]) + '\n'); f.close()" > "$SP/zz_marcador.pth"
v312/bin/python -c pass
v312/bin/python -I -c pass
v312/bin/python -S -c pass
v312/bin/pip --version > /dev/null
cat marcas.log
# -c pass
# -c pass
# -I -c pass
# -I -c pass
# v312/bin/pip --version
# v312/bin/pip --version
Dos cosas que no esperaba. La primera: -I no lo para. El modo aislado deja fuera las variables PYTHON* y el sitio del usuario, pero procesa igual el site-packages del entorno. Ni -s ni -E. Solo -S lo evita, porque no carga site, y entonces tampoco encuentra los paquetes del entorno: python -S -m pip falla.
La segunda: en el Python 3.12 de Ubuntu, en un entorno virtual, la línea corre dos veces por arranque. Una traza muestra a site.py procesando el directorio desde venv() y desde main(). Es el fallo gh-75723 de CPython, abierto en 2017 y cerrado en mayo de 2026; en 3.15.1 corre una vez.
[INFO] Con import cook la repetición no cambia nada: la segunda vez Python encuentra el módulo ya cargado y no lo vuelve a ejecutar. Lo comprobé con un .pth de una sola línea, import zz_modulo: el módulo escribió su marca una vez.
En Ubuntu, el sitecustomize de serie va primero
El otro gancho, sitecustomize, funciona distinto: Python importa el primero que encuentra en sys.path. Ubuntu 24.04 trae uno, /usr/lib/python3.12/sitecustomize.py, enlace a /etc/python3.12/sitecustomize.py del paquete libpython3.12-minimal, que instala el gancho de apport. Y ese directorio va antes que el site-packages del entorno.
Medido: el sitecustomize.py que dejé en el entorno de 3.12 no se ejecutó; en mi 3.15.1 compilado, que no trae ninguno, sí. No sé qué hace la carga de LiteLLM cuando su sitecustomize.py no llega a cargarse, porque no tengo el paquete. El cook.pth corre igual.
Python 3.15 lo desaprueba, pero lo ejecuta
PEP 829, de Barry Warsaw, aceptada el 24 de abril, entra en 3.15: el código de arranque se declara en ficheros .start (paquete.módulo:función) y las líneas import de los .pth quedan desaprobadas. Se ejecutan hasta 3.17 y se ignorarán en silencio en 3.18 y 3.19.
El aviso es discreto: según la documentación de 3.15, solo sale con -v; ni -W error ni -X dev lo sacan. Lo comprobé con el mismo marcador en Python 3.15.1, la versión de seguridad adelantada del 21 de octubre, cuyo pip 26.2.2 trae el certifi del incidente de eMudhra:
python3.15 -m venv v315
# (el mismo zz_marcador.pth, ahora en v315)
v315/bin/python -c pass # la marca aparece una vez, sin ningún aviso
v315/bin/python -W error -X dev -c pass # tampoco avisa
v315/bin/python -v -c pass 2>&1 | grep 'are deprecated'
# import lines in .../v315/lib/python3.15/site-packages/zz_marcador.pth are
# deprecated, use entry points in a .../zz_marcador.start file instead.
La propia PEP no se vende como un candado:
The overall pre-start code execution attack surface is not eliminated by this PEP.
PEP 829, «Security Implications»
También lo medí: un .start con cualquier nombre ejecuta su función en cada arranque, igual que el .pth. Lo que se gana es que se ve: un fichero aparte y una función con nombre, en vez de código pegado en una línea de rutas.
Hay otra razón para no esperar nada de 3.15 aquí: las versiones recientes de litellm declaran en PyPI Requires-Python <3.15, y en mi 3.15.1 pip se queda en la 1.83.7, de abril. Quien usa un LiteLLM actual está en 3.14 o antes, donde esas líneas ni siquiera están desaprobadas.
Buscar sin arrancar Python
El aviso de LiteLLM lo dice claro: «Don’t start with pip install». Si el entorno está infectado, cualquier Python que arranque en él vuelve a ejecutar la carga. Así que la búsqueda va con find y grep, que no saben nada de site:
#!/bin/bash
# Busca ganchos de arranque de Python sin arrancar Python: solo find y grep.
# Uso: buscar-arranque.sh [raíz...] (por defecto /)
# -xdev no cruza a otros sistemas de ficheros: si /home o /var van aparte,
# pásalos como raíz (ahí quedan los site-packages de pip install --user).
raices=("${@:-/}")
echo "== indicios de LiteLLM 1.107.0 y ganchos sitecustomize/usercustomize"
find "${raices[@]}" -xdev \( -name 'cook.pth' -o -name 'litellm-1.107.0.dist-info' \
-o -regex '.*-packages/\(site\|user\)customize\.py' \) -print 2>/dev/null
echo "== .pth con líneas que site.py pasa a exec()"
find "${raices[@]}" -xdev -type f -regex '.*-packages/[^/]*\.pth' \
-exec grep -l -E '^import[[:space:]]' {} + 2>/dev/null
echo "== .start (Python 3.15 o posterior)"
find "${raices[@]}" -xdev -type f -regex '.*-packages/[^/]*\.start' -print 2>/dev/null
La expresión [^/]*\.pth solo mira el primer nivel de site-packages y dist-packages, que es donde los lee site. El grep usa la misma regla que Python: import seguido de espacio o tabulador.
Qué salió en mi servidor
Lo pasé por mi servidor, sobre /usr, /opt, /srv y /var/lib: tardó menos de cinco segundos y no encontró rastro de LiteLLM 1.107.0. Sí encontró cuatro .pth con líneas import, los cuatro legítimos:
distutils-precedence.pth, del paquete python3-setuptools de Ubuntu (lo dicedpkg -S).- Otros dos
distutils-precedence.pth, de setuptools, dentro de dos entornos que traen programas instalados en/opt(lo dice elRECORDde cada paquete). zope.interface-6.1-nspkg.pth, de python3-zope.interface: el truco antiguo de los paquetes de espacio de nombres, con unsys._getframe(1).f_locals['sitedir']que da un poco de miedo leer.
Por eso no sirve alarmarse con cualquier .pth que ejecute código: hay que saber quién lo puso. Para los de Ubuntu, dpkg -S fichero; para los que instaló pip, grep -l '^nombre.pth,' site-packages/*.dist-info/RECORD.
[INFO] Si buscas sitecustomize.py por nombre, como pide el aviso, en Ubuntu siempre saldrá el legítimo de /usr/lib/python3.12. Por eso el guion solo lo busca dentro de site-packages y dist-packages.
Un día de espera para pip
La 1.107.0 estuvo en PyPI menos de dos horas. Desde la 26.0, del 30 de enero, pip tiene --uploaded-prior-to, que descarta lo subido después de una fecha; desde la 26.1, del 26 de abril, acepta plazos como P3D (registro de cambios).
El pip que trae python3 -m venv en Ubuntu 24.04 es el 24.0 y no conoce la opción. Primero se actualiza pip dentro del entorno; luego, la prueba en seco, que no instala nada:
python3 -m venv espera
espera/bin/python -m pip install -q --uploaded-prior-to P1D litellm
# no such option: --uploaded-prior-to (pip 24.0)
espera/bin/python -m pip install -q 'pip>=26.1'
espera/bin/python -m pip --version
# pip 26.2.2 from .../espera/lib/python3.12/site-packages/pip (python 3.12)
# ¿Qué habría elegido pip con un día de espera a las 12:00 UTC del 21?
espera/bin/python -m pip install --dry-run --no-deps -q --report - \
--uploaded-prior-to 2026-10-20T12:00:00Z litellm \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["install"][0]["metadata"]["version"])'
# 1.106.0
La fecha tiene truco. A mediodía UTC del 21, la 1.107.0 llevaba veinte minutos en PyPI; con un día de espera, el corte cae al mediodía del 20 y pip elige la 1.106.0. Es una cuenta, no una medida: la 1.107.0 ya no está y no puedo repetirlo con ella delante.
Funciona, y es una línea. Para no depender de acordarse, va en el pip.conf del propio entorno o en la variable PIP_UPLOADED_PRIOR_TO. Con tres límites:
- Solo vale si el índice publica la hora de subida, como hace PyPI. Con un espejo propio no lo he probado.
- Retrasa igual una versión que tapa un fallo de seguridad.
- No salva de un paquete malicioso que nadie detecta durante el plazo. A la 1.107.0, el escáner de Socket la marcó a los siete minutos de publicarse, y el de Chainguard, a los dieciséis, según cada empresa.
Comprobación
Que la espera está puesta se ve con pip config list, y su efecto, con la misma prueba en seco sin fecha:
printf '[install]\nuploaded-prior-to = P3D\n' > espera/pip.conf
espera/bin/python -m pip config list
# install.uploaded-prior-to='P3D'
espera/bin/python -m pip install --dry-run --no-deps -q --report - litellm \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["install"][0]["metadata"]["version"])'
# 1.106.2
Hoy, 25 de octubre, con tres días de espera sale la 1.106.2, subida el 21 a las 10:44 UTC; sin espera, la 1.106.3, del 24 a las 09:14, según las horas de subida de PyPI.
Vuelta atrás
No he tocado nada del sistema: todo vive en carpetas de trabajo. Para quitar la espera, se borra el pip.conf del entorno o la variable; para quitar el laboratorio, su carpeta entera, marcador incluido.
rm espera/pip.conf
unset PIP_UPLOADED_PRIOR_TO
rm -rf "$LAB"/v312 "$LAB"/v315 "$LAB"/espera "$LAB"/marcas.log
Qué no he probado
- El paquete 1.107.0 de verdad: lo pusieron en cuarentena antes de que yo mirara.
- Las imágenes oficiales
pythonde Docker, ni si susitecustomizese comporta como en mi 3.15.1 compilado. - uv, Poetry y conda: no sé si leen el
pip.confni cómo se les hace esperar. - Python 3.13 y 3.14: no sé si en ellos el
.pthde un entorno virtual corre dos veces. - Windows, macOS y ARM.
En la entrada de lazy import en Python 3.15 repasé lo nuevo de esa versión y no dije nada de PEP 829. Me equivoqué de prioridad: para quien administra máquinas, saber qué se ejecuta al arrancar importa más que unos milisegundos de arranque.
El resto de lo que he escrito sobre programación está en desarrollo web y WordPress. Imagen destacada: «Pycon 2013», de Ed Schipul, CC BY 2.0, vía Wikimedia Commons; es una foto de contexto.
$ tail -f comentarios.log
La conversación empieza aquí
Escribir un comentario ↓[esperando] 0 mensajes en el hilo
Nadie ha escrito todavía. Si lo has probado en tu máquina, cuenta qué te salió: otra versión u otro resultado también ayudan.