Volver a todas las publicaciones
Publicado el · por Renaud Deraison

Primero retiraron las versiones seguras

El 20 de agosto alguien publicó un arrayref envenenado en crates.io y, dieciséis segundos después, retiró las versiones seguras que quedaban detrás. El propio aviso de Cargo pasó entonces a señalar la única versión moderna que seguía en pie. La carga estaba en un script de compilación, así que con hacer type-check del proyecto bastaba para ejecutarla. Bromure Agentic Coding convierte toda la operación en un problema de cronómetro que ella pierde: los crates envenenados vivieron 86 minutos, y la barrera de edad por defecto es de dos días.

El atacante no tuvo que engañar a nadie para que instalara malware. Hizo desaparecer las versiones limpias y dejó que el propio consejo de actualización del registro hiciera el resto.

A las 07:15:00 UTC del 20 de agosto, [email protected] apareció en crates.io bajo la cuenta de su verdadero mantenedor. Veinticuatro segundos más tarde el atacante empezó a retirar las versiones 0.3.5 a 0.3.9: dieciséis segundos de trabajo automatizado para vaciar el estante que quedaba detrás.

El yank es el mecanismo de retirada de crates.io, la palanca que un mantenedor acciona cuando una versión sale rota. Cargo responde a eso como uno querría. Te avisa de que una versión de tu lockfile ha sido retirada y se niega a elegir una versión retirada en una resolución nueva. Todo consejo que sigue a un aviso así te dice que actualices.

Así que el atacante retiró las versiones seguras, dejó en pie una sola versión moderna y dejó que hacer lo correcto te llevara directo a ella.

Noventa minutos, de principio a fin

El aviso del Rust Security Response WG y la cronología reconstruida por StepSecurity describen una operación que el atacante llevaba dos días preparando.

El 18 de agosto a las 01:25:58 registró en crates.io una cuenta llamada dtolney, a una letra de David Tolnay, cuyo nombre figura en la mayoría de los crates proc-macro del ecosistema. Media hora después publicó un [email protected] limpio, un typosquat de proc-macro2 que no hacía absolutamente nada. Esa versión existía para darle historial a la cuenta.

La de verdad llegó cinco horas más tarde. [email protected] subió a las 07:11:15 del 20 de agosto con un build.rs malicioso. Cuatro minutos después, [email protected] salió de la cuenta de su dueño legítimo con un solo cambio: una nueva línea de dependencia que apuntaba a proc-macro1 ^1.0.107. El Security Response WG es cuidadoso con el mantenedor aquí, y hace bien. Dicen que “no creen que el autor de arrayref esté actuando de forma maliciosa” y señalan que “su ordenador o sus credenciales están probablemente comprometidos”. [email protected] siguió a las 07:34:07 y [email protected] a las 07:37:49.

Después terminó casi tan rápido como había empezado. El equipo de investigación de Nextron Systems encontró el crate malicioso y lo reportó. crates.io retiró arrayref a los 86 minutos, internment a los 90 y append-only-vec a los 107, borró seis crates del atacante y bloqueó la cuenta comprometida. RustSec no registró ninguna evidencia de que nadie compilara contra una versión envenenada.

Noventa minutos es una respuesta rápida. Para quien tuviera una compilación corriendo dentro de la ventana, aun así llegó después.

Dos días de preparación, noventa minutos de exposición18 AGO · 01:25cuenta “dtolney” registradaa una letra de un nombre en elque ya confía todo dev de Rust01:55 · señuelo limpio 1.0.10620 AGO · 07:11:15[email protected]typosquat de proc-macro2con un build.rs maliciosoun nombre que nadie teclea20 AGO · 07:15:00[email protected]cuenta real, credenciales robadascódigo de la biblioteca intactouna nueva línea de dependencia20 AGO · 07:15:24 → 07:15:40 — dieciséis segundosarrayref 0.3.5, 0.3.6, 0.3.7, 0.3.8, 0.3.9 — todas retiradasel mecanismo de retirada del registro, al revés: Cargo avisa de que tu versión fijada fue retirada y la rechaza al resolver de nuevoQueda una sola versión moderna instalable. Ejecutas cargo update para quitar el aviso.build.rs se ejecuta en cargo build, cargo check y cargo test — nunca se llama a ninguna función de la biblioteca
La vía de entrega nunca tocó el criterio de un desarrollador. Una dependencia de compilación typosquatteada se publicó cuatro minutos antes que el crate que la arrastraba, todas las versiones seguras que había detrás de ese crate se retiraron en una ráfaga de dieciséis segundos, y el propio aviso de Cargo sobre la retirada señalaba la única versión moderna que quedaba.

