Voltar para todas as publicações
Publicado em · por Renaud Deraison

Eles retiraram primeiro as versões seguras

Em 20 de agosto alguém publicou um arrayref envenenado no crates.io e, dezesseis segundos depois, retirou as versões seguras que ficavam atrás dele. O próprio aviso do Cargo passou então a apontar para a única versão moderna que restava de pé. A carga estava num script de build, então type-checar o projeto já bastava para executá-la. O Bromure Agentic Coding transforma toda essa operação num problema de cronômetro que ela perde: as crates envenenadas viveram 86 minutos, e o portão de idade padrão é de dois dias.

O atacante não precisou enganar ninguém para instalar malware. Ele fez as versões limpas desaparecerem e deixou o próprio conselho de atualização do registro fazer o resto.

Às 07:15:00 UTC de 20 de agosto, [email protected] apareceu no crates.io sob a conta de seu verdadeiro mantenedor. Vinte e quatro segundos depois, o atacante começou a retirar as versões 0.3.5 a 0.3.9: dezesseis segundos de trabalho automatizado para esvaziar a prateleira atrás dela.

O yank é o mecanismo de recall do crates.io, a alavanca que um mantenedor puxa quando uma versão sai quebrada. O Cargo responde a isso do jeito que você gostaria. Ele avisa que uma versão presente no seu lockfile foi retirada e se recusa a escolher uma versão retirada numa resolução nova. Todo conselho que vem depois de um aviso desses manda você atualizar.

Então o atacante retirou as versões seguras, deixou uma única versão moderna de pé e deixou que fazer a coisa certa levasse você direto até ela.

Noventa minutos, do começo ao fim

O comunicado do Rust Security Response WG e a linha do tempo reconstruída pela StepSecurity descrevem uma operação que o atacante vinha preparando havia dois dias.

Em 18 de agosto, à 01:25:58, ele registrou no crates.io uma conta chamada dtolney, a uma letra de David Tolnay, cujo nome está na maioria das crates proc-macro do ecossistema. Meia hora depois, publicou um [email protected] limpo, um typosquat de proc-macro2 que não fazia absolutamente nada. Aquela versão existia para dar histórico à conta.

A de verdade veio cinco horas mais tarde. [email protected] subiu às 07:11:15 de 20 de agosto carregando um build.rs malicioso. Quatro minutos depois, [email protected] saiu da conta do dono legítimo com uma única mudança: uma nova linha de dependência apontando para proc-macro1 ^1.0.107. O Security Response WG é cuidadoso com o mantenedor aqui, e faz bem em ser. Eles “não acreditam que o autor do arrayref esteja agindo de forma maliciosa” e observam que “o computador ou as credenciais dele provavelmente estão comprometidos”. [email protected] veio às 07:34:07, [email protected] às 07:37:49.

Aí acabou quase tão rápido quanto começou. A equipe de pesquisa da Nextron Systems achou a crate maliciosa e reportou. O crates.io removeu arrayref depois de 86 minutos, internment depois de 90 e append-only-vec depois de 107, apagou seis crates do atacante e bloqueou a conta comprometida. A RustSec não registrou evidência de que alguém tenha compilado contra uma versão envenenada.

Noventa minutos é uma resposta rápida. Para quem teve um build rodando dentro da janela, ela ainda assim chegou depois.

Dois dias de preparação, noventa minutos de exposição18 AGO · 01:25conta “dtolney” registradaa uma letra de um nome em quetodo dev Rust já confia01:55 · isca limpa 1.0.10620 AGO · 07:11:15[email protected]typosquat de proc-macro2com um build.rs maliciosoum nome que ninguém digita20 AGO · 07:15:00[email protected]conta real, credenciais roubadascódigo da biblioteca intactouma nova linha de dependência20 AGO · 07:15:24 → 07:15:40 — dezesseis segundosarrayref 0.3.5, 0.3.6, 0.3.7, 0.3.8, 0.3.9 — todas retiradaso mecanismo de recall do registro, ao contrário: o Cargo avisa que sua versão travada foi retirada e a recusa numa resolução novaResta uma única versão moderna instalável. Você roda cargo update para limpar o aviso.build.rs executa em cargo build, cargo check e cargo test — nenhuma função da biblioteca chega a ser chamada
O caminho de entrega nunca encostou no julgamento de um desenvolvedor. Uma dependência de build typosquatada foi publicada quatro minutos antes da crate que a puxava, todas as versões seguras atrás dessa crate foram retiradas numa rajada de dezesseis segundos, e o próprio aviso do Cargo sobre a retirada apontava para a única versão moderna que sobrou.

