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

A versão tinha duas horas

Em 21 de outubro, atacantes com tokens do GitHub CLI roubados de três desenvolvedores do LiteLLM publicaram o litellm 1.107.0 no PyPI. Cada wheel trazia um ladrão de credenciais que roda a cada início do Python e um carregador de código remoto que acorda de hora em hora. O PyPI o removeu 1 hora e 43 minutos depois, após cerca de 49.000 downloads estimados. No Bromure Agentic Coding, o portão de idade, ligado por padrão, esconde qualquer versão com menos de dois dias, então o pip numa máquina Bromure nunca viu a 1.107.0.

A versão maliciosa ficou 1 hora e 43 minutos no PyPI. Com as configurações padrão do Bromure, o pip na máquina de um agente só a teria visto dois dias depois da publicação.

Às 11h39 UTC da terça-feira, 21 de outubro, sete wheels do litellm==1.107.0 chegaram ao PyPI. O LiteLLM é a biblioteca Python e o proxy que muitas equipes usam para rotear o tráfego dos modelos, e ele vem embutido em muita ferramenta de IA. A nova versão parecia ser do próprio projeto: o workflow de release do projeto a tinha assinado, e ela vinha com proveniência SLSA. Ela também continha um ladrão de credenciais e um backdoor.

O PyPI a removeu às 13h23 UTC. Imagens Docker infectadas com as tags v1.107.0, latest, main-latest e main-stable ficaram no Docker Hub até 14h20. A Wiz estima cerca de 49.000 downloads da versão maliciosa nessa janela. O relatório de incidente do LiteLLM diz a quem a instalou em uma máquina com acesso de saída à internet para presumir que a exfiltração deu certo.

É o segundo comprometimento do projeto neste ano. O primeiro, em março, veio por um scanner de segurança envenenado na CI. Escrevemos sobre como as equipes de resposta checaram a janela errada naquela vez.

Como os atacantes conseguiram as chaves

A equipe do LiteLLM publicou uma linha do tempo minuto a minuto. Os atacantes tinham tokens OAuth do GitHub CLI de três desenvolvedores. Naquela segunda-feira, as permissões de release tinham sido ampliadas para que dois engenheiros pudessem lançar backports de correções. Na terça, às 10h08, a proteção de branch rejeitou uma mudança no workflow privado de release. Cinco segundos depois, o token de um dos dois donos da organização passou por cima da proteção e enviou a mudança. O workflow modificado compilava a partir de Berri-AI/litellm, um clone falso do verdadeiro BerriAI/litellm, e o token de um segundo desenvolvedor passou pela aprovação de produção. Às 11h53, o token de um terceiro desenvolvedor apagou os registros de execução do workflow.

O LiteLLM atribui os tokens a duas descobertas. Uma foi uma "conta de usuário inesperada, de nome inofensivo" na sua implantação interna do LiteLLM, que estava exposta à internet. A outra diz respeito a agentes de código. Segundo o relatório, o LiteLLM "observou situações específicas em que o Claude Code incorpora o token OAuth do GitHub CLI de um usuário no corpo de uma resposta e, quando os corpos de resposta são registrados, esses tokens podem aparecer nos logs do LiteLLM".

Um gateway que registra o tráfego dos modelos guarda uma cópia de tudo o que o agente escreveu. Nesse caso, a cópia incluía o token capaz de publicar o pacote.

O que os wheels faziam

A análise da StepSecurity explica por que o código-fonte no GitHub parecia limpo. Os atacantes adicionaram uma única linha à configuração de build, data = "cook" sob [tool.maturin]. Ela copiava um diretório cook/ para dentro de cada wheel. O diretório nunca apareceu na distribuição de código-fonte, e a tag v1.107.0 no GitHub não tem nenhum vestígio dele.

Cada wheel instala três arquivos em site-packages:

  • cook.pth, 12 bytes: import cook. O módulo site do Python executa as linhas de import dos arquivos .pth a cada início do interpretador. Ninguém precisa fazer import litellm. Basta iniciar o pip.
  • cook.py, uma linha ofuscada que decodifica em cerca de 61.000 bytes. É um ladrão que marca sua saída com "tool": "privcollect" e roda 16 módulos de coleta.
  • sitecustomize.py, um carregador que se separa em um daemon independente. A cada hora, ele lê domain.txt de um repositório do GitHub controlado pelo atacante, busca http://<host>/codepy e executa o resultado com exec().

