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

El código se ejecutó antes que el modelo

Pluto Security encontró 31 repositorios de Hugging Face que se hacían pasar por pequeños checkpoints de prueba de Qwen2. Ninguno contenía un modelo. Cada uno contenía un archivo Python que se ejecuta en cuanto el modelo se carga con trust_remote_code e instala un minero oculto, hooks de arranque y, en algunas versiones, una reverse shell. El repositorio principal pasó los escáneres de Hugging Face y llegó a 28.412 descargas, y una copia casi idéntica siguió en línea después de la retirada. Los agentes de código recurren a modelos de prueba diminutos cuando escriben tests. En Bromure Agentic Coding, esa carga se ejecuta en una VM desde la que el minero no alcanza su pool, la Security Timeline vincula cada conexión con la llamada a herramienta que la abrió, y un rewind elimina lo que plantó el payload.

El archivo de pesos medía 42 bytes y no tenía ningún tensor. El trabajo lo hacía un archivo Python que se ejecutaba en cuanto se cargaba el modelo, antes de que el código del modelo hiciera nada.

El 29 de octubre, Pluto Security publicó Load/Mine, su análisis de una campaña activa en Hugging Face y en ModelScope, de China, desde septiembre. Pluto encontró 31 repositorios maliciosos repartidos en siete cuentas. Se disfrazan de pequeños checkpoints de prueba de Qwen2, con nombres como qwen2-tiny, y entre todos sumaban más de 32.000 descargas registradas. BleepingComputer lo cubrió el mismo día.

Pluto resume la campaña en una frase: “The code runs before the model does” (el código se ejecuta antes que el modelo).

Cómo cargar un modelo se convierte en un minero

El ataque no usa pesos envenenados ni un exploit de pickle. Usa una función. Un modelo de Hugging Face puede incluir su propio código Python, y la biblioteca Transformers ejecuta ese código si quien llama pasa trust_remote_code=True. El config.json del repositorio indica un módulo y una clase en una entrada auto_map, como modeling_evil2.EvilModel. Transformers descarga ese archivo .py y lo importa. Todo lo que está en el nivel superior del archivo se ejecuta durante la importación, antes incluso de que se devuelva la clase.

En estos repositorios, model.safetensors mide 42 bytes. No hay ningún modelo dentro. El archivo Python hace todo el trabajo:

  1. Se atrinchera. Escribe en /tmp/.cch/svc.py un watchdog que relanza el minero cada dos minutos y añade un hook de arranque a ~/.bashrc, /root/.bashrc y /root/.profile. Las versiones de la primera oleada también plantan una clave SSH llamada qx-5090-op en authorized_keys y una tarea cron en /etc/cron.d/.sync-helper.
  2. Descarga el minero y lo esconde. El descargador se construye con un manejador de proxy vacío, así que ignora los ajustes HTTP_PROXY y HTTPS_PROXY de los que puede depender una empresa. El binario de 40,6 MB tiene los marcadores UPX alterados para que upx -d lo rechace. Se ejecuta desde un archivo anónimo en memoria, con el nombre de proceso [kworker/u20:1], y mina Pearl a través de Kryptex en el puerto 8048.
  3. Abre una puerta. Tres versiones añaden una reverse shell hacia 212.32.253.3 en los puertos 443 o 9443, que se reconecta cada vez que cae.
  4. Va a por el host. Un script relacionado de la segunda oleada monta los discos del host desde un contenedor privilegiado y se da root en el servidor de debajo.