Nada na biblioteca precisava ser chamado

A carga vive num script de build, e scripts de build rodam em tempo de compilação com os seus privilégios. O The Hacker News relata a carga disparando durante cargo build, cargo check e cargo test. Esse do meio é a opção cautelosa: o que seu editor roda ao salvar, o que você digita quando quer a opinião do compilador sem executar nada. Aqui, bastou.

O script em si é pequeno e nada sutil depois que você o tem diante dos olhos. Ele remonta https://23.254.165[.]112:9089/ a partir de fragmentos base64, desliga a verificação de certificado TLS, baixa uma carga compatível com seu SO e arquitetura e a executa desanexada. O toque elegante é o std::mem::forget(child), que descarta o handle para que o processo escape do job object do Cargo. O implante segue rodando e o compilador termina com um build verde.

O que desce em seguida é um infostealer. A análise da Wiz o descreve emitindo balizas por HTTPS POST para /49890878, coletando hostname, nome de usuário, SO e aplicativos instalados, e consultando os bancos SQLite de senhas do Chrome, Brave e Edge. Ele persiste como chave Run no Registro do Windows, LaunchAgent no macOS e serviço de usuário systemd no Linux e, se o C2 dele cair, recorre a um algoritmo de geração de domínios que produz dez domínios .com a cada cinco dias. A Wiz liga a infraestrutura a atividade da RPDC: o mesmo endpoint /49890878 da campanha npm do Mastra, um emissor de certificado compartilhado e o uso repetido de uma faixa da Hostwinds.

Os binários entregues cobrem Linux x86_64, Windows e macOS, além de macOS aarch64. Esse último é um laptop Apple Silicon. Alguém pensou em quem compila Rust.

A dependência que você nunca escolheu

arrayref tem 245 milhões de downloads acumulados, e quase ninguém a colocou num Cargo.toml. A StepSecurity contou 406 versões de crates dependentes, com arrayref sentada sob o winit e portanto sob egui e iced, sob blake3 e blake2b_simd, e sob boa parte do ferramental de Ethereum e Solana. internment e append-only-vec somam mais 19 milhões de downloads entre as duas.

Então o conselho de revisar suas dependências antes de adicioná-las não alcança isso. Ninguém a adicionou. Ela chegou quatro níveis abaixo, sob uma crate que você adicionou, num diff de lockfile que passa rolando. Ler o código da biblioteca também não teria ajudado. Ninguém tocou no código-fonte das três crates envenenadas; a mudança inteira era uma linha de dependência nomeando uma crate de dois dias de idade, publicada por uma conta cujo nome parecia certo.

Por que um blog sobre codificação agêntica se importa

Olhe o gatilho de novo. Um aviso de retirada aparece na saída do build. Algo o limpa rodando cargo update.

Esse algo muitas vezes já não é uma pessoa. Limpar um aviso é a arrumação que um agente faz a caminho do que você pediu: ele vê uma versão retirada, sabe a correção, aplica a correção. Ele trabalha enquanto você dorme, em laço, sobre uma fila de tarefas. Ele atualiza lockfiles porque um build falhou e adiciona crates porque um recurso precisava de uma.