O ladrão leva chaves SSH e GPG, credenciais de nuvem e metadados de instância, ~/.kube/config, a configuração do Docker, tokens do GitHub CLI, .git-credentials, .netrc, tokens de registro para npm, PyPI, Cargo e outros, arquivos .env até dois níveis de profundidade, logins e cookies do navegador, histórico do shell e de bancos de dados, e o ambiente de todo processo legível em /proc/<pid>/environ. Ele também lista arquivos de autenticação e configuração do Claude Code, Claude Desktop, Cursor, Windsurf, Kiro, GitHub Copilot, Gemini CLI, Codex, Continue, Aider, Goose e cerca de uma dúzia de outras ferramentas de IA. Em uma máquina com o proxy do LiteLLM, ele lê as chaves de API dos provedores no banco de dados do proxy.

O ladrão criptografa o pacote com AES-256, embrulha a chave com a chave pública RSA-4096 do atacante e envia o resultado por HTTP sem criptografia para http://www.litellm.dev/healthcheck. O atacante registrou o domínio em 13 de outubro. Enquanto o servidor não responder 200, o ladrão roda de novo no próximo início do Python.

O domain.txt do carregador estava vazio no lançamento, então o segundo estágio nunca foi armado. A conta do GitHub por trás dele foi apagada, mas máquinas infectadas continuam consultando essa URL de hora em hora. Se alguém registrar esse nome de usuário de novo, todas essas máquinas vão rodar o código dessa pessoa.

Por que os agentes de código estavam expostos

Um agente trabalhando em um projeto Python roda pip install -U litellm porque um README ou uma mensagem de erro sugere isso. Ele roda pip install -r requirements.txt com uma linha litellm sem versão fixada. Ele cria um virtualenv novo e instala o que houver de mais novo, a qualquer hora, e ninguém lê a versão que veio.

Na terça, entre 11h39 e 13h23 UTC, o mais novo era a 1.107.0. Na máquina de um agente, o cook.pth rodava então em toda chamada a python, pytest e pip que o agente fazia pelo resto da sessão. As credenciais do desenvolvedor estavam ao alcance do ladrão, e os logins do agente estavam na sua lista de alvos.

O Bromure nunca serviu a 1.107.0

O Bromure Agentic Coding roda Claude Code, Codex, Grok, Kimi e omp em VMs Linux no seu Mac. Todo download de pacote a partir da VM passa por um proxy no Mac, que aplica a política de cadeia de suprimentos da máquina antes que a resposta chegue ao pip. A VM não pode mudar essa política. O pip.conf dentro da VM só pode restringi-la.

Uma camada dessa política vem ligada em toda máquina nova: o portão de idade, com idade mínima de 2 dias. Quando o pip pergunta ao índice simples do PyPI quais versões do litellm existem, o proxy tira da lista todo arquivo mais novo que o limite antes que o pip a leia. Às 11h45 UTC de terça, uma máquina Bromure rodando pip install -U litellm recebia uma lista que terminava na 1.106.2, a última versão limpa, e a instalava. Para o agente, a 1.107.0 ainda não existia, e quando o portão se abrisse, o PyPI já a teria apagado.

Se o agente fixa a versão, o download do artefato é a segunda verificação. O proxy consulta no PyPI a data de publicação do arquivo e recusa o download com HTTP 451:

$ pip install litellm==1.107.0
ERROR: HTTP error 451 while getting https://files.pythonhosted.org/.../litellm-1.107.0-...whl
Bromure Supply-Chain Security blocked this request: pypi package [email protected]
published 1 hour ago — policy requires 2 days minimum

O pip imprime o motivo literalmente, então o agente também o lê, e muitas vezes fixa sozinho uma versão mais antiga.

A versão sumiu muito antes de o portão se abrir21 out 08:0022 out 00:0023 out 00:0023 out 14:00litellm 1.107.0 no PyPI11:39 a 13:23 · cerca de 49.000 downloadstags no Docker Hub até 14:20Portão de idade do Bromure, padrão de 2 diaso pip numa máquina Bromure vê a 1.106.2 como o litellm mais novo23 out 11:39: o portão abriria. Nada mais a servir.removidaHorários do relatório de incidente do LiteLLM. Estimativa de downloads da Wiz.
litellm 1.107.0 no PyPI, de 21 a 23 de outubro, em UTC. A versão maliciosa ficou no ar por 1 hora e 43 minutos e já tinha sumido mais de 46 horas antes de o portão de idade padrão do Bromure deixar o pip vê-la.

