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ódulositedo Python executa as linhas de import dos arquivos.ptha cada início do interpretador. Ninguém precisa fazerimport 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.txtde um repositório do GitHub controlado pelo atacante, buscahttp://<host>/codepye executa o resultado comexec().
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.
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_KEYe 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/confige 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
~/.sshnã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.
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.