← 返回所有文章
發佈於 · 作者 Renaud Deraison

那個版本才發布兩小時

10 月 21 日,攻擊者拿著從三位 LiteLLM 開發者那裡偷來的 GitHub CLI 權杖,把 litellm 1.107.0 發布到 PyPI。每個 wheel 都帶著一支在每次 Python 啟動時執行的憑證竊取程式,以及一個每小時去抓遠端程式碼的載入器。PyPI 在 1 小時 43 分鐘後將它下架,此前估計已被下載約 49,000 次。在 Bromure Agentic Coding 裡,預設開啟的存在時間閘門會隱藏任何發布未滿兩天的版本,所以 Bromure 機器上的 pip 從來沒看見 1.107.0。

惡意版本在 PyPI 上待了 1 小時 43 分鐘。在 Bromure 的預設設定下,代理所在機器上的 pip 要到它發布兩天後才會看見它。

10 月 21 日星期二 11:39 UTC,七個 litellm==1.107.0 的 wheel 抵達 PyPI。LiteLLM 是許多團隊用來轉送模型流量的 Python 函式庫兼代理伺服器,大量 AI 工具都內建了它。新版本看起來就像專案自己發的:專案的發布工作流程替它簽了名,還附上 SLSA 來源證明。它同時也帶著一支憑證竊取程式和一個後門。

PyPI 在 13:23 UTC 將它移除。標記為 v1.107.0、latest、main-latest 和 main-stable 的受感染 Docker 映像檔,則在 Docker Hub 上留到 14:20。Wiz 估計這段期間惡意版本被下載了約 49,000 次。LiteLLM 的事件報告告訴所有在可對外連網的主機上安裝過它的人:「假設資料外洩已經成功」。

這是該專案今年第二次遭入侵。第一次在 3 月,透過 CI 裡一個被下毒的安全掃描工具。那次我們寫過應變人員如何查錯了時間窗。

攻擊者怎麼拿到鑰匙

LiteLLM 團隊公布了逐分鐘的時間線。攻擊者握有三位開發者的 GitHub CLI OAuth 權杖。那個星期一,為了讓兩位工程師能出錯誤修正的回移版本,發布權限被放寬了。星期二 10:08,分支保護拒絕了一次對私有發布工作流程的修改。五秒後,組織兩位擁有者之一的權杖覆寫了保護,推送了這次修改。修改後的工作流程從 Berri-AI/litellm 建置,那是仿冒真正 BerriAI/litellm 的帳號;第二位開發者的權杖通過了正式環境的核准關卡。11:53,第三位開發者的權杖刪除了工作流程的執行紀錄。

LiteLLM 把權杖的來源追溯到兩項發現。一項是內部 LiteLLM 部署上有一個「意料之外、名字看起來無害的使用者帳號」,而那個部署暴露在網際網路上。另一項與程式碼代理有關。報告說,LiteLLM「觀察到 Claude Code 在特定情況下會把使用者的 GitHub CLI OAuth 權杖嵌入回應內容中;在回應內容會被記錄的情況下,這些權杖可能出現在 LiteLLM 的日誌裡」。

會記錄模型流量的閘道,保留了代理寫下的一切內容的副本。這一次,那份副本裡有能發布這個套件的權杖。

那些 wheel 做了什麼

StepSecurity 的拆解分析解釋了為什麼 GitHub 上的原始碼看起來乾淨。攻擊者只在建置設定裡加了一行:[tool.maturin] 底下的 data = "cook"。它把一個 cook/ 目錄複製進每個 wheel。這個目錄從未出現在原始碼發行包裡,GitHub 上的 v1.107.0 標籤也找不到它的任何痕跡。

每個 wheel 會在 site-packages 裡安裝三個檔案:

  • cook.pth,12 位元組:import cook。Python 的 site 模組在每次直譯器啟動時都會執行 .pth 檔案裡的 import 行。沒有人需要 import litellm,啟動 pip 就夠了。
  • cook.py,一行混淆過的程式碼,解碼後約 61,000 位元組。這是一支竊取程式,會把輸出標記為 "tool": "privcollect",並執行 16 個收集模組。
  • sitecustomize.py,一個會 fork 成獨立常駐程式的載入器。它每小時從攻擊者控制的 GitHub 儲存庫讀取 domain.txt,抓取 http://<host>/codepy,再用 exec() 執行結果。