O portão não precisou de alerta de segurança, de assinatura de malware nem de alguém que apontasse o pacote. Registros e pesquisadores costumam achar e remover uma versão sequestrada em poucas horas, e uma espera de dois dias quase não custa nada no desenvolvimento normal. Se a sua própria equipe publica um pacote que precisa ser instalado no minuto em que sai, adicione-o em Exempt packages, no painel Supply Chain da máquina, como pypi:yourpkg. O resto do PyPI continua esperando dois dias.

O mesmo painel adiciona outras camadas. A verificação do OSV recusa qualquer versão com um alerta publicado de severidade igual ou acima da que você escolher. O filtro do socket.dev bloqueia pacotes marcados como malware. Se uma fonte de reputação que você ativou não consegue dar um veredito, o download pausa e o Bromure pergunta a você sobre aquele pacote e aquela versão.

O token no log era um substituto

A publicação só foi possível porque os atacantes tinham tokens reais do GitHub. O LiteLLM acredita que eles os obtiveram de uma implantação que registrava o tráfego dos modelos, onde o Claude Code tinha escrito o token do GitHub CLI de um desenvolvedor em uma resposta.

Em uma máquina Bromure, esse token é um substituto. Quando a VM inicia, o Bromure escreve um token ghp_ falso na configuração do gh e em ~/.git-credentials e o exporta como GH_TOKEN. O falso tem o prefixo e o tamanho certos, então o gh o aceita. O token real fica no Mac. Quando uma requisição sai da VM para o GitHub, o proxy troca o falso pelo valor real no caminho, apenas para os hosts do GitHub.

Um agente ainda pode colar o token em uma resposta. Ele só tem o substituto para colar. O provedor do modelo e qualquer log de gateway no meio do caminho registram o substituto também. Reutilizado a partir do notebook de outra pessoa, ele é rejeitado pelo GitHub, porque não há nenhum proxy do Bromure ali para fazer a troca.

As permissões de release recebem a mesma proteção. No painel Guardrails de uma máquina, todo token de forge tem uma política de escrita, e máquinas novas começam em Prompt before write. Um force-push em um workflow de release, ou uma chamada de API que desliga a proteção de branch, espera por um diálogo no seu Mac que mostra METHOD /path, e você decide.

O que o privcollect teria encontrado

Suponha que a 1.107.0 chegasse a uma máquina, porque alguém desligou o portão ou adicionou uma exceção. A lista do ladrão é longa. Em uma VM do Bromure, quase tudo o que ele consegue abrir guarda substitutos:

  • GH_TOKEN, ANTHROPIC_API_KEY e todas as outras credenciais dadas à máquina, no ambiente de cada processo que o ladrão lê via /proc, são falsas.
  • ~/.git-credentials, ~/.docker/config.json, ~/.kube/config, ~/.aws/config e as configurações de MCP guardam falsos. Assim como os arquivos de login dos próprios agentes: as assinaturas do Claude, ChatGPT, Grok e Kimi fazem login e são renovadas no Mac.
  • As chaves dos provedores de modelos nunca entram na VM, então não existe banco de dados de proxy cheio de chaves para ler. O proxy do Bromure as adiciona no Mac, como descrevemos em O provider roda onde estão as chaves.
  • As chaves SSH assinam por meio de uma ponte de agente até o Mac. Os bytes da chave privada nunca entram na VM, então ~/.ssh não tem nada a levar.

Um substituto só funciona através do proxy daquela máquina, em direção ao host para o qual foi emitido. O atacante pode descriptografar o pacote e reutilizar cada valor que há nele, e GitHub, AWS e Anthropic vão rejeitar cada um.

O POST não tinha para onde ir

O pacote sai como um POST HTTP sem criptografia para www.litellm.dev, um domínio de que a máquina nunca precisou. Com Unmatched traffic em Deny no painel Guardrails, a VM só alcança os hosts que você lista:

allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
allow web api.anthropic.com
default deny

O switch no Mac recusa a conexão, e a Security Timeline mostra uma linha vermelha deny com o host e a porta. O segundo estágio do carregador falha da mesma forma: seja qual for o host que o domain.txt indicar, http://<host>/codepy não está na lista, então o exec() de hora em hora não tem nada para rodar.

