← Zurück zu allen Beiträgen
Veröffentlicht am · von Renaud Deraison

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. Pythons site-Modul führt Import-Zeilen in .pth-Dateien bei jedem Interpreter-Start aus. Niemand muss je import litellm aufrufen. 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 er domain.txt aus einem GitHub-Repository unter Kontrolle des Angreifers, lädt http://<host>/codepy und führt das Ergebnis mit exec() 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.

Das Release war längst weg, bevor die Sperre aufgegangen wäre21. Okt. 08:0022. Okt. 00:0023. Okt. 00:0023. Okt. 14:00litellm 1.107.0 auf PyPI11:39 bis 13:23 · rund 49.000 DownloadsDocker-Hub-Tags bis 14:20Bromure-Mindestalter-Sperre, Standard 2 Tagepip auf einer Bromure-Maschine sieht 1.106.2 als neuestes litellm23. Okt. 11:39: Sperre ginge auf. Nichts mehr da.entferntZeiten aus LiteLLMs Incident-Bericht. Download-Schätzung von Wiz.
litellm 1.107.0 auf PyPI, 21. bis 23. Oktober, UTC. Das bösartige Release war 1 Stunde 43 Minuten online und schon mehr als 46 Stunden verschwunden, bevor die Standard-Mindestalter-Sperre von Bromure es pip gezeigt hätte.

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_KEY und alle anderen Zugangsdaten der Maschine, in der Umgebung jedes Prozesses, die der Stealer über /proc liest, sind Fälschungen.
  • ~/.git-credentials, ~/.docker/config.json, ~/.kube/config, ~/.aws/config und 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 ~/.ssh nichts 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.

Vier Schritte, vier Prüfungen auf dem MacDer Angriff, 21. Oktober1 · Tokens in einem Loggh-OAuth-Tokens kopiertaus Modell-Traffic-Logs2 · Workflow force-gepushtSchutz ausgehebelt,Build aus Berri-AI/litellm3 · Wheels auf PyPIlitellm==1.107.011:39 bis 13:23 UTC4 · cook.pth läuftsammelt, dann POST anwww.litellm.devIn einer Bromure-MaschinePlatzhalter-Tokendie VM hat ein falsches ghp_;das Log behält die Fälschung.GitHub lehnt es woanders ab.SchreibrichtliniePushes und Änderungen amBranch-Schutz warten aufeinen Dialog auf dem Mac.Mindestalter, ab Werk anpip löst 1.106.2 auf;ein gepinntes 1.107.0bekommt HTTP 451.Fälschungen, Default-Denydie Beute sind Platzhalter;POST und /codepy werdenam Switch verweigert.
Die Kette von litellm 1.107.0 und wo eine Bromure-Maschine sie durchbricht. Jede der vier Prüfungen läuft auf dem Mac, außerhalb der VM, in die das Paket installiert wird.

„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.