竊取程式會拿走 SSH 和 GPG 金鑰、雲端憑證與執行個體中繼資料、~/.kube/config、Docker 設定、GitHub CLI 權杖、.git-credentials、.netrc、npm、PyPI、Cargo 等套件庫的權杖、往下兩層的 .env 檔案、瀏覽器登入資訊與 Cookie、shell 與資料庫的歷史紀錄,以及透過 /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 在發布時是空的,所以第二階段從未啟動。背後的 GitHub 帳號已被刪除,但受感染的主機仍每小時輪詢那個網址。如果有人重新註冊那個使用者名稱,所有這些主機都會執行對方的程式碼。

為什麼程式碼代理特別暴露

在 Python 專案上工作的代理,會因為 README 或錯誤訊息的建議而執行 pip install -U litellm。它會執行 pip install -r requirements.txt,而裡面的 litellm 那一行沒有釘住版本。它會建一個新的 virtualenv,安裝最新的東西,不分時段,也沒有人去看裝回來的是哪個版本。

星期二 11:39 到 13:23 UTC 之間,「最新」指的就是 1.107.0。在代理的主機上,接下來整個工作階段裡,代理每呼叫一次 python、pytest 和 pip,cook.pth 就執行一次。開發者自己的憑證在竊取程式的觸及範圍內,而代理的登入資訊就在它的目標清單上。

Bromure 從未提供 1.107.0

Bromure Agentic Coding 在你 Mac 上的 Linux VM 裡執行 Claude Code、Codex、Grok、Kimi 和 omp。VM 發出的每一次套件下載都會經過 Mac 上的代理伺服器,它在回應抵達 pip 之前套用該機器的供應鏈政策。VM 無法變更這項政策。VM 內的 pip.conf 只能讓它更嚴格。

這項政策有一層在每台新機器上都是開啟的:存在時間閘門,最短存在時間為 2 天。當 pip 向 PyPI 的 simple 索引詢問 litellm 有哪些版本時,代理伺服器會在 pip 讀取之前,把所有比門檻新的檔案從清單中拿掉。星期二 11:45 UTC,一台執行 pip install -U litellm 的 Bromure 機器拿到的清單停在 1.106.2,也就是最後一個乾淨的版本,並安裝了它。對代理來說,1.107.0 還不存在;等到閘門本該打開時,PyPI 早已把它刪掉。

如果代理釘住了版本,下載檔案本身就是第二道檢查。代理伺服器會到 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 會原封不動地印出原因,所以代理也會讀到,而且往往會自己改釘一個較舊的版本。

閘門還沒打開,版本早已消失10/21 08:0010/22 00:0010/23 00:0010/23 14:00PyPI 上的 litellm 1.107.011:39 至 13:23 · 約 49,000 次下載Docker Hub 標籤留到 14:20Bromure 存在時間閘門,預設 2 天Bromure 機器上的 pip 把 1.106.2 視為最新的 litellm10/23 11:39 閘門才會打開,已無可提供已移除時間取自 LiteLLM 事件報告,下載數為 Wiz 的估計。
PyPI 上的 litellm 1.107.0,10 月 21 日至 23 日,UTC。惡意版本上線了 1 小時 43 分鐘,在 Bromure 預設的存在時間閘門讓 pip 看見它之前 46 小時以上就已消失。

閘門不需要安全公告、不需要惡意程式特徵碼,也不需要有人點名這個套件。套件庫和研究人員通常在幾小時內就會發現並下架被劫持的版本,而在一般開發中,等兩天幾乎沒有成本。如果你自己的團隊發布的套件必須一出來就能安裝,就到機器的 Supply Chain 面板,在 Exempt packages 裡以 pypi:yourpkg 的形式加入它。PyPI 其餘的套件仍然要等兩天。

同一個面板還能再加幾層。OSV 檢查會拒絕任何已有公開公告、且嚴重程度達到你所選等級以上的版本。socket.dev 篩選器會封鎖被標記為惡意程式的套件。如果你開啟的某個信譽來源給不出判定,下載會暫停,Bromure 會針對那個套件和版本詢問你。

日誌裡的權杖是替身

這次發布之所以可能,只是因為攻擊者握有真正的 GitHub 權杖。LiteLLM 認為,權杖來自一個會記錄模型流量的部署,而 Claude Code 曾在那裡把一位開發者的 GitHub CLI 權杖寫進回應。

在 Bromure 機器裡,那個權杖是替身。VM 開機時,Bromure 會把一個假的 ghp_ 權杖寫進 gh 的設定和 ~/.git-credentials,並匯出為 GH_TOKEN。這個假權杖的前綴和長度都正確,所以 gh 會接受它。真正的權杖留在 Mac 上。當請求離開 VM 前往 GitHub 時,代理伺服器會在傳輸途中把假值換成真值,而且只對 GitHub 的主機這麼做。