Em um workspace com o motor de sandbox OpenShell + Sentry, Show the Whole Chain nessa linha abre a linhagem do fluxo bloqueado. Ela mostra a chamada de ferramenta pip install do agente e o raciocínio que o modelo deu para ela, depois o processo pip em que o cook.pth iniciou o ladrão, depois a conexão recusada. Você vê a instrução que trouxe o pacote e o motivo que o modelo deu para executá-la.

Quatro etapas, quatro verificações no MacO ataque, 21 de outubro1 · Tokens num logtokens OAuth do gh copiadosdo tráfego de modelos2 · Workflow com force-pushproteção ignorada,build de Berri-AI/litellm3 · Wheels no PyPIlitellm==1.107.011:39 a 13:23 UTC4 · cook.pth rodacoleta e faz POST parawww.litellm.devNuma máquina BromureToken substitutoa VM tem um ghp_ falso;o log guarda o falso.GitHub o rejeita fora dali.Política de escritapushes e mudanças naproteção de branch esperamum diálogo no Mac.Portão de idade, ligadoo pip resolve a 1.106.2;uma 1.107.0 fixadarecebe HTTP 451.Falsos e negação padrãoa coleta são substitutos;o POST e /codepy sãorecusados no switch.
A cadeia do litellm 1.107.0, e onde uma máquina Bromure a interrompe. Cada uma das quatro verificações roda no Mac, fora da VM em que o pacote é instalado.

Nós instalamos? Basta um filtro

Toda equipe de resposta tinha a mesma primeira pergunta na terça à tarde: quais das nossas máquinas rodaram a 1.107.0? O LiteLLM avisa que não encontrar o cook.pth não prova nada: a ausência dele não significa que a máquina está limpa.

O Bromure registra todo download de pacote que o proxy vê, seja qual for a política e haja ou não alguma verificação ligada. Abra Window → Security Timeline…, escolha Supply chain no filtro de motor e procure litellm. Cada linha mostra o pacote e a versão, pypi · [email protected], e o que aconteceu com eles. O registro fica no Mac por 90 dias, e Export… o salva em CSV para o ticket do incidente. Em um Mac registrado no bromure.io, cada download também é um evento supply_chain.fetch com o ecossistema, o pacote, a versão e o resultado. Um administrador pode listar todas as máquinas da organização que baixaram [email protected] sem perguntar a ninguém.

Limpar depois de um incidente é rápido. O guia do LiteLLM manda matar o daemon que segura /tmp/remote_code_loader.lock antes de rodar o pip, apagar os arquivos do payload de todo site-packages, reconstruir e rotacionar. No Bromure, Reset disk clona de novo o disco do sistema a partir da imagem base, e Rewind home… volta o home para um checkpoint anterior à instalação. As chaves que você teria rotacionado às pressas eram substitutos. As reais nunca saíram do Mac.

Versões novas esperam dois dias

O portão de idade vem ligado em toda máquina nova. Instalações do PyPI, npm, Cargo, RubyGems e Packagist resolvem para a versão mais nova que seja mais antiga que o limite. Uma versão recente fixada recebe um 451 com o motivo.

O token no log é falso

Credenciais do GitHub, da nuvem, de registros e de modelos entram na VM como substitutos. O proxy coloca o valor real no Mac, para o host de cada credencial.

A exfiltração precisa de uma rota

Com o tráfego não listado em deny, um POST para um domínio registrado na semana passada morre no switch e aparece como uma linha vermelha.

Toda instalação fica registrada

A Security Timeline lista cada pacote e versão que cada máquina baixou, por 90 dias. Macs registrados enviam os mesmos eventos para a organização.

Deixe o registro se atualizar primeiro

Os atacantes precisaram de minutos para publicar a 1.107.0. O PyPI precisou de 1 hora e 43 minutos para removê-la, o que é rápido para um registro. Seu agente instala pacotes o dia todo, a qualquer hora, e pega o que houver de mais novo, então ele cai dentro de janelas como essa. Em uma máquina Bromure, ele espera dois dias. Até lá, PyPI, Wiz, StepSecurity e a própria equipe do LiteLLM já examinaram a versão, e uma versão ruim já sumiu.

Sobre o ladrão que foi atrás das credenciais de agentes de código no início deste mês, leia O stealer levou os substitutos. Sobre como tokens vão parar em transcrições e logs de modelos, leia A transcrição guardou o token.