PyPIサプライチェーン攻撃:LiteLLMが残した教訓
LiteLLMのPyPI侵害では、全wheelに認証情報窃取マルウェアが含まれていました。ハッシュ固定とロックファイルで防げたこと、防げなかったことを解説します。

ファイルは3つだけです。これが、私たちが見てきた中でも特に悪質なPythonサプライチェーン攻撃の全容です。12バイトのcook.pth、22 KBのcook.py、25 KBのsitecustomize.py。LiteLLM 1.107.0がPyPIに公開されたとき、この3つのファイルは7つのwheelすべてに同梱されていました。Pythonの起動の仕組みのせいで、感染したホスト上のあらゆるPythonプロセスでこれらが実行されました。アプリ、CIランナー、pip本体、さらにはエディタのLanguage Serverまで。litellmをimportする必要もなければ、実行する必要もありません。どのインタープリタを起動しても十分だったのです。
LiteLLMチームは侵害を確認し、PyPIも数時間以内にリリースを取り下げました。ただ、その時間の窓は十分に長かった。悪意あるDockerイメージ(latestタグを含む)は約2時間半の間公開されていました。その間にCIがイメージを取得していたなら、スティーラーはすでにSSHキー、クラウド認証情報、ブラウザのパスワード、CI/CDのシークレット、AIツールのトークンを収集し、暗号化して攻撃者の管理するドメインに送信していたことになります。これはタイポスクワッティングやよく似た名前のパッケージではありません。本物のパッケージで、本物のパイプラインがビルドし、本物のリリースプロセスで署名されたものです。依存関係への信頼の考え方を見直すべき理由がここにあります。
3つのファイルで公式リリースパイプラインを乗っ取った手口
攻撃者はPyPIをハッキングしたわけでも、公開リポジトリにこっそりコミットを紛れ込ませたわけでもありません。上流のGitHubソースは無傷です。LiteLLMのインシデントレポートによると、攻撃者はインターネットに公開されていた社内のLiteLLMデプロイのログから抜き取ったとみられる、3人の開発者のGitHub CLI認証情報を持っていました。組織オーナーのトークンを使ってブランチ保護を回避し、プライベートのリリースワークフローを、正規のBerriAI/litellmではなくよく似た名前のBerri-AI/litellmを指すように変更しました。さらに別の開発者の認証情報で承認ゲートを通過。その結果、公式パイプラインが攻撃者の成果物をビルドし、署名し、公開してしまったのです。
実際の注入は驚くほど小さなものでした。sdistのpyproject.tomlに追加されたのは1行だけです。
[tool.maturin]
python-source = "."
data = "cook" # <-- injected, copies cook/ into the wheel
python-packages = ["litellm_cli"] # legitimate, present upstream
このdata = "cook"というディレクティブにより、maturinはwheelのpurelibパスにcook/ディレクトリを同梱するよう指示され、pipはそれを忠実にsite-packagesへインストールします。sdist自体にはそのフォルダが含まれていなかったため、リリースを流し見した人には謎めいた1行しか見えなかったはずです。ペイロードはソースではなく、ビルド成果物の中に隠れていました。大半の人が信頼の拠り所としているgitの履歴を確認しても、何も見つからなかったでしょう。
.pthファイルとsitecustomize.pyが、マルウェアにとって理想的な理由
Pythonの起動シーケンスには、今回の攻撃を異様に強力にした2つの仕組みがあります。正直なところ、多くのPython開発者が一度も意識したことのない機能です。1つ目。site-packagesにある.pthファイルはすべてインタープリタの起動時に処理され、importで始まる行は実行されます。12バイトのcook.pthにはimport cookという1行だけが入っていて、それだけでマシン上のすべてのPythonプロセスでスティーラーを動かすのに十分でした。2つ目。Pythonは起動時に、パス上にsitecustomizeというモジュールがあれば自動的にimportします。攻撃者は自前のsitecustomizeを仕込みました。それは切り離されたデーモンをforkし、1時間ごとにGitHub上のテキストファイルへ接続して、攻撃者が指定したコードをダウンロードし、exec()で実行していました。
これは、汚染されたsetup.pyや__init__.py内の不正なimportを使う一般的な悪意あるパッケージよりも、はるかに深刻です。それらはパッケージをインストールしたりimportしたりしたときに実行されます。しかし今回は、Pythonで何かを実行するたびに実行されたのです。CIでのpytestの実行。何が起きたかを確認するために打ったpip list。IDEに入っているblackフォーマッタ。実行のたびに罠が再び仕掛けられていました。
スティーラーが実際に何を集めたのかを見ておく価値があります。その一覧は、現代の開発者の生活の地図のようなものです。AWS、Azure、gcloudの認証情報、kubeconfig、GitHub CLIのトークン、SSHとGPGのキー、ブラウザのパスワードストア、シェルの履歴、GitLab RunnerやJenkinsのCIシークレット、Terraformの認証情報、/proc/<pid>/environ経由で読み取られた他のプロセスの環境変数、AWSインスタンスメタデータの認証情報、そして皮肉なことに、Claude Code、Cursor、Copilotをはじめとする十数種類のAIコーディングツールのトークン。さらにLiteLLM自身のプロキシデータベースに保存されていたLLMプロバイダのキーまで含まれます。pipを1回実行するだけで、攻撃者はエンジニアとしてのあなたのアイデンティティをほぼ丸ごと手にすることになるのです。
litellmをimportする必要はありませんでした。そのホスト上で何かPythonインタープリタを起動するだけで、スティーラーは動きました。
ハッシュ固定とロックファイル:必要条件だが十分ではない
サプライチェーンのセキュリティチェックリストは、どれもロックファイルとハッシュ固定から始まります。そしてそれは正しいことです。pip install --require-hashesでハッシュを固定するか、ロック済みハッシュを使うuvやPoetryのようなツールを使えば、検証後にこっそり内容が変わったwheelは拒否されます。これは、レジストリの改ざん、ミラーの汚染、成果物の差し替えといった攻撃クラス全体に対する実効的な防御です。
ただ、この件に限って言えば正直に言うと、効果はありません。少なくとも、最初にアップグレードした人にとっては。ハッシュ固定が保証するのは、今日インストールした成果物と明日インストールする成果物がバイト単位で同一であることだけです。今回の攻撃では、悪意あるwheelこそが公式の成果物でした。第一者が、パイプラインでビルドし、一貫したハッシュを持つものです。誰かが定例のアップグレードを行い、新しいバージョンを解決し、更新されたロックファイルをコミットした瞬間、悪意あるハッシュがグリーンのCIチェックとともにリポジトリに固定されてしまいます。ハッシュ固定は変更から守ってくれますが、意図については何も教えてくれません。
今回のような事例で、ハッシュ固定が本当に役立つのは被害範囲(ブラスト半径)を抑える場面です。ハッシュを固定し、計画的なアップグレードのサイクルを持つチームは、リリースされた瞬間に自動的に乗ってしまうことはありません。もしポリシーが「週次のPRで依存関係をアップグレードし、重大な修正以外は数日のクーリングオフ期間を置く」なら、PyPIでの削除(協定世界時13:23)は、あなたのアップグレード時間枠が開く前に起きていたことになります。退屈なプロセスが、賢いツールよりも効くことは、認めたくないほど多いものです。
だからロックファイルは維持してください。ハッシュ付きで生成しましょう。ただし、侵害されたメンテナのパイプラインに対する防御になると思い込むのはやめてください。それはドリフトと改ざんに対する防御であり、悪いリリースに対する速度違反の減速帯のようなものです。それだけです。