Um agente trabalhando sem supervisão é o ator com mais chance de ter estado dentro de uma janela de 86 minutos, e o com menos chance de parar diante de uma dependência transitiva desconhecida publicada por um nome que ele reconhece. Dê a ele uma conta de mantenedor comprometida, um typosquat a uma letra de David Tolnay e um aviso de registro recomendando a atualização, e não sobra nada na cadeia para o julgamento pegar.

Você consegue sobreviver a isso, porque o julgamento é um lugar ruim para decidir isso de qualquer forma.

O Bromure transforma isso num problema de cronômetro

Todo perfil do Bromure Agentic Coding vem com um portão de idade ligado, ajustado em dois dias. O proxy MITM do host vê cada busca de pacote que a VM faz, em npm, PyPI, Cargo, RubyGems, Maven, NuGet, módulos Go e Packagist, e versões mais novas que o corte não voltam.

O Cargo recebe o tratamento completo. O Bromure reconhece index.crates.io, crates.io e static.crates.io e então reescreve os metadados da crate na passagem: retira as versões novas demais da lista de versões e ajusta max_version, max_stable_version e newest_version para a versão mais recente que sobrevive ao filtro. O resolvedor do Cargo nunca fica sabendo que existe uma versão mais nova.

Agora ponha os números lado a lado.

A idade máxima que cada versão alcançou, contra um corte de dois dias[email protected]4 minutos de idade quando o arrayref a puxou[email protected]86 minutos no registro[email protected]90 minutos[email protected]107 minutosPortão de idade Bromure2 dias — 2.880 minutos — ligado por padrão, ajustável de 0 a 90Filtrado no proxy do host: versões novas demais saem dos metadados da crate e os campos de “versão mais recente” são reescritos antes do Cargo.
Toda versão deste ataque nasceu e morreu dentro de uma janela que a política padrão não abre. O portão de idade não é um julgamento sobre o pacote; é um relógio, e a operação não tinha como esperá-lo passar sem ser encontrada.

Nada nesta operação chegou aos dois dias de idade. [email protected] tinha quatro minutos quando arrayref o nomeou como dependência. Nenhuma das duas crates é visível para um resolvedor atrás do portão, então cargo update não encontra nada novo.

Você vai ver isso na sua tela. Com 0.3.5 a 0.3.9 retiradas e 0.3.10 filtrada, uma resolução nova de arrayref pode voltar incapaz de escolher uma versão, o que é um erro em vez de um sucesso silencioso. Um Cargo.lock existente travado em 0.3.9 continua compilando, porque uma retirada só bloqueia resolução nova. De um jeito ou de outro, você passa aquela quinta-feira de manhã lendo uma mensagem do resolvedor em vez de rotacionar todas as credenciais que você tem.

O Bromure confere uma segunda vez, para o caso em que o resolvedor já tinha a resposta. Ele compara cada busca de artefato, o próprio arquivo .crate, com o horário de publicação registrado, e devolve um 451 que nomeia a idade: publicado há 41 minutos, a política exige 2 dias no mínimo. Um cache de metadados quente não compra nada para o atacante.

O primeiro salto do dropper não vai a lugar nenhum

Suponha que você tivesse desligado o relógio. Alguém isenta um pacote, ou baixa o corte por uma tarde. O script de build ainda precisa alcançar 23.254.165.112 na porta 9089.

Cada perfil do Bromure carrega sua própria política de saída, escrita como um pequeno conjunto de regras no estilo pf:

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

Seu Mac a aplica em duas camadas que compartilham um único conjunto de regras: o switch virtual, casando por IP de destino e por hostname capturado do DNS em todos os protocolos, e o proxy, casando por SNI do TLS e método HTTP. Um IP puro na porta 9089 não casa com nenhuma regra daquele arquivo, então cai no default deny e a conexão morre no lado do host da fronteira. O DGA do estágio 2, dez domínios .com novos a cada cinco dias, encontra a mesma parede, porque um nome gerado continua sendo um nome que ninguém pôs na lista. Cada negativa vira uma linha egress.firewall em Window → Security Timeline, com host, IP, porta e veredito, escrita onde o código dentro da VM não alcança.

Não há nada ali para roubar

