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

O código rodou antes do modelo

A Pluto Security encontrou 31 repositórios no Hugging Face disfarçados de pequenos checkpoints de teste do Qwen2. Nenhum continha um modelo. Cada um continha um arquivo Python que roda no instante em que o modelo é carregado com trust_remote_code, instalando um minerador oculto, hooks de inicialização e, em algumas versões, um reverse shell. O repositório principal passou pelos scanners do Hugging Face e chegou a 28.412 downloads, e uma cópia quase idêntica continuou no ar depois da remoção. Agentes de código recorrem a pequenos modelos de teste quando escrevem testes. No Bromure Agentic Coding, esse carregamento roda em uma VM de onde o minerador não alcança seu pool, a Security Timeline liga cada conexão à chamada de ferramenta que a abriu, e um rewind remove o que o payload plantou.

O arquivo de pesos tinha 42 bytes e nenhum tensor. O trabalho era feito por um arquivo Python que rodava assim que o modelo era carregado, antes que qualquer código do modelo fizesse alguma coisa.

Em 29 de outubro, a Pluto Security publicou Load/Mine, sua análise de uma campanha que roda no Hugging Face e no ModelScope, da China, desde setembro. A Pluto encontrou 31 repositórios maliciosos em sete contas. Eles se apresentam como pequenos checkpoints de teste do Qwen2, com nomes como qwen2-tiny, e juntos somavam mais de 32.000 downloads registrados. O BleepingComputer noticiou no mesmo dia.

A Pluto resume a campanha em uma frase: “The code runs before the model does” (o código roda antes do modelo).

Como carregar um modelo vira um minerador

O ataque não usa pesos envenenados nem um exploit de pickle. Usa um recurso. Um modelo do Hugging Face pode trazer seu próprio código Python, e a biblioteca Transformers executa esse código se quem chama passar trust_remote_code=True. O config.json do repositório indica um módulo e uma classe em uma entrada auto_map, como modeling_evil2.EvilModel. O Transformers baixa esse arquivo .py e o importa. Tudo o que está no nível superior do arquivo roda durante o import, antes mesmo de a classe ser devolvida.

Nesses repositórios, model.safetensors tem 42 bytes. Não há modelo nenhum ali. O arquivo Python faz todo o trabalho:

  1. Ele se instala. Grava em /tmp/.cch/svc.py um watchdog que relança o minerador a cada dois minutos e acrescenta um hook de inicialização a ~/.bashrc, /root/.bashrc e /root/.profile. As versões da primeira onda também plantam uma chave SSH chamada qx-5090-op em authorized_keys e um cron job em /etc/cron.d/.sync-helper.
  2. Ele baixa o minerador e o esconde. O downloader é montado com um handler de proxy vazio, então ignora as configurações HTTP_PROXY e HTTPS_PROXY das quais uma empresa pode depender. O binário de 40,6 MB tem os marcadores UPX alterados para que upx -d o recuse. Ele roda a partir de um arquivo anônimo em memória, com o nome de processo [kworker/u20:1], e minera Pearl pela Kryptex na porta 8048.
  3. Ele abre uma porta. Três versões acrescentam um reverse shell para 212.32.253.3 nas portas 443 ou 9443, que se reconecta sempre que cai.
  4. Ele vai atrás do host. Um script relacionado da segunda onda monta os discos do host a partir de um contêiner privilegiado e se dá root no servidor por baixo.