この件はプロヴナンス認証で防げていた
不都合な裏返しを言えば、今回の攻撃には明白で検知可能な異常があり、それを捕まえるエコシステムのツールは今すでに存在しています。悪意あるwheelは公開リポジトリからビルドされたものではありませんでした。攻撃者が作ったよく似たリポジトリからビルドされていたのです。「この成果物は、主張しているコミットとリポジトリからビルドされたのか」と問う検証ステップがあれば、大きく失敗していたはずです。
まさにそれを提供するのがプロヴナンス認証です。PyPIはPEP 740の認証をサポートしています。Trusted PublishersでGitHub Actionsから公開されたパッケージでは、ワークフローが、成果物を生成した正確なリポジトリ、ワークフロー、コミットに紐付けた署名付きの声明を生成できます。利用者側のチェックは機械的になります。認証を検証し、ソースリポジトリがBerri-AI/litellmではなくBerriAI/litellmであることを確認するだけです。Sigstoreバンドル、SLSAプロヴナンス、pip-auditのような検証も、この系統のものです。
現状のギャップは導入と強制にあります。認証は公開者側でのオプトインであり、存在していても、インストール時に検証しているところはほとんどありません。ここを変える必要があります。以前書いた「信頼の基盤は、誰かが実際に検証して初めて機能する」という指摘とも通じるところがあります(信頼の基盤は誰かが実際に検証して初めて機能する)。誰も検証しない署名は、ただの飾りです。自社でパッケージインデックスやプロキシを運用しているなら(そして数人以上のエンジニアがいるなら、運用すべきです)、「期待するソースからの、検証可能なプロヴナンスのない成果物は拒否する」というポリシーを一度設定すれば、全開発者に対して一括で強制できます。
もう一つ、より微妙な教訓があります。攻撃者には開発者の認証情報が3つ必要でした。ワークフローのリダイレクトに1つ、承認に1つ、そして確認をすり抜けるのに1つ。これは単一のミスではなく、認証情報管理の体系的な失敗です。PyPIの、OIDCベースで短命なTrusted Publisherトークンを使えば、攻撃対象面は劇的に小さくなっていたでしょう。ログファイルに盗まれる可能性のある長期トークンが残っていないからです。プロジェクトがまだCIのシークレットとして保存したAPIトークンで公開しているなら、これは背中を押される合図です。
サンドボックス化されたインストールと、クーリングオフ期間の必要性
最悪のケースを想定しましょう。悪意あるリリースが、ロックファイルの運用もプロヴナンスチェックもすり抜けてしまう。いつかは必ず起きます。そうなったら何が残るか。封じ込めです。問いは「悪いパッケージをどう防ぐか」から、「悪いパッケージが実行されたとき、何に手が届くのか」へと変わります。
CIについては、答えは現実的に実現可能です。ビルドは、長期の認証情報を持たず、スコープを絞ったトークンを使い、egressをフィルタリングしたエフェメラルな環境で実行されるべきです。LiteLLMのスティーラーの振る舞いは、ネットワーク上では非常に目立ちます。見張っていればの話ですが。見慣れないドメインへカスタム認証ヘッダ付きのPOST、1時間ごとにループしてrawのGitHub URLから取得する処理。そして最大の手がかりは、Pythonプロセスが169.254.169.254、つまりAWSのインスタンスメタデータサービスに問い合わせていることです。アプリケーションコードが正当な理由でIMDSに直接アクセスすることはほとんどありません。SDKがそれを処理するからです。そのエンドポイントに触れるPythonプロセスをアラートすれば、今回の1件だけでなく、この種の認証情報窃取犯の系統をまるごと捕まえられます。以前「クラウド認証ログの死角」について書きましたが、これもその一つで、塞ぐのは手軽です。
開発者のマシンはもっと難しく、そこは誤魔化すつもりはありません。同じユーザーがエディタ、ブラウザ、SSHエージェント、任意のpip installを全部動かしているラップトップは、スティーラーにとってのビュッフェです。現実的な対策は次のとおりです。探索的なインストールはホストの環境ではなくコンテナやdevcontainerで行う。パッケージのインストールはプロジェクトのdevcontainer内で行い、悪意あるsitecustomize.pyがホストのsite-packagesに入り込まないようにする。そして~/.aws/credentialsを負債として扱う。短命トークンのSSO(aws sso login、gcloud auth)を使えば、ディスク上に盗まれる対象がずっと少なくなります。どれも特別なことではありません。ただの摩擦です。そして摩擦こそが狙いなのです。
- ロックファイルでバージョンとハッシュの両方を固定し、アップグレードはバックグラウンドのノイズではなく、計画的なイベントとして扱う。
- 重要度の低い依存関係のアップグレードが本番ビルドに届く前に、48〜72時間のクーリングオフ期間を設ける。悪意あるリリースの大半は数時間以内に取り下げられる。
- プロヴナンス認証が存在する場合は検証し、長期のAPIトークンより、OIDCによるTrusted Publishersで公開されたパッケージを優先する。
- CIビルドは、egressフィルタリング付きの、エフェメラルで認証情報を持たない環境で実行し、インスタンスメタデータのエンドポイントに触れるPythonプロセスはすべてアラートする。
- 開発者マシンでのサードパーティコードのインストールと実行はコンテナ内で行い、静的なクラウド認証情報ファイルは短命のSSOセッションに置き換える。
- 期間内に何かをpullした場合は、IoCを確認する。site-packages内の
cook.pth、cook.py、または不審なsitecustomize.py、そしてディスク上の/tmp/.pc_once_*や/tmp/remote_code_loader.lock。
今回の攻撃が示す、本当のリスクの所在
攻撃者がわざわざやらなかったことに注目してください。コードの脆弱性を突いたわけではありません。ゼロデイも必要ありませんでした。彼らが狙ったのは、現代のソフトウェアで最も柔らかい標的、つまり常設の認証情報を持つ人間とマシンです。インターネットに公開された社内デプロイのログから拾われたとみられるGitHub CLIのトークン3つで、数百万回ダウンロードされるリリースパイプラインを乗っ取るには十分でした。これは業界全体で繰り返し見られてきたパターンです。シークレットがログ、エージェント、設定ファイルに散らばり、そのどれもがマスターキーになる。社内向けに何らかのインスタンスを運用し、それをインターネットに公開しているなら、そのログはもはや脅威モデルの一部です。この一文に、何人かは居心地の悪さを感じるはずです。
もう一つ、名前を挙げておくべき速度の問題があります。LiteLLMは変化の速いプロジェクトで、コミット数は数万、リリースも頻繁です。そしてAIツールスタックの中心に位置し、多くのチームが依存していることすら知らないフレームワークやSDKに取り込まれています。推移的な依存関係の影響範囲では、直接依存を選んだところで自分の露出を選べたことにはなりません。この世界ではSBOMはコンプライアンスの書類作業ではなく、「影響を受けているか?」という問いに、数日ではなく数分で答えるための手段になります。マニフェストを読み取るのではなく、実際のビルドの証拠から部品表を生成するツールなら、requirements.txtに書かれていない依存関係も見つけられるでしょう。