Nada de la biblioteca tenía que llamarse

La carga vive en un script de compilación, y los scripts de compilación se ejecutan en tiempo de compilación con tus privilegios. The Hacker News informa de que la carga se disparaba durante cargo build, cargo check y cargo test. El del medio es la opción prudente: lo que tu editor ejecuta al guardar, lo que tecleas cuando quieres la opinión del compilador sin ejecutar nada. Aquí bastó.

El script en sí es corto y nada sutil una vez lo tienes delante. Reensambla https://23.254.165[.]112:9089/ a partir de fragmentos base64, desactiva la verificación de certificados TLS, descarga una carga acorde a tu sistema operativo y arquitectura, y la ejecuta desacoplada. El detalle elegante es std::mem::forget(child), que suelta el handle para que el proceso escape del job object de Cargo. El implante sigue corriendo y el compilador termina con una compilación en verde.

Lo que baja después es un infostealer. El análisis de Wiz lo describe emitiendo balizas por HTTPS POST hacia /49890878, recogiendo nombre de host, nombre de usuario, sistema operativo y aplicaciones instaladas, y consultando las bases SQLite de contraseñas de Chrome, Brave y Edge. Persiste como clave Run del Registro en Windows, como LaunchAgent en macOS y como servicio de usuario systemd en Linux, y si su C2 se apaga recurre a un algoritmo de generación de dominios que produce diez dominios .com cada cinco días. Wiz vincula la infraestructura con actividad de la RPDC: el mismo endpoint /49890878 que la campaña npm de Mastra, un emisor de certificados compartido y el uso repetido de un rango de Hostwinds.

Los binarios que envió cubren Linux x86_64, Windows y macOS, más macOS aarch64. Ese último es un portátil con Apple Silicon. Alguien pensó en quién compila Rust.

La dependencia que nunca elegiste

arrayref tiene 245 millones de descargas acumuladas, y casi nadie la puso en un Cargo.toml. StepSecurity contó 406 versiones de crates dependientes, con arrayref sentado bajo winit y por tanto bajo egui e iced, bajo blake3 y blake2b_simd, y bajo buena parte del utillaje de Ethereum y Solana. internment y append-only-vec suman otros 19 millones de descargas entre las dos.

Así que el consejo de revisar tus dependencias antes de añadirlas no llega hasta aquí. Nadie la añadió. Llegó cuatro niveles más abajo, bajo un crate que sí añadiste, en un diff de lockfile que pasa de largo. Leer el código de la biblioteca tampoco habría servido. Nadie tocó el código fuente de los tres crates envenenados; todo el cambio era una línea de dependencia que nombraba un crate de dos días de antigüedad, publicado por una cuenta cuyo nombre parecía correcto.

Por qué le importa a un blog sobre programación agéntica

Mira otra vez el disparador. Un aviso de retirada aparece en la salida de la compilación. Algo lo limpia ejecutando cargo update.

Ese algo muchas veces ya no es una persona. Limpiar un aviso es la tarea doméstica que un agente hace de camino a lo que le pediste: ve una versión retirada, conoce el arreglo, aplica el arreglo. Trabaja mientras duermes, en bucle, sobre una cola entera de tareas. Refresca lockfiles porque una compilación falló y añade crates porque una funcionalidad necesitaba uno.

Un agente trabajando sin supervisión es el actor con más probabilidades de haber estado dentro de una ventana de 86 minutos, y el que menos probabilidades tiene de detenerse ante una dependencia transitiva desconocida publicada por un nombre que reconoce. Dale una cuenta de mantenedor comprometida, un typosquat a una letra de David Tolnay y un aviso del registro que recomienda la actualización, y no queda nada en la cadena que el criterio pueda atrapar.

Puedes sobrevivir a eso, porque el criterio es de todos modos un mal sitio donde decidir esto.

Bromure lo convierte en un problema de cronómetro