Um carregamento de modelo, três payloadsO carregamentofrom_pretrained("…/qwen2-tiny", trust_remote_code=True)config.json · auto_mapmodeling_evil2.EvilModelmodel.safetensors: 42 bytes, 0 tensoresO import executa o arquivoo código de nível superior rodaantes de a classe ser devolvidaPersistênciawatchdog em /tmp/.cch/svc.pyhooks em ~/.bashrc e /root/.profilecron job, chave SSH qx-5090-opMinerador ocultoo downloader ignora HTTP_PROXYroda em memória como [kworker/u20:1]minera em prl-us.kryptex.network:8048Reverse shellpara 212.32.253.3 na 443 ou 9443,reconecta quando cai(três versões)
O que acontece ao carregar um dos modelos da campanha, segundo a análise da Pluto Security. O config.json aponta o Transformers para um arquivo Python, o arquivo roda no import, e o arquivo de pesos de 42 bytes nunca é usado. O payload instala persistência, inicia um minerador disfarçado que ignora as configurações de proxy e, em algumas versões, abre um reverse shell.

Passou limpo pelo scanner e voltou

O Hugging Face verifica os uploads com antivírus e com ferramentas de parceiros, da Protect AI e da JFrog. Segundo a Pluto, essas ferramentas procuram malware conhecido e formatos de modelo inseguros. Elas não olham o que o arquivo Python faz. O repositório principal da segunda onda, MENXIANGYU/qwen2-tiny, “scanned clean while it collected 28,412 downloads” (passou limpo pelo scanner enquanto acumulava 28.412 downloads). O veredito de um scanner parceiro sobre o arquivo do payload foi que ele era “not a machine-learning model” (não é um modelo de machine learning).

A Pluto reportou a primeira onda ao Hugging Face e ao ModelScope, e os dois removeram as contas. Em poucos dias, 19 novos repositórios apareceram em contas novas. Horas depois do relatório da Pluto, o operador publicou um script de fuga de contêiner. O Hugging Face desativou o repositório principal, mas o BleepingComputer confirmou que uma cópia quase idêntica, htt0118/qwen2-tiny, continuava no ar com o mesmo minerador e o mesmo reverse shell.

Um modelo de teste minúsculo é o que seu agente procura

A campanha foi feita para servidores com GPU que rodam plataformas de inferência como Xinference e GPUStack. A isca também funciona na máquina de um desenvolvedor, porque um agente de código procura um checkpoint pequeno do Qwen2 quando escreve testes para código que chama um modelo. Peça a um agente para adicionar um teste ao seu pipeline de embeddings ou ao seu script de fine-tuning, e ele vai procurar um modelo pequeno o bastante para carregar em segundos. Os nomes desta campanha, qwen2-tiny e suas variantes, foram feitos para casar com essa busca.

O agente não precisa ser descuidado para ativar a flag. Quando um repositório traz código próprio, o Transformers para e diz a quem chama para passar trust_remote_code=True. Para um agente tentando consertar um teste que falha, isso é um erro com a correção impressa na mensagem. Ele adiciona o argumento e roda o teste de novo. Se o teste passa ou não, já não importa: o minerador começou durante o import.

Em um notebook, esse arquivo roda como você. O hook de inicialização em ~/.bashrc roda em todo terminal que você abrir depois disso. O reverse shell dá ao operador um prompt com o seu ~/.ssh, suas credenciais de nuvem, seu login do gh e o seu HF_TOKEN. O conselho da Pluto para quem carregou esses modelos é tratar a máquina como comprometida e rotacionar todas as credenciais que o processo podia alcançar.

Entre as recomendações de hardening da Pluto, uma linha descreve a configuração que você quer: rodar código de modelo não confiável “with limited privileges, restricted egress and no production secrets” (com privilégios limitados, saída de rede restrita e nenhum segredo de produção).

O mesmo teste no Bromure Agentic Coding

O Bromure Agentic Coding roda Claude Code, Codex, Grok, Kimi e omp em VMs Ubuntu no seu Mac. Cada máquina tem seu próprio disco, sua própria home e sua própria política de rede, aplicada no Mac, fora do alcance do agente. Veja o mesmo teste rodando em uma máquina Bromure, uma etapa do payload por vez.

O minerador nunca chega ao pool

