Das Release war zwei Stunden alt
Am 21. Oktober veröffentlichten Angreifer mit den gestohlenen GitHub-CLI-Tokens dreier LiteLLM-Entwickler litellm 1.107.0 auf PyPI. Jedes Wheel enthielt einen Credential-Stealer, der bei jedem Python-Start läuft, und einen stündlichen Loader für Fremdcode. PyPI entfernte das Release 1 Stunde 43 Minuten später, nach geschätzt 49.000 Downloads. In Bromure Agentic Coding blendet die standardmäßig aktive Mindestalter-Sperre jedes Release aus, das jünger als zwei Tage ist. pip auf einer Bromure-Maschine hat 1.107.0 nie gesehen.
Das bösartige Release war 1 Stunde und 43 Minuten auf PyPI. Mit den Standardeinstellungen von Bromure hätte pip es auf der Maschine eines Agenten erst zwei Tage nach der Veröffentlichung zu sehen bekommen.
Am Dienstag, dem 21. Oktober, um 11:39 UTC landeten sieben Wheels für
litellm==1.107.0 auf PyPI. LiteLLM ist die Python-Bibliothek und der Proxy,
über die viele Teams ihren Modell-Traffic leiten, und sie steckt in vielen
KI-Werkzeugen. Das neue Release sah aus wie ein echtes: Der Release-Workflow
des Projekts hatte es signiert, und es kam mit SLSA-Provenance. Es enthielt
außerdem einen Credential-Stealer und eine Hintertür.
PyPI entfernte es um 13:23 UTC. Infizierte Docker-Images mit den Tags
v1.107.0, latest, main-latest und main-stable blieben bis 14:20 auf
Docker Hub. Wiz
schätzt rund 49.000 Downloads der bösartigen Version in diesem Zeitfenster.
LiteLLMs
Incident-Bericht
rät allen, die es auf einem Host mit ausgehendem Internetzugang installiert
haben: „Gehen Sie davon aus, dass die Exfiltration gelungen ist.“
Es ist die zweite Kompromittierung des Projekts in diesem Jahr. Die erste, im März, kam über einen manipulierten Sicherheitsscanner in der CI. Wir haben darüber geschrieben, wie Responder damals das falsche Zeitfenster prüften.
Wie die Angreifer an die Schlüssel kamen
Das LiteLLM-Team hat eine minutengenaue Chronologie veröffentlicht. Die
Angreifer besaßen GitHub-CLI-OAuth-Tokens von drei Entwicklern. Am Montag
waren die Release-Berechtigungen erweitert worden, damit zwei Ingenieure
Bugfix-Backports ausliefern konnten. Am Dienstag um 10:08 wies der
Branch-Schutz eine Änderung am privaten Release-Workflow zurück. Fünf
Sekunden später hebelte das Token eines der beiden Owner der Organisation
den Schutz aus und pushte die Änderung. Der geänderte Workflow baute aus
Berri-AI/litellm, einem Doppelgänger des echten BerriAI/litellm, und das
Token eines zweiten Entwicklers passierte die Produktionsfreigabe. Um 11:53
löschte das Token eines dritten Entwicklers die Workflow-Run-Einträge.
LiteLLM führt die Tokens auf zwei Funde zurück. Der eine war ein „unerwartetes, harmlos benanntes Benutzerkonto“ auf der internen LiteLLM-Installation, die aus dem Internet erreichbar war. Der andere betrifft Coding-Agenten. Laut Bericht hat LiteLLM „konkrete Situationen beobachtet, in denen Claude Code das GitHub-CLI-OAuth-Token eines Nutzers in einen Response-Body einbettet, und wo Response-Bodies protokolliert werden, können diese Tokens in LiteLLM-Logs auftauchen“.
Ein Gateway, das Modell-Traffic protokolliert, behält eine Kopie von allem, was der Agent geschrieben hat. In diesem Fall enthielt die Kopie das Token, mit dem sich das Paket veröffentlichen ließ.
Was die Wheels taten
Die Analyse von
StepSecurity
erklärt, warum der Quellcode auf GitHub sauber aussah. Die Angreifer fügten
der Build-Konfiguration eine einzige Zeile hinzu, data = "cook" unter
[tool.maturin]. Sie kopierte ein Verzeichnis cook/ in jedes Wheel. Das
Verzeichnis tauchte nie in der Source-Distribution auf, und der Tag
v1.107.0 auf GitHub enthält keine Spur davon.
Jedes Wheel installiert drei Dateien in site-packages:
cook.pth, 12 Bytes:import cook. Pythonssite-Modul führt Import-Zeilen in.pth-Dateien bei jedem Interpreter-Start aus. Niemand muss jeimport litellmaufrufen. Es genügt, pip zu starten.cook.py, eine verschleierte Zeile, die sich zu rund 61.000 Bytes dekodiert. Es ist ein Stealer, der seine Ausgabe mit"tool": "privcollect"markiert und 16 Sammelmodule ausführt.sitecustomize.py, ein Loader, der sich als losgelöster Daemon abspaltet. Jede Stunde liest erdomain.txtaus einem GitHub-Repository unter Kontrolle des Angreifers, lädthttp://<host>/codepyund führt das Ergebnis mitexec()aus.
Der Stealer nimmt SSH- und GPG-Schlüssel, Cloud-Zugangsdaten und
Instanz-Metadaten, ~/.kube/config, die Docker-Konfiguration,
GitHub-CLI-Tokens, .git-credentials, .netrc, Registry-Tokens für npm,
PyPI, Cargo und andere, .env-Dateien zwei Ebenen tief, Browser-Logins und
Cookies, Shell- und Datenbank-Historie sowie die Umgebung jedes lesbaren
Prozesses aus /proc/<pid>/environ. Außerdem führt er Auth- und
Konfigurationsdateien für Claude Code, Claude Desktop, Cursor, Windsurf,
Kiro, GitHub Copilot, Gemini CLI, Codex, Continue, Aider, Goose und rund ein
Dutzend weiterer KI-Werkzeuge auf. Auf einem LiteLLM-Proxy-Host liest er die
API-Schlüssel der Anbieter aus der Datenbank des Proxys.
Der Stealer verschlüsselt das Bündel mit AES-256, verpackt den Schlüssel mit
dem öffentlichen RSA-4096-Schlüssel des Angreifers und schickt das Ergebnis
per unverschlüsseltem HTTP an http://www.litellm.dev/healthcheck. Der
Angreifer hatte die Domain am 13. Oktober registriert. Solange der Server
nicht mit 200 antwortet, läuft der Stealer beim nächsten Python-Start erneut.
Die domain.txt des Loaders war beim Release leer, die zweite Stufe wurde
also nie scharf geschaltet. Das GitHub-Konto dahinter ist gelöscht, aber
infizierte Hosts fragen diese URL weiterhin jede Stunde ab. Registriert
jemand den Benutzernamen neu, führt jeder dieser Hosts dessen Code aus.
Warum Coding-Agenten exponiert waren
Ein Agent, der an einem Python-Projekt arbeitet, führt pip install -U litellm aus, weil ein README oder eine Fehlermeldung es nahelegt. Er führt
pip install -r requirements.txt mit einer ungepinnten litellm-Zeile aus.
Er legt ein frisches Virtualenv an und installiert das Neueste, zu jeder
Uhrzeit, und niemand liest, welche Version zurückkam.
Am Dienstag zwischen 11:39 und 13:23 UTC hieß „das Neueste“ 1.107.0. Auf
einem Agenten-Host lief cook.pth dann bei jedem Aufruf von python,
pytest und pip, den der Agent für den Rest der Sitzung machte. Die
Zugangsdaten des Entwicklers waren in Reichweite des Stealers, und die
Anmeldungen des Agenten standen auf seiner Zielliste.
Bromure hat 1.107.0 nie ausgeliefert
Bromure Agentic Coding führt Claude Code, Codex, Grok, Kimi und omp in
Linux-VMs auf Ihrem Mac aus. Jeder Paketabruf aus der VM läuft über einen
Proxy auf dem Mac, der die Supply-Chain-Richtlinie der Maschine anwendet,
bevor die Antwort pip erreicht. Die VM kann diese Richtlinie nicht ändern.
Die pip.conf in der VM kann sie nur enger fassen.
Eine Schicht dieser Richtlinie ist auf jeder neuen Maschine aktiv: die
Mindestalter-Sperre, mit einem Mindestalter von 2 Tagen. Wenn pip den
Simple Index von PyPI fragt, welche Versionen von litellm es gibt, streicht
der Proxy jede Datei, die jünger als die Grenze ist, aus der Liste, bevor pip
sie liest. Am Dienstag um 11:45 UTC bekam eine Bromure-Maschine bei pip install -U litellm eine Liste, die bei 1.106.2 endete, dem letzten sauberen
Release, und installierte dieses. Für den Agenten gab es 1.107.0 noch nicht,
und bis die Sperre sich geöffnet hätte, hatte PyPI das Release gelöscht.
Pinnt der Agent die Version, ist der Artefakt-Abruf die zweite Prüfung. Der Proxy schlägt den Veröffentlichungszeitpunkt der Datei auf PyPI nach und verweigert den Download mit 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
pip gibt den Grund wörtlich aus, also liest der Agent ihn auch, und oft pinnt er von sich aus eine ältere Version.
Die Sperre brauchte kein Advisory, keine Malware-Signatur und niemanden, der
das Paket benennt. Registries und Forscher finden und entfernen ein
gekapertes Release meist innerhalb von Stunden, und zwei Tage Wartezeit
kosten Sie in der normalen Entwicklung fast nichts. Wenn Ihr eigenes Team ein
Paket veröffentlicht, das sich sofort nach dem Release installieren lassen
muss, tragen Sie es im Supply Chain-Bereich der Maschine unter Exempt
packages ein, als pypi:yourpkg. Der Rest von PyPI wartet weiterhin zwei
Tage.
Derselbe Bereich fügt weitere Schichten hinzu. Die OSV-Prüfung verweigert jede Version mit einem veröffentlichten Advisory ab dem von Ihnen gewählten Schweregrad. Der socket.dev-Filter blockiert Pakete, die als Malware markiert sind. Kann eine von Ihnen aktivierte Reputationsquelle kein Urteil liefern, pausiert der Download, und Bromure fragt Sie nach genau diesem Paket und dieser Version.
Das Token im Log war ein Platzhalter
Das Release war nur möglich, weil die Angreifer echte GitHub-Tokens besaßen. LiteLLM vermutet, dass sie sie aus einer Installation hatten, die Modell-Traffic protokollierte und in der Claude Code das GitHub-CLI-Token eines Entwicklers in eine Antwort geschrieben hatte.
In einer Bromure-Maschine ist dieses Token ein Platzhalter. Beim Start der VM
schreibt Bromure ein gefälschtes ghp_-Token in die Konfiguration von gh
und in ~/.git-credentials und exportiert es als GH_TOKEN. Die Fälschung
hat das richtige Präfix und die richtige Länge, also akzeptiert gh sie. Das
echte Token bleibt auf dem Mac. Verlässt eine Anfrage die VM in Richtung
GitHub, tauscht der Proxy die Fälschung auf der Leitung gegen den echten Wert
aus, nur für GitHubs Hosts.
Ein Agent kann das Token immer noch in eine Antwort kopieren. Er hat nur den Platzhalter zum Kopieren. Der Modellanbieter und jedes Gateway-Log dazwischen zeichnen ebenfalls den Platzhalter auf. Wird er vom Laptop eines anderen aus wiederverwendet, lehnt GitHub ihn ab, weil dort kein Bromure-Proxy ist, der ihn austauscht.
Release-Berechtigungen bekommen denselben Schutz. Im Guardrails-Bereich
einer Maschine hat jedes Forge-Token eine Schreibrichtlinie, und neue
Maschinen starten mit Prompt before write. Ein Force-Push auf einen
Release-Workflow oder ein API-Aufruf, der den Branch-Schutz abschaltet,
wartet auf einen Dialog auf Ihrem Mac, der METHOD /path zeigt, und Sie
entscheiden.
Was privcollect gefunden hätte
Angenommen, 1.107.0 hätte doch eine Maschine erreicht, weil jemand die Sperre abgeschaltet oder eine Ausnahme eingetragen hat. Die Liste des Stealers ist lang. In einer Bromure-VM enthält das meiste, was er öffnen kann, Platzhalter:
GH_TOKEN,ANTHROPIC_API_KEYund alle anderen Zugangsdaten der Maschine, in der Umgebung jedes Prozesses, die der Stealer über/procliest, sind Fälschungen.~/.git-credentials,~/.docker/config.json,~/.kube/config,~/.aws/configund die MCP-Konfigurationen enthalten Fälschungen. Ebenso die Anmeldedateien der Agenten selbst: Die Claude-, ChatGPT-, Grok- und Kimi-Abonnements werden auf dem Mac angemeldet und erneuert.- Die Schlüssel der Modellanbieter kommen nie in die VM, es gibt also keine Proxy-Datenbank voller Upstream-Schlüssel zum Auslesen. Der Proxy von Bromure fügt sie auf dem Mac hinzu, wie wir in Der Provider läuft dort, wo die Schlüssel liegen beschrieben haben.
- SSH-Schlüssel signieren über eine Agent-Bridge zum Mac. Die Bytes des
privaten Schlüssels kommen nie in die VM, also gibt es in
~/.sshnichts zu holen.
Ein Platzhalter funktioniert nur über den Proxy dieser Maschine, gegenüber dem Host, für den er ausgestellt wurde. Der Angreifer kann das Archiv entschlüsseln und jeden Wert darin ausprobieren, und GitHub, AWS und Anthropic lehnen jeden einzelnen ab.
Der POST hatte kein Ziel
Das Bündel geht als unverschlüsselter HTTP-POST an www.litellm.dev, eine
Domain, die die Maschine nie gebraucht hat. Steht Unmatched traffic im
Guardrails-Bereich auf Deny, erreicht die VM nur die Hosts, die Sie
auflisten:
allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
allow web api.anthropic.com
default deny
Der Switch auf dem Mac verweigert die Verbindung, und die Security Timeline
zeigt eine rote deny-Zeile mit Host und Port. Die zweite Stufe des
Loaders scheitert genauso: Welchen Host domain.txt auch nennt,
http://<host>/codepy steht nicht auf der Liste, also hat das stündliche
exec() nichts auszuführen.
In einem Workspace mit der Sandbox-Engine OpenShell + Sentry öffnet
Show the Whole Chain auf dieser Zeile die Herkunftskette des blockierten
Datenflusses. Sie zeigt den pip install-Tool-Aufruf des Agenten und die
Begründung, die das Modell dafür gab, dann den pip-Prozess, in dem
cook.pth den Stealer startete, dann die verweigerte Verbindung. Sie sehen
die Anweisung, die das Paket hereingeholt hat, und den Grund, den das Modell
für ihre Ausführung angab.
„Haben wir es installiert?“ – ein Filter genügt
Jedes Response-Team hatte am Dienstagnachmittag dieselbe erste Frage: Welche
unserer Maschinen haben 1.107.0 ausgeführt? LiteLLM warnt, dass ein fehlendes
cook.pth nichts beweist: Seine Abwesenheit heißt nicht, dass ein Host
sauber ist.
Bromure zeichnet jeden Paketabruf auf, den der Proxy sieht, unabhängig von
der Richtlinie und davon, ob überhaupt eine Prüfung aktiv ist. Öffnen Sie
Window → Security Timeline…, wählen Sie im Engine-Filter Supply
chain und suchen Sie nach litellm. Jede Zeile nennt Paket und Version,
pypi · [email protected], und was damit geschah. Die Aufzeichnung bleibt 90
Tage auf dem Mac, und Export… speichert sie als CSV für das
Incident-Ticket. Auf einem bei bromure.io registrierten Mac ist jeder Abruf
außerdem ein supply_chain.fetch-Ereignis mit Ökosystem, Paket, Version und
Ergebnis. Ein Administrator kann jede Maschine der Organisation auflisten, die
[email protected] heruntergeladen hat, ohne jemanden zu fragen.
Das Aufräumen nach einem Treffer geht schnell. LiteLLMs Anleitung sagt: den
Daemon beenden, der /tmp/remote_code_loader.lock hält, bevor pip läuft, die
Payload-Dateien aus jedem site-packages löschen, neu bauen und rotieren. In
Bromure klont Reset disk die Systemplatte neu aus dem Basis-Image, und
Rewind home… setzt das Home auf einen Checkpoint vor der Installation
zurück. Die Schlüssel, die Sie sonst in Eile rotiert hätten, waren
Platzhalter. Die echten haben den Mac nie verlassen.
Neue Releases warten zwei Tage
Die Mindestalter-Sperre ist auf jeder neuen Maschine aktiv. Installationen aus PyPI, npm, Cargo, RubyGems und Packagist lösen auf die neueste Version auf, die älter als die Grenze ist. Eine gepinnte frische Version bekommt ein 451 mit dem Grund.
Das Token im Log ist eine Fälschung
GitHub-, Cloud-, Registry- und Modell-Zugangsdaten kommen als Platzhalter in die VM. Der Proxy setzt auf dem Mac den echten Wert ein, für den jeweils eigenen Host.
Exfiltration braucht eine Route
Steht nicht gelisteter Traffic auf deny, stirbt ein POST an eine Domain, die letzte Woche registriert wurde, am Switch und erscheint als rote Zeile.
Jede Installation ist protokolliert
Die Security Timeline listet 90 Tage lang jedes Paket und jede Version, die jede Maschine abgerufen hat. Registrierte Macs senden dieselben Ereignisse an die Organisation.
Lassen Sie die Registry zuerst aufholen
Die Angreifer brauchten Minuten, um 1.107.0 zu veröffentlichen. PyPI brauchte 1 Stunde 43 Minuten, um es zu entfernen, was für eine Registry schnell ist. Ihr Agent installiert den ganzen Tag Pakete, zu jeder Uhrzeit, und nimmt das Neueste, also landet er in genau solchen Zeitfenstern. Auf einer Bromure-Maschine wartet er zwei Tage. Bis dahin haben PyPI, Wiz, StepSecurity und das LiteLLM-Team selbst sich das Release angesehen, und ein schlechtes ist verschwunden.
Zum Stealer, der es Anfang des Monats auf die Zugangsdaten von Coding-Agenten abgesehen hatte, lesen Sie Der Stealer packte die Platzhalter ein. Wie Tokens überhaupt in Transkripten und Modell-Logs landen, lesen Sie in Das Transkript behielt das Token.