Una carga de modelo, tres payloadsLa cargafrom_pretrained("…/qwen2-tiny", trust_remote_code=True)config.json · auto_mapmodeling_evil2.EvilModelmodel.safetensors: 42 bytes, 0 tensoresLa importación ejecuta el archivoel código de nivel superior correantes de devolver la clasePersistenciawatchdog en /tmp/.cch/svc.pyhooks en ~/.bashrc y /root/.profiletarea cron, clave SSH qx-5090-opMinero ocultoel descargador ignora HTTP_PROXYcorre en memoria como [kworker/u20:1]mina en prl-us.kryptex.network:8048Reverse shellhacia 212.32.253.3 en 443 o 9443,se reconecta si cae(tres versiones)
Lo que hace cargar uno de los modelos de la campaña, según el análisis de Pluto Security. config.json dirige a Transformers hacia un archivo Python, el archivo se ejecuta al importarse y el archivo de pesos de 42 bytes nunca se usa. El payload instala persistencia, arranca un minero disfrazado que ignora los ajustes de proxy y, en algunas versiones, abre una reverse shell.

Pasó limpio el escaneo y volvió

Hugging Face analiza las subidas con un antivirus y con herramientas de socios, de Protect AI y JFrog. Según Pluto, esas herramientas buscan malware conocido y formatos de modelo inseguros. No miran lo que hace el archivo Python. El repositorio principal de la segunda oleada, MENXIANGYU/qwen2-tiny, “scanned clean while it collected 28,412 downloads” (pasó limpio el escaneo mientras acumulaba 28.412 descargas). El veredicto de un escáner asociado sobre el archivo del payload fue que era “not a machine-learning model” (no es un modelo de machine learning).

Pluto informó de la primera oleada a Hugging Face y a ModelScope, y ambos eliminaron las cuentas. En pocos días aparecieron 19 repositorios nuevos con cuentas nuevas. Horas después del informe de Pluto, el operador publicó un script de escape de contenedores. Hugging Face desactivó el repositorio principal, pero BleepingComputer confirmó que una copia casi idéntica, htt0118/qwen2-tiny, seguía en línea con el mismo minero y la misma reverse shell.

Un modelo de prueba diminuto es lo que pide tu agente

La campaña se diseñó para servidores con GPU que ejecutan plataformas de inferencia como Xinference y GPUStack. El cebo también funciona en la máquina de un desarrollador, porque un agente de código busca un checkpoint pequeño de Qwen2 cuando escribe tests para código que llama a un modelo. Pídele a un agente que añada un test a tu pipeline de embeddings o a tu script de fine-tuning, y saldrá a buscar un modelo lo bastante pequeño para cargarse en segundos. Los nombres de esta campaña, qwen2-tiny y sus variantes, están hechos para coincidir con esa búsqueda.

El agente no tiene que ser descuidado para activar la opción. Cuando un repositorio incluye código propio, Transformers se detiene y le dice a quien llama que pase trust_remote_code=True. Para un agente que intenta arreglar un test que falla, es un error con la solución impresa en el mensaje. Añade el argumento y vuelve a ejecutar el test. Que el test pase o no ya da igual: el minero arrancó durante la importación.

En un portátil, ese archivo se ejecuta como tú. El hook de arranque en ~/.bashrc se ejecuta en cada terminal que abras a partir de entonces. La reverse shell le da al operador un prompt con tu ~/.ssh, tus credenciales de la nube, tu sesión de gh y tu HF_TOKEN. El consejo de Pluto para quien haya cargado estos modelos es tratar la máquina como comprometida y rotar todas las credenciales que el proceso pudiera alcanzar.

Entre las recomendaciones de bastionado de Pluto, una línea describe la configuración que quieres: ejecutar código de modelos no fiable “with limited privileges, restricted egress and no production secrets” (con privilegios limitados, salida de red restringida y sin secretos de producción).

El mismo test en Bromure Agentic Coding

Bromure Agentic Coding ejecuta Claude Code, Codex, Grok, Kimi y omp en VMs Ubuntu en tu Mac. Cada máquina tiene su propio disco, su propio home y su propia política de red, aplicada en el Mac, donde el agente no llega. Este es el mismo test en una máquina Bromure, una etapa del payload cada vez.

El minero nunca llega a su pool