代理還是可能把權杖貼進回覆裡,但它手上只有替身可貼。模型供應商和中間任何一個閘道日誌,記下的也都是替身。從別人的筆電重放它,GitHub 會拒絕,因為那裡沒有 Bromure 代理伺服器幫它替換。

發布權限也受到同樣的保護。在機器的 Guardrails 面板裡,每個程式碼託管平台的權杖都有一條寫入政策,新機器一開始就是 Prompt before write。對發布工作流程的 force-push,或關閉分支保護的 API 呼叫,都要等你 Mac 上顯示 METHOD /path 的對話框,由你決定。

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 上把金鑰加上去,正如我們在 provider 在金鑰所在之處執行中所說明的。
  • SSH 金鑰透過通往 Mac 的 agent 橋接來簽章。私鑰的位元組從不進入 VM,所以 ~/.ssh 裡沒有東西可拿。

替身只有經過那台機器的代理伺服器、前往它被簽發的目標主機時才有效。攻擊者可以解密打包檔、重放裡面的每一個值,而 GitHub、AWS 和 Anthropic 會一一拒絕。

那個 POST 無處可去

打包的內容會以未加密的 HTTP POST 送往 www.litellm.dev,一個這台機器從來不需要的網域。在 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,附上主機和連接埠。載入器的第二階段也以同樣方式失敗:不管 domain.txt 指向哪台主機,http://<host>/codepy 都不在清單上,所以每小時一次的 exec() 沒有東西可執行。

在使用 OpenShell + Sentry 沙箱引擎的工作區裡,在那一列點 Show the Whole Chain,就會打開這條被封鎖流量的來龍去脈。它會顯示代理的 pip install 工具呼叫和模型給出的理由,接著是 cook.pth 在其中啟動竊取程式的 pip 行程,再來是被拒絕的連線。你看得到把套件拉進來的那道指令,以及模型自稱執行它的理由。

四個步驟,Mac 上的四道檢查攻擊,10 月 21 日1 · 日誌裡的權杖gh OAuth 權杖從被記錄的模型流量中被複製2 · 工作流程被強推保護被覆寫,從 Berri-AI/litellm 建置3 · PyPI 上的 wheellitellm==1.107.011:39 至 13:23 UTC4 · cook.pth 執行收集後 POST 到www.litellm.dev在 Bromure 機器裡替身權杖VM 裡是假的 ghp_;日誌留下的也是假值。換個地方 GitHub 就拒絕。寫入政策推送與分支保護的變更,都要等 Mac 上的對話框。存在時間閘門,預設開啟pip 解析到 1.106.2;釘住的 1.107.0收到 HTTP 451。假值與預設拒絕收集到的全是替身;POST 和 /codepy都被交換器拒絕。
litellm 1.107.0 的攻擊鏈,以及 Bromure 機器在哪裡切斷它。四道檢查都在 Mac 上執行,位於套件安裝所在的 VM 之外。

「我們裝了嗎?」一個篩選就有答案

星期二下午,每個應變團隊的第一個問題都一樣:我們哪些機器執行過 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。

新版本要等兩天

存在時間閘門在每台新機器上都是開啟的。從 PyPI、npm、Cargo、RubyGems 和 Packagist 安裝時,會解析到比門檻更舊的最新版本。釘住一個太新的版本,會收到附帶原因的 451。

日誌裡的權杖是假的

GitHub、雲端、套件庫和模型的憑證都以替身的形式進入 VM。代理伺服器在 Mac 上把真值填進去,只對該憑證自己的主機。

外洩需要一條路

把未列出的流量設為 deny,送往上週才註冊的網域的 POST 就會死在交換器上,並顯示為一列紅色紀錄。

每次安裝都有紀錄

Security Timeline 列出每台機器 90 天內下載過的每個套件和版本。已註冊的 Mac 會把同樣的事件送給組織。

先讓套件庫跟上

攻擊者只花了幾分鐘就發布了 1.107.0。PyPI 花了 1 小時 43 分鐘把它下架,以套件庫而言算快。你的代理整天都在安裝套件,不分時段,而且拿最新的,所以它會落進這樣的時間窗。在 Bromure 機器上,它會等兩天。到那時,PyPI、Wiz、StepSecurity 和 LiteLLM 團隊自己都已經檢查過這個版本,壞的早就不見了。

關於本月稍早鎖定程式碼代理憑證的竊取程式,請讀竊取程式打包走的是替身。至於權杖一開始是怎麼跑進對話紀錄和模型日誌的,請讀權杖留在了對話紀錄裡。