Todo perfil de Bromure Agentic Coding viene con una barrera de edad activada, fijada en dos días. El proxy MITM del host ve cada descarga de paquete que hace la VM, en npm, PyPI, Cargo, RubyGems, Maven, NuGet, módulos de Go y Packagist, y las versiones más jóvenes que el corte no vuelven.

Cargo recibe el tratamiento completo. Bromure reconoce index.crates.io, crates.io y static.crates.io, y entonces reescribe los metadatos del crate al vuelo: quita de la lista de versiones las demasiado frescas y fija max_version, max_stable_version y newest_version en la versión más reciente que sobrevive al filtro. El resolutor de Cargo nunca llega a enterarse de que existe una versión más nueva.

Ahora pon los números uno al lado del otro.

La edad máxima que alcanzó cada versión, frente a un corte de dos días[email protected]4 minutos de vida cuando arrayref lo arrastró[email protected]86 minutos en el registro[email protected]90 minutos[email protected]107 minutosBarrera de edad de Bromure2 días — 2.880 minutos — activa por defecto, ajustable de 0 a 90Filtrado en el proxy del host: las versiones demasiado frescas salen de los metadatos y los campos de “versión más reciente” se reescriben antes de Cargo.
Todas las versiones de este ataque nacieron y murieron dentro de una ventana que la política por defecto no abre. La barrera de edad no es un juicio sobre el paquete; es un reloj, y la operación no tenía forma de esperar a que pasara sin ser descubierta.

Nada en esta operación llegó a los dos días de antigüedad. [email protected] tenía cuatro minutos cuando arrayref lo nombró como dependencia. Ninguno de los dos crates es visible para un resolutor que esté detrás de la barrera, así que cargo update no encuentra nada nuevo.

Lo verás en tu pantalla. Con 0.3.5 a 0.3.9 retiradas y 0.3.10 filtrada, una resolución nueva de arrayref puede volver sin poder elegir una versión, lo cual es un error en lugar de un éxito silencioso. Un Cargo.lock existente fijado en 0.3.9 sigue compilando, porque una retirada solo bloquea la resolución nueva. En cualquier caso pasas ese jueves por la mañana leyendo un mensaje del resolutor en vez de rotando todas las credenciales que tienes.

Bromure comprueba una segunda vez, por si el resolutor ya tenía su respuesta. Contrasta cada descarga de artefacto, el propio fichero .crate, con la hora de publicación registrada, y devuelve un 451 que nombra la edad: publicado hace 41 minutos, la política exige 2 días como mínimo. Una caché de metadatos caliente no le compra nada al atacante.

El primer salto del dropper no lleva a ninguna parte

Supón que hubieras apagado el reloj. Alguien exime un paquete, o baja el corte durante una tarde. El script de compilación todavía tiene que alcanzar 23.254.165.112 en el puerto 9089.

Cada perfil de Bromure lleva su propia política de salida, escrita como un pequeño conjunto de reglas al estilo pf:

allow web crates.io
allow web static.crates.io
allow web index.crates.io
allow tcp api.anthropic.com:443
default deny

Tu Mac la aplica en dos capas que comparten un único conjunto de reglas: el conmutador virtual, que casa por IP de destino y por nombre de host espiado en el DNS a través de todos los protocolos, y el proxy, que casa por SNI de TLS y método HTTP. Una IP pelada en el puerto 9089 no casa con ninguna regla de ese fichero, así que cae en default deny y la conexión muere del lado del host de la frontera. El DGA de la fase 2, diez dominios .com nuevos cada cinco días, se topa con el mismo muro, porque un nombre generado sigue siendo un nombre que nadie puso en la lista. Cada denegación aterriza como una fila egress.firewall en Window → Security Timeline, con host, IP, puerto y veredicto, escrita donde el código de dentro de la VM no puede llegar.

Ahí dentro no hay nada que robar

Lee la lista de recuperación que vino con este incidente: rotar claves SSH, tokens de nube, tokens de API, claves de firma. Purgar la caché del registro. Limpiar las capas de caché de CI. Reconstruir los directorios vendor. En cada máquina que compiló durante la ventana.

Un espacio de trabajo Bromure responde a casi toda esa lista con una habitación vacía.