Cada conexión que abre la VM pasa por el switch virtual y el proxy de Bromure en el Mac. Los puertos 80 y 443 se desvían al proxy en el switch, así que un descargador con un manejador de proxy vacío queda interceptado igualmente. El truco que usa la campaña para saltarse los ajustes de proxy corporativos no tiene nada que esquivar, porque la interceptación no depende de ningún ajuste dentro de la VM.

Pon Unmatched traffic en Deny en Sandbox Policy, o pasa la máquina al motor OpenShell + Sentry, donde todo se deniega salvo lo que una regla permite. La política inicial permite GitHub, npm y PyPI en solo lectura, además de los mirrors de Ubuntu, y tú añades huggingface.co para las descargas. Entonces la conexión del minero a prl-us.kryptex.network:8048 no coincide con ninguna regla y se rechaza. Lo mismo la reverse shell hacia 212.32.253.3, en cualquiera de los dos puertos. Con Tell the agent when policy blocks it, activado por defecto en las máquinas OpenShell, el agente también lee en su conversación qué se bloqueó y que debe parar y preguntarte, en lugar de pasarse los diez minutos siguientes buscando un rodeo.

En una máquina OpenShell con Enforce binaries, cada conexión también se asocia al programa que la abrió, y el hash de cada programa se fija la primera vez que se ejecuta. Un minero lanzado desde un archivo anónimo en memoria es un programa que ninguna regla nombra. Renombrar su proceso a [kworker/u20:1] no cambia eso, porque el atestador identifica el binario, no el nombre que se pone.

Ves qué llamada a herramienta lo hizo

El operador eligió el nombre [kworker/u20:1] para engañar a quien lea ps o top en un servidor cargado. En una máquina Bromure con el centinela del kernel, el kernel de la VM informa al Mac de cada programa que se lanza y de cada conexión, por un canal privado que el agente no puede cortar. Cada conexión bloqueada llega a la Security Timeline con la cadena que hay detrás, algo como Tool pytest tests/test_embed.py: claude → bash → python → TCP 212.32.253.3:443.

Haz doble clic en la fila y network lineage muestra la historia completa: el prompt que diste, lo que el agente escribió antes de la llamada, el comando exacto, cada proceso desde el agente hasta el que se conectó, y qué capa lo rechazó. Cuando una ejecución de tests marca una dirección IP a pelo, obtienes una fila roja con el comando de la carga del modelo.

El watchdog del agente, configurado en Quarantine en Guardrails, actúa sobre las mismas señales sin esperarte. Una ráfaga de bloqueos, o destinos nuevos una vez asentada la sesión, por encima de su umbral en menos de un minuto, y Bromure corta la red de la VM y la pausa en milisegundos. La alerta enumera lo que vio. Keep quarantined reanuda la VM con la red todavía cortada, para que puedas mirar dentro.

Portátil: el test se ejecuta como túEl agente carga qwen2-tiny para un testañade trust_remote_code=True ante el errorEl payload se ejecuta con tu cuentael minero alcanza el pool de Kryptex,la reverse shell alcanza 212.32.253.3Se queda, y el operador tiene un promptel hook de ~/.bashrc corre en cada terminal;~/.ssh, claves de nube, gh, HF_TOKEN a su alcanceBromure: el test se ejecuta en una VMMisma carga, en la VM Ubuntu de la máquinaHF_TOKEN, gh y claves de nube: sustitutasSwitch y proxy en el Mac: denegadopool :8048 y shell :443/:9443: sin regla;el centinela nombra la llamada; watchdog: cuarentenaReset disk, Rewind homedisco del sistema nuevo, de la imagen base;home devuelto al arranque previo al test
La misma carga de modelo en un portátil y en una máquina Bromure. En el portátil, el payload se ejecuta como tú, alcanza su pool y su shell, y se queda en tus archivos de arranque. En la máquina Bromure, el switch y el proxy en el Mac rechazan ambas conexiones, el centinela del kernel las vincula a la llamada a herramienta, el watchdog puede poner la VM en cuarentena, y Reset disk más Rewind home eliminan lo que se plantó.

