そのリリースは公開から二時間だった
10 月 21 日、LiteLLM の開発者 3 人から盗んだ GitHub CLI トークンを持つ攻撃者が、litellm 1.107.0 を PyPI に公開した。どの wheel にも、Python が起動するたびに動く認証情報スティーラーと、1 時間ごとにリモートコードを取りに行くローダーが仕込まれていた。PyPI は 1 時間 43 分後に削除したが、それまでの推定ダウンロード数は約 49,000 件。Bromure Agentic Coding では、既定で有効な経過日数ゲートが公開から 2 日未満のリリースをすべて隠すので、Bromure マシン上の pip が 1.107.0 を目にすることはなかった。
悪意あるリリースが PyPI に置かれていたのは 1 時間 43 分。Bromure の既定設定なら、エージェントのマシン上の pip がそれを目にするのは公開から 2 日後だった。
10 月 21 日火曜日の 11:39 UTC、litellm==1.107.0 の wheel 7 つが PyPI に届いた。LiteLLM は、多くのチームがモデルへのトラフィックを振り分けるのに使っている Python ライブラリ兼プロキシで、数多くの AI ツールに組み込まれている。新しいリリースはプロジェクト自身のものに見えた。プロジェクトのリリースワークフローが署名し、SLSA の来歴情報も付いていた。そして、認証情報スティーラーとバックドアも入っていた。
PyPI は 13:23 UTC に削除した。v1.107.0、latest、main-latest、main-stable のタグが付いた感染済み Docker イメージは、14:20 まで Docker Hub に残った。Wiz は、この間の悪意あるバージョンのダウンロード数を約 49,000 件と推定している。LiteLLM のインシデントレポートは、外向きのインターネット接続があるホストにインストールした人に向けて「持ち出しは成功したものと考えよ」と書いている。
このプロジェクトが侵害されたのは今年 2 度目だ。1 度目は 3 月で、CI に入り込んだ汚染されたセキュリティスキャナー経由だった。そのときは対応チームが間違った時間帯を調べた話を書いた。
攻撃者はどうやって鍵を手に入れたか
LiteLLM のチームは分刻みのタイムラインを公開している。攻撃者は 3 人の開発者の GitHub CLI OAuth トークンを持っていた。その月曜日、2 人のエンジニアがバグ修正のバックポートを出せるよう、リリース権限が広げられていた。火曜日の 10:08、ブランチ保護が非公開のリリースワークフローへの変更を拒否した。5 秒後、組織に 2 人いるオーナーのうち 1 人のトークンが保護を無効化して、その変更を push した。変更後のワークフローは本物の BerriAI/litellm によく似た Berri-AI/litellm からビルドし、2 人目の開発者のトークンが本番の承認ゲートを通した。11:53 には、3 人目の開発者のトークンがワークフローの実行記録を削除した。
LiteLLM は、トークンの出どころを 2 つの発見にたどっている。1 つは、インターネットに公開されていた社内の LiteLLM デプロイメント上にあった「予期しない、無害そうな名前のユーザーアカウント」。もう 1 つはコーディングエージェントに関わる。レポートによれば、LiteLLM は「Claude Code がユーザーの GitHub CLI OAuth トークンをレスポンス本文に埋め込む具体的な状況を確認しており、レスポンス本文がログに記録される状況では、これらのトークンが LiteLLM のログに現れる可能性がある」という。
モデルへのトラフィックを記録するゲートウェイは、エージェントが書いたものすべての写しを持つ。今回、その写しにはパッケージを公開できるトークンが含まれていた。
wheel は何をしたか
StepSecurity の解析は、GitHub 上のソースがきれいに見えた理由を説明している。攻撃者はビルド設定に 1 行だけ足した。[tool.maturin] の下の data = "cook" だ。これで cook/ ディレクトリが各 wheel にコピーされた。このディレクトリはソース配布物には一度も現れず、GitHub の v1.107.0 タグにも痕跡はない。
各 wheel は site-packages に 3 つのファイルを置く。
cook.pth、12 バイト:import cook。Python のsiteモジュールは、インタープリタが起動するたびに.pthファイル内の import 行を実行する。誰かがimport litellmする必要はない。pip を起動するだけで足りる。cook.py、難読化された 1 行で、デコードすると約 61,000 バイトになる。出力に"tool": "privcollect"というタグを付け、16 個の収集モジュールを動かすスティーラーだ。sitecustomize.py、切り離されたデーモンとして fork するローダー。1 時間ごとに、攻撃者が管理する GitHub リポジトリからdomain.txtを読み、http://<host>/codepyを取得して、その結果をexec()で実行する。
スティーラーが持ち去るのは、SSH 鍵と GPG 鍵、クラウドの認証情報とインスタンスメタデータ、~/.kube/config、Docker の設定、GitHub CLI トークン、.git-credentials、.netrc、npm・PyPI・Cargo などのレジストリトークン、2 階層下までの .env ファイル、ブラウザのログイン情報と Cookie、シェルとデータベースの履歴、そして /proc/<pid>/environ から読める全プロセスの環境変数だ。さらに Claude Code、Claude Desktop、Cursor、Windsurf、Kiro、GitHub Copilot、Gemini CLI、Codex、Continue、Aider、Goose ほか十数個の AI ツールの認証・設定ファイルも対象に挙げている。LiteLLM プロキシのホスト上では、プロキシのデータベースからプロバイダの API キーを読み出す。
スティーラーは集めたものを AES-256 で暗号化し、その鍵を攻撃者の RSA-4096 公開鍵で包んで、暗号化なしの HTTP で http://www.litellm.dev/healthcheck に送る。攻撃者はこのドメインを 10 月 13 日に登録していた。サーバーが 200 を返すまで、スティーラーは次に Python が起動したときにもう一度動く。
ローダーの domain.txt は公開時点で空だったので、第 2 段階は起動しなかった。その背後にあった GitHub アカウントは削除されたが、感染したホストは今も 1 時間ごとにその URL を問い合わせている。誰かがそのユーザー名を取り直せば、それらのホストすべてがその人物のコードを実行する。
なぜコーディングエージェントが狙われやすかったか
Python プロジェクトで作業するエージェントは、README やエラーメッセージに勧められて pip install -U litellm を実行する。バージョンを固定していない litellm 行を含む pip install -r requirements.txt を実行する。新しい virtualenv を作って最新版を入れる。時間帯は問わないし、入ってきたバージョンを誰も読まない。
火曜日の 11:39 から 13:23 UTC のあいだ、最新版とは 1.107.0 のことだった。エージェントのホストでは、その後セッションが終わるまで、エージェントが呼ぶ python、pytest、pip のたびに cook.pth が動いた。開発者自身の認証情報はスティーラーの手の届くところにあり、エージェントのサインイン情報はその標的リストに載っていた。
Bromure は 1.107.0 を一度も配らなかった
Bromure Agentic Coding は、Claude Code、Codex、Grok、Kimi、omp を Mac 上の Linux VM で動かす。VM からのパッケージ取得はすべて Mac 上のプロキシを通り、プロキシはレスポンスが pip に届く前にそのマシンのサプライチェーンポリシーを適用する。VM からこのポリシーは変えられない。VM 内の pip.conf にできるのは、それをさらに絞ることだけだ。
このポリシーの 1 層は、新しいマシンすべてで有効になっている。最小経過日数 2 日の経過日数ゲートだ。pip が PyPI の simple インデックスに litellm のどのバージョンがあるかを尋ねると、プロキシは pip が読む前に、しきい値より新しいファイルを一覧からすべて取り除く。火曜日の 11:45 UTC に pip install -U litellm を実行した Bromure マシンが受け取った一覧は、最後のクリーンなリリースである 1.106.2 で終わっており、pip はそれをインストールした。エージェントにとって 1.107.0 はまだ存在せず、ゲートが開くころには PyPI がすでに削除していた。
エージェントがバージョンを固定した場合は、成果物の取得が 2 つ目の関門になる。プロキシは PyPI でそのファイルの公開時刻を調べ、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 は理由をそのまま表示するのでエージェントもそれを読み、多くの場合は自分から古いバージョンに固定し直す。
ゲートには、セキュリティアドバイザリも、マルウェアのシグネチャも、パッケージを名指しする人も要らなかった。レジストリや研究者は乗っ取られたリリースをたいてい数時間で見つけて取り下げるし、2 日待つことは普段の開発ではほとんど何のコストにもならない。自分のチームが公開するパッケージで、出たその瞬間にインストールしたいものがあるなら、マシンの Supply Chain ペインの Exempt packages に pypi:yourpkg の形で追加すればよい。PyPI のそれ以外は引き続き 2 日待つ。
同じペインでは、さらに層を足せる。OSV チェックは、選んだ深刻度以上の公開アドバイザリがあるバージョンを拒否する。socket.dev フィルタはマルウェアと判定されたパッケージをブロックする。有効にした評判ソースが判定を返せない場合は、ダウンロードが一時停止し、Bromure がそのパッケージとバージョンについてあなたに尋ねる。
ログのトークンは代用値だった
このリリースが可能だったのは、攻撃者が本物の GitHub トークンを持っていたからにほかならない。LiteLLM は、モデルへのトラフィックを記録していたデプロイメントからそれが入手されたと見ている。そこでは Claude Code が、ある開発者の GitHub CLI トークンをレスポンスに書き込んでいた。
Bromure マシンでは、そのトークンは代用値だ。VM の起動時に、Bromure は偽の ghp_ トークンを gh の設定と ~/.git-credentials に書き込み、GH_TOKEN としてエクスポートする。偽物は接頭辞も長さも正しいので、gh は受け入れる。本物のトークンは Mac に置かれたままだ。リクエストが VM から GitHub に向けて出ていくとき、プロキシが通信路上で偽物を本物の値に差し替える。対象は GitHub のホストだけだ。
エージェントは、それでもトークンを返答に貼り付けることがある。だが貼り付けられるのは代用値だけだ。モデルのプロバイダも、途中にあるゲートウェイのログも、記録するのは代用値になる。別の誰かのノート PC から再利用しても、GitHub は拒否する。そこには差し替えを行う Bromure のプロキシがないからだ。
リリース権限も同じように守られる。マシンの Guardrails ペインでは、フォージのトークンごとに書き込みポリシーがあり、新しいマシンは Prompt before write で始まる。リリースワークフローへの force-push や、ブランチ保護を無効にする API 呼び出しは、METHOD /path を表示する Mac 上のダイアログで止まり、判断するのはあなただ。
privcollect が見つけたはずのもの
誰かがゲートを切ったか例外を追加したかで、1.107.0 がマシンに届いたとしよう。スティーラーのリストは長い。Bromure の VM では、それが開けるものの大半に代用値が入っている。
GH_TOKEN、ANTHROPIC_API_KEYをはじめ、マシンに与えられた認証情報はすべて偽物で、スティーラーが/proc経由で読むどのプロセスの環境変数でも同じだ。~/.git-credentials、~/.docker/config.json、~/.kube/config、~/.aws/config、MCP の設定にも偽物が入っている。エージェント自身のサインインファイルも同様で、Claude、ChatGPT、Grok、Kimi のサブスクリプションは Mac 上でサインインと更新が行われる。- モデルプロバイダのキーは VM に一切入らないので、上流のキーが詰まったプロキシのデータベースというものがそもそもない。プロバイダは鍵のある場所で動くで説明したとおり、Bromure のプロキシが Mac 上でキーを付け加える。
- SSH 鍵は、Mac へのエージェントブリッジ経由で署名する。秘密鍵のバイト列は VM に入らないので、
~/.sshには盗るものがない。
代用値が機能するのは、そのマシンのプロキシを通り、発行先のホストに向かうときだけだ。攻撃者はアーカイブを復号して中の値を片端から試せるが、GitHub も AWS も Anthropic も、そのすべてを拒否する。
POST には行き場がなかった
集めたものは、マシンが一度も必要としなかったドメイン www.litellm.dev に、暗号化なしの HTTP POST で送られる。Guardrails ペインで Unmatched traffic を Deny にしておけば、VM が届くのはあなたが列挙したホストだけになる。
allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
allow web api.anthropic.com
default deny
Mac 上のスイッチが接続を拒否し、Security Timeline にはホストとポートを示す赤い deny 行が出る。ローダーの第 2 段階も同じように失敗する。domain.txt がどのホストを指していようと、http://<host>/codepy はリストにないので、1 時間ごとの exec() には実行するものがない。
サンドボックスエンジンに OpenShell + Sentry を使うワークスペースでは、その行の Show the Whole Chain がブロックされた通信の系譜を開く。エージェントの pip install ツール呼び出しと、モデルがそれに付けた理由、次に cook.pth がスティーラーを起動した pip プロセス、そして拒否された接続が並ぶ。パッケージを引き込んだ指示と、それを実行した理由としてモデルが述べた内容が見える。
「うちは入れたか」はフィルタ 1 つでわかる
火曜日の午後、どの対応チームも最初に同じ問いを抱えた。うちのマシンのどれが 1.107.0 を実行したのか。LiteLLM は、cook.pth が見つからなくても何の証明にもならないと警告している。ないからといって、そのホストがクリーンだとは限らない。
Bromure は、ポリシーがどうであれ、チェックが有効かどうかにかかわらず、プロキシが見たパッケージ取得をすべて記録する。Window → Security Timeline… を開き、エンジンフィルタで Supply chain を選んで litellm を検索する。各行にはパッケージとバージョン(pypi · [email protected])と、それがどう扱われたかが表示される。記録は Mac 上に 90 日間残り、Export… でインシデントチケット用に CSV として保存できる。bromure.io に登録された Mac では、各取得はエコシステム、パッケージ、バージョン、結果を持つ supply_chain.fetch イベントにもなる。管理者は誰にも尋ねることなく、組織内で [email protected] をダウンロードしたマシンをすべて一覧できる。
被害を受けたあとの後始末も早い。LiteLLM のガイドは、pip を実行する前に /tmp/remote_code_loader.lock を握っているデーモンを止め、すべての site-packages からペイロードのファイルを消し、作り直してローテーションせよと言う。Bromure では、Reset disk がベースイメージからシステムディスクを複製し直し、Rewind home… がホームをインストール前のチェックポイントに戻す。本来なら大急ぎでローテーションするはずの鍵は代用値だった。本物は一度も Mac を出ていない。
新しいリリースは 2 日待つ
経過日数ゲートは新しいマシンすべてで有効だ。PyPI、npm、Cargo、RubyGems、Packagist からのインストールは、しきい値より古いなかで最新のバージョンに解決される。新しすぎるバージョンを固定すると、理由付きの 451 が返る。
ログのトークンは偽物
GitHub、クラウド、レジストリ、モデルの認証情報は、代用値として VM に入る。プロキシが Mac 上で、それぞれの認証情報本来のホストに向けて本物の値を差し込む。
持ち出しには経路が要る
未登録の通信を deny にしておけば、先週登録されたドメインへの POST はスイッチで止まり、赤い行として現れる。
インストールはすべて記録に残る
Security Timeline は、各マシンが取得したパッケージとバージョンをすべて 90 日間一覧する。登録済みの Mac は同じイベントを組織に送る。
まずレジストリに追いつく時間を与える
攻撃者が 1.107.0 を公開するのにかかったのは数分。PyPI が取り下げるのにかかったのは 1 時間 43 分で、レジストリとしては速い。あなたのエージェントは一日じゅう、時間を問わずパッケージを入れ、最新のものを取るので、こうした時間帯にはまり込む。Bromure マシンでは、エージェントは 2 日待つ。そのころには PyPI、Wiz、StepSecurity、そして LiteLLM のチーム自身がリリースを調べ終えていて、悪いものは消えている。
今月初めにコーディングエージェントの認証情報を狙ったスティーラーについては、スティーラーは代用値を持ち去ったを。そもそもトークンがどうやってトランスクリプトやモデルのログに入り込むのかについては、トークンはトランスクリプトに残ったを読んでほしい。