Las credenciales de verdad nunca entran en la VM. La ANTHROPIC_API_KEY de ese invitado es un marcador brm_…. El kubeconfig guarda certificados de cliente desechables. La autenticación del registro de contenedores es una cadena Basic derivada. El token de GitHub es falso, y la clave SSH es una clave ed25519 generada por perfil para ese espacio de trabajo, no la de tu portátil. El proxy MITM del host sustituye el valor real en la petición saliente, y con AWS va un paso más allá: el host vuelve a firmar la petición SigV4 con el material real, de modo que cualquier cosa que rodee el proxy recibe un InvalidSignatureException de Amazon en lugar de una llamada que funcione.

Un infostealer que barre ese invitado buscando credenciales encuentra credenciales. Se parsean, parecen correctas y no valen nada fuera de la máquina.

El resto de su lista de la compra está al otro lado del hipervisor. Chrome, Brave y Edge guardan sus bases de contraseñas en tu Mac, en un perfil de usuario al que el invitado no tiene camino. El LaunchAgent de macOS que quiere escribir, la razón por la que envió un binario macOS aarch64, no tiene ningún macOS donde escribirse. Puede instalar un servicio de usuario systemd en el propio home del invitado, y esa capa tiene un botón: Erase home… devuelve el /home/ubuntu del perfil a su estado justo después del clonado, y Reset to base… vuelve a clonar el disco de sistema del espacio de trabajo desde la imagen base de solo lectura.

Y el inventario ya lo tienes

La última línea de toda guía de remediación para este incidente es un grep: buscar en cada Cargo.lock de cada máquina seis nombres de crate y tres cadenas de versión, y después buscar los tarballs en ~/.cargo/registry/cache. Es una instrucción estupenda y una tarde miserable, y solo funciona en las máquinas que todavía conservas.

Bromure registra la respuesta según ocurre. Cada descarga de paquete a través del proxy escribe una fila supply_chain.fetch en Window → Security Timeline con el ecosistema, el paquete, la versión, el resultado y la razón detrás del resultado, del lado del host, junto a cada sustitución de credencial y cada veredicto del cortafuegos. Cuando el próximo aviso nombre una versión y una ventana de dos horas, la pregunta “¿algo de aquí descargó eso?” será una búsqueda en vez de una expedición.

Bromure Enterprise Manager agrega el mismo flujo a lo largo de una flota, que es la forma en la que llega la pregunta: ¿alguna de las noventa máquinas que corrían agentes durante la noche lo compiló?

La parte que vale la pena guardar

crates.io lo hizo bien aquí. Noventa minutos desde el reporte hasta la retirada, seis crates del atacante eliminados, la cuenta comprometida bloqueada, un aviso claro y ninguna evidencia de que nadie fuera golpeado. El equipo de Rust además se esforzó por no culpar a un mantenedor al que le robaron las credenciales, que es el instinto correcto y no siempre el habitual.

Alguien reutilizará el mecanismo, sin embargo, porque es barato y es ingenioso. Una retirada es una palanca pública, instantánea y de una sola línea que hace que un registro recomiende una actualización, y todos los ecosistemas tienen una. Lo que sea que ejecute tus compilaciones verá esa recomendación y actuará en consecuencia, y a lo que actúa hoy en consecuencia le falta a menudo un jueves por la mañana de contexto sobre qué crate es cuál.

Así que pon la decisión en algún sitio que no exija contexto. Dos días de luz entre la publicación de una versión y tu compilación no son un juicio sobre un paquete, un mantenedor o un nombre de cuenta que se queda a una letra. Son un reloj, y esta operación tuvo noventa minutos.


Fuentes: Rust Security Response WG, “Supply chain attack on arrayref” (20 de agosto de 2026) · Wiz, “Rust Supply Chain Attack on arrayref: Significant Overlap with DPRK Campaigns” (20 de agosto de 2026) · StepSecurity, “arrayref, internment, and append-only-vec Poisoned by the proc-macro1 Build-Time Dropper” (20 de agosto de 2026) · The Hacker News, “Rust Supply Chain Attack Puts Build-Time Malware in Crates with 245 Million Downloads” (20 de agosto de 2026) · BleepingComputer, “Hackers poison arrayref Rust crate to push infostealer malware” (20 de agosto de 2026) · Semgrep, “Rust crates arrayref & append-only-vec compromised” (20 de agosto de 2026)