Toda conexão que a VM abre passa pelo switch virtual e pelo proxy do Bromure no Mac. As portas 80 e 443 são desviadas para o proxy no switch, então um downloader com um handler de proxy vazio é interceptado do mesmo jeito. O truque que a campanha usa para escapar das configurações de proxy corporativas não tem o que contornar, porque a interceptação não depende de nenhuma configuração dentro da VM.

Coloque Unmatched traffic em Deny em Sandbox Policy, ou passe a máquina para o motor OpenShell + Sentry, em que tudo é negado a menos que uma regra permita. A política inicial libera GitHub, npm e PyPI somente leitura, além dos mirrors do Ubuntu, e você adiciona huggingface.co para os downloads. Aí a conexão do minerador com prl-us.kryptex.network:8048 não casa com nenhuma regra e é recusada. O reverse shell para 212.32.253.3 também, em qualquer das portas. Com Tell the agent when policy blocks it, ativado por padrão em máquinas OpenShell, o agente também lê na conversa o que foi bloqueado e que deve parar e perguntar a você, em vez de passar os próximos dez minutos tentando dar a volta.

Em uma máquina OpenShell com Enforce binaries, cada conexão também é associada ao programa que a abriu, e o hash de cada programa é fixado na primeira vez que ele roda. Um minerador iniciado a partir de um arquivo anônimo em memória é um programa que nenhuma regra nomeia. Renomear o processo para [kworker/u20:1] não muda isso, porque o atestador identifica o binário, não o nome que ele dá a si mesmo.

Você vê qual chamada de ferramenta fez isso

O operador escolheu o nome [kworker/u20:1] para enganar quem lê ps ou top em um servidor ocupado. Em uma máquina Bromure com a sentinela do kernel, o kernel da VM informa ao Mac cada programa iniciado e cada conexão, por um canal privado que o agente não consegue derrubar. Cada conexão bloqueada entra na Security Timeline com a cadeia por trás dela, algo como Tool pytest tests/test_embed.py: claude → bash → python → TCP 212.32.253.3:443.

Dê dois cliques na linha e a network lineage mostra a história inteira: o prompt que você deu, o que o agente escreveu antes da chamada, o comando exato, cada processo do agente até o que se conectou, e qual camada recusou. Quando uma execução de testes disca um endereço IP puro, você ganha uma linha vermelha com o comando do carregamento do modelo.

O watchdog do agente, configurado como Quarantine em Guardrails, age sobre os mesmos sinais sem esperar por você. Uma rajada de bloqueios, ou destinos novos depois que a sessão se estabilizou, acima do limite em menos de um minuto, e o Bromure corta a rede da VM e a pausa em milissegundos. O alerta lista o que ele viu. Keep quarantined retoma a VM com a rede ainda cortada, para você olhar lá dentro.

Notebook: o teste roda como vocêO agente carrega qwen2-tiny para um testepõe trust_remote_code=True para sumir o erroO payload roda na sua contao minerador alcança o pool da Kryptex,o reverse shell alcança 212.32.253.3Ele fica, e o operador tem um prompthook em ~/.bashrc roda em todo terminal novo;~/.ssh, chaves de nuvem, gh, HF_TOKEN ao alcanceBromure: o teste roda em uma VMMesmo carregamento, na VM Ubuntu da máquinaHF_TOKEN, gh e chaves de nuvem: substitutasSwitch e proxy no Mac: negadopool :8048 e shell :443/:9443 sem regra;sentinela liga cada um à chamada; watchdog isola a VMReset disk, Rewind homedisco do sistema novo, da imagem base;home de volta ao boot de antes do teste
O mesmo carregamento de modelo em um notebook e em uma máquina Bromure. No notebook, o payload roda como você, alcança o pool e o shell, e fica nos seus arquivos de inicialização. Na máquina Bromure, o switch e o proxy no Mac recusam as duas conexões, a sentinela do kernel as liga à chamada de ferramenta, o watchdog pode colocar a VM em quarentena, e Reset disk mais Rewind home removem o que foi plantado.