Leia a lista de recuperação que veio com este incidente: rotacionar chaves SSH, tokens de nuvem, tokens de API, chaves de assinatura. Limpar o cache do registro. Limpar camadas de cache de CI. Reconstruir diretórios vendor. Em cada máquina que compilou durante a janela.

Um espaço de trabalho Bromure responde à maior parte dessa lista com um cômodo vazio.

As credenciais de verdade nunca entram na VM. A ANTHROPIC_API_KEY naquele convidado é um substituto brm_…. O kubeconfig guarda certificados de cliente descartáveis. A autenticação do registro de contêineres é uma string Basic derivada. O token do GitHub é falso, e a chave SSH é uma chave ed25519 gerada por perfil para aquele espaço de trabalho, não a do seu laptop. O proxy MITM do host substitui o valor real na requisição de saída, e a AWS vai um passo além: o host reassina a requisição SigV4 com o material verdadeiro, de modo que qualquer coisa que contorne o proxy recebe um InvalidSignatureException da Amazon em vez de uma chamada que funciona.

Um infostealer varrendo aquele convidado em busca de credenciais encontra credenciais. Elas fazem parse, parecem certas e não valem nada fora da máquina.

O resto da lista de compras dele fica do lado errado do hipervisor. Chrome, Brave e Edge guardam seus bancos de senhas no seu Mac, num perfil de usuário para o qual o convidado não tem caminho. O LaunchAgent do macOS que ele quer escrever, a razão de ter enviado um binário macOS aarch64, não tem macOS onde escrever. Ele pode instalar um serviço de usuário systemd no home do próprio convidado, e essa camada tem um botão: Erase home… devolve o /home/ubuntu do perfil ao estado logo após a clonagem, e Reset to base… reclona o disco de sistema do espaço de trabalho a partir da imagem base somente leitura.

E você já tem o inventário

A última linha de todo guia de remediação deste incidente é um grep: procurar em cada Cargo.lock de cada máquina por seis nomes de crate e três strings de versão, e depois procurar os tarballs em ~/.cargo/registry/cache. É uma instrução ótima e uma tarde miserável, e só funciona nas máquinas que você ainda tem.

O Bromure registra a resposta enquanto ela acontece. Cada busca de pacote pelo proxy escreve uma linha supply_chain.fetch em Window → Security Timeline com o ecossistema, o pacote, a versão, o resultado e o motivo por trás do resultado, do lado do host, ao lado de cada troca de credencial e de cada veredito do firewall. Quando o próximo comunicado nomear uma versão e uma janela de duas horas, a pergunta “alguma coisa aqui buscou isso?” vira uma busca em vez de uma expedição.

O Bromure Enterprise Manager consolida o mesmo fluxo por toda uma frota, que é a forma como a pergunta chega: alguma das noventa máquinas rodando agentes durante a noite compilou isso.

A parte que vale guardar

O crates.io se saiu bem aqui. Noventa minutos do relato à remoção, seis crates do atacante apagadas, a conta comprometida bloqueada, um comunicado claro e nenhuma evidência de que alguém tenha sido atingido. O time do Rust também fez questão de não culpar um mantenedor cujas credenciais foram roubadas, o que é o instinto certo e nem sempre o mais comum.

Alguém vai reutilizar o mecanismo, no entanto, porque ele é barato e é esperto. Uma retirada é uma alavanca pública, instantânea e de uma linha só que faz um registro recomendar uma atualização, e todo ecossistema tem uma. O que quer que rode seus builds vai ver essa recomendação e agir sobre ela, e a coisa que age sobre ela hoje muitas vezes não tem uma quinta-feira de manhã de contexto sobre qual crate é qual.

Então ponha a decisão em algum lugar que não exija contexto. Dois dias de luz do dia entre a publicação de uma versão e o seu build não são um julgamento sobre um pacote, um mantenedor ou um nome de conta que está a uma letra de distância. São um relógio, e esta operação teve noventa minutos.


Fontes: 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)