私の考えを言えば、業界のサプライチェーン攻撃への対応は、予防の演出に偏りすぎています。事後に既知の悪いバージョンにフラグを立てるスキャナーです。一方で、今回の展開を変えていたはずの地味な統制には、重点が置かれていません。短命の公開用認証情報。プロキシでのプロヴナンス検証。クーリングオフ期間。メタデータエンドポイントへのegressアラート。サンドボックス化されたインストール。どれも研究課題ではありません。今日すぐに使える設定上の判断です。それなのに、ほとんどのチームは、このうち一つも実装していません。LiteLLMの攻撃者は、ビルド設定の悪い1行と盗んだトークン3つを必要としただけです。上に挙げた防御があれば、彼らは1層ではなく4つの独立した層を突破しなければならなかったでしょう。それこそが多層防御の本当の意味です。スキャナーを増やすことではなく、失敗する独立した経路を減らすことなのです。これは、ただ最善を祈るのではなく、破局に耐えるシステムの設計と同じ考え方でもあります。
次に侵害されるリリースは、もう誰かが計画しています。それが公開されるのを止めることはできないでしょう。しかし、それがAWSの鍵に手が届く状態で、SSHエージェントのロックが外れたまま、攻撃者の管理するドメインへの明確な経路を持った状態で実行されるのか、それとも認証情報を持たないエフェメラルなコンテナでファイアウォールに阻まれて消えるのか、それは確実にあなたが決められます。その判断は、インシデントレポートが公開された日ではなく、今のCI設定とdevcontainerの設定で下されているのです。