Las claves en la VM son sustitutas

Supongamos que una versión del payload llega más lejos, en una máquina que dejaste con el firewall por defecto. El operador tiene una shell en una VM Ubuntu, y cada credencial que hay en ella es una sustituta. El GH_TOKEN del entorno y el token de ~/.git-credentials son falsos, y el proxy de tu Mac los cambia por el verdadero solo de camino a GitHub. Añade tu token de Hugging Face en Other API keys con el nombre de variable HF_TOKEN y un filtro de host huggingface.co, y la VM recibe una sustituta brm_… que solo funciona allí. Las claves SSH nunca entran en la VM: el agente puede pedir una firma, y los bytes de la clave se quedan en el Mac.

Una sustituta también es una trampa. Si el payload envía la sustituta de GitHub a cualquier sitio que no sea GitHub por HTTPS, el proxy bloquea la petición con HTTP 451, pausa la VM y abre una alerta con el nombre de la credencial y el host al que iba.

La limpieza son dos clics

La lista de limpieza de Pluto para un host infectado es larga: encontrar el watchdog, quitar los hooks de la shell, borrar la tarea cron, sacar la clave SSH de seis rutas authorized_keys, buscar registros fraudulentos y reconstruir desde una imagen de confianza si no tienes certeza. Una máquina Bromure ya tiene esa imagen de confianza. Reset disk vuelve a clonar el disco del sistema desde la imagen base, lo que se lleva /etc/cron.d/.sync-helper y todo lo demás que el payload escribió fuera del home. Rewind home… devuelve /home/ubuntu a un punto de control de un arranque anterior. Bromure toma uno cada vez que la máquina arranca, así que el ~/.bashrc de antes del test es uno de ellos. Tus carpetas compartidas con el host están en el Mac y no se tocan.

En una máquina OpenShell con Enforce binaries, buena parte de esa lista ni siquiera llega a escribirse. El agente se ejecuta sin root, así que los hooks en /root/.bashrc y /root/.profile, la tarea cron y las escrituras en el authorized_keys de root se rechazan, y cada rechazo es una fila Guest sandbox en la Timeline.

La salida se decide en el Mac

Los puertos 80 y 443 van al proxy digan lo que digan los ajustes de la VM. Con el tráfico sin coincidencia en denegar, el pool y la shell se rechazan.

Cada conexión tiene nombre

El centinela del kernel vincula cada conexión a la llamada a herramienta y a la cadena de procesos que hay detrás. Un minero disfrazado sigue apareciendo como la carga del modelo.

Sustitutas, no claves

Los tokens de GitHub, de la nube y de Hugging Face en la VM son falsos que solo funcionan en sus propios hosts. En otro sitio, disparan una alerta.

Una máquina limpia cuando quieras

Reset disk y Rewind home devuelven la VM a como estaba antes del test, persistencia incluida.

Trata un repositorio de modelos como un paquete

Pluto enumera sus indicadores en el informe. Si cargaste uno de estos modelos con el código propio activado, busca /tmp/.cch/, el proceso [kworker/u20:1] con argumentos de minería y los hooks de arranque, y después rota todas las credenciales que esa máquina pudiera alcanzar. Antes de activar trust_remote_code para cualquier repositorio, lee su modeling_*.py y fija el commit que leíste.

El informe de Pluto muestra un repositorio de modelos funcionando como un repositorio de código mientras los escáneres lo revisaban como un formato de archivo. Si tu agente elige modelos para sus tests, acabará cargando uno de estos archivos. En una máquina Bromure, ese archivo se ejecuta en una VM con claves sustitutas y una política de salida aplicada en el Mac, y después puedes devolver la máquina a un punto de control limpio.

Para un caso anterior en el que falló la propia comprobación de trust_remote_code, lee La flag dijo que no. Para ver cómo Bromure filtra los paquetes que instala un agente, lee La versión tenía dos horas.