As chaves na VM são substitutas

Digamos que uma versão do payload vá mais longe, em uma máquina que você deixou no firewall padrão. O operador tem um shell em uma VM Ubuntu, e toda credencial ali é uma substituta. O GH_TOKEN no ambiente e o token em ~/.git-credentials são falsos, e o proxy no seu Mac só os troca pelo verdadeiro a caminho do GitHub. Adicione seu token do Hugging Face em Other API keys com o nome de variável HF_TOKEN e um filtro de host huggingface.co, e a VM recebe uma substituta brm_… que só funciona lá. Chaves SSH nunca entram na VM: o agente pode pedir uma assinatura, e os bytes da chave ficam no Mac.

Uma substituta também é uma armadilha. Se o payload mandar a substituta do GitHub para qualquer lugar que não seja o GitHub via HTTPS, o proxy bloqueia a requisição com HTTP 451, pausa a VM e abre um alerta com o nome da credencial e o host para onde ela ia.

A limpeza são dois cliques

A lista de limpeza da Pluto para um host infectado é longa: achar o watchdog, remover os hooks do shell, apagar o cron job, tirar a chave SSH de seis caminhos authorized_keys, procurar registros indevidos e reconstruir a partir de uma imagem confiável se você não tiver certeza. Uma máquina Bromure já tem essa imagem confiável. Reset disk clona de novo o disco do sistema a partir da imagem base, o que leva junto /etc/cron.d/.sync-helper e tudo o mais que o payload gravou fora da home. Rewind home… volta /home/ubuntu a um checkpoint de uma inicialização anterior. O Bromure tira um toda vez que a máquina inicia, então o ~/.bashrc de antes do teste é um deles. Suas pastas compartilhadas com o host ficam no Mac e não são tocadas.

Em uma máquina OpenShell com Enforce binaries, boa parte dessa lista nem chega a ser gravada. O agente roda sem root, então os hooks em /root/.bashrc e /root/.profile, o cron job e as gravações no authorized_keys do root são recusados, e cada recusa vira uma linha Guest sandbox na Timeline.

A saída é decidida no Mac

As portas 80 e 443 vão para o proxy, digam o que disserem as configurações da VM. Com o tráfego não correspondido em negar, o pool e o shell são recusados.

Toda conexão tem um nome

A sentinela do kernel liga cada conexão à chamada de ferramenta e à cadeia de processos por trás dela. Um minerador disfarçado ainda aparece como o carregamento do modelo.

Substitutas, não chaves

Tokens do GitHub, de nuvem e do Hugging Face na VM são falsos que só funcionam nos próprios hosts. Em outro lugar, disparam um alerta.

Uma máquina limpa quando quiser

Reset disk e Rewind home devolvem a VM ao estado de antes do teste, persistência incluída.

Trate um repositório de modelo como um pacote

A Pluto lista seus indicadores no relatório. Se você carregou um desses modelos com código próprio ativado, procure /tmp/.cch/, o processo [kworker/u20:1] com argumentos de mineração e os hooks de inicialização, e depois rotacione todas as credenciais que aquela máquina podia alcançar. Antes de ativar trust_remote_code para qualquer repositório, leia o modeling_*.py dele e fixe o commit que você leu.

O relatório da Pluto mostra um repositório de modelo funcionando como um repositório de código enquanto os scanners o verificavam como um formato de arquivo. Se o seu agente escolhe modelos para os testes, uma hora vai carregar um desses arquivos. Em uma máquina Bromure, esse arquivo roda em uma VM com chaves substitutas e uma política de saída aplicada no Mac, e depois você pode voltar a máquina a um checkpoint limpo.

Para um caso anterior em que a própria verificação de trust_remote_code falhou, leia A flag disse não. Para ver como o Bromure filtra os pacotes que um agente instala, leia A versão tinha duas horas.