← 回首頁
EN繁中

KV cache 層的 pickle RCE:LMCache 0.5.5 之前,一條 ZMQ 訊息就能跑你的指令

KV cache 層的 pickle RCE:LMCache 0.5.5 之前,一條 ZMQ 訊息就能跑你的指令

你大概跑過 vLLM。但 vLLM 旁邊那層KV cache 加速,你未必查過它開的 port。

2026 年 10 月 7 日,JFrog Security Research 公布 LMCache 的 multiprocess 模式有一個未認證的遠端程式碼執行:CVE-2026-105192,CVSS 3.1 打 9.8。機制不複雜——它的 ZeroMQ 傳輸 port(預設 5555)沒有任何認證,而 device IPC descriptor 是用 pickle.loads 解開的。一條精心構造的消息,就能以 LMCache 進程的權限跑任意指令;官方 Docker 映像裡那個進程是 root。(source: JFrog JFSA-2026-001694382, LMCache Team)

影響範圍是 0.3.9 到 0.5.5,加上 0.5.6 的 release candidate 和修補前的 dev 分支。修補(把 pickle 換成 versioned MessagePack descriptor,PR #5644)在 10 月 12 日合併、0.5.6 已上線。(source: PyPI lmcache, GitHub GHSA-w5f2-9q9c-ff4h)

⚠️ 先講結論:如果你只是單機、用 LMCache 的 in-process 模式,預設不會開這個 port,基本不受影響。危險的是多機 / 分散式的 MP 模式——那需要你主動把 transport 綁到可路由的位址讓 peer 連進來,而那正是 9.8 這個分數的來源。

白話導讀

想像你有一間資料中心,裡面的機器要分享一份「計算快取」(KV cache,讓長 prompt 不用每次重算注意力)來互相提速。為了讓這些機器快速交換快取塊,它們開了一條內部專線,而且因為「這些都是自己人」,專線沒裝門禁。

pickle 就是那扇門的問題。pickle 是 Python 的「把一個物件凍結成一串 bytes、再還原成物件」的格式。它很方便,但它會執行資料:還原一個 pickle 的時候,它可以呼叫任意函式。所以一條「快取塊」如果裡面塞了一個會執行 os.system("...") 的物件,對方一收到、一解開,你的機器就跑了那段指令。

💡 重點:LMCache 的 transport 預設是綁 localhost(單機安全),但官方给的 K8s 範例把 transport 綁了所有介面,而且官方映像跑 root。你如果是照著官方範例部署的,要假設自己是被暴露的那一群。(source: LMCache Team)

漏洞到底怎麼走

LMCache 的 multiprocess(MP,也叫 distributed)模式會開一個 ZeroMQ ROUTER socket(預設 port 5555),讓 worker 進程註冊、分享 KV cache 塊。這個 socket 的「預期客戶」是同一叢集裡的兄弟 LMCache 進程,但沒有 CURVE、沒有 ZAP、沒有密碼、沒有 message 認證——什麼都沒有。(source: GitHub GHSA)

socket 上的訊息是 msgpack。其中 extension code 1 註冊給 DeviceIPCWrapper,會一路送進 lmcache/v1/platform/base/ipc_wrapper.py 的 DeviceIPCWrapper.Deserialize,而那裡呼叫的是 pickle.loads。

最糟的時機:這個 hook 是在 REGISTER_KV_CACHE 的參數還在建解碼、handler 還沒開始跑的時候就被觸發的(ext_hook in lmcache/v1/multiprocess/custom_types.py)。換句話說——你的指令在「訊息被正式處理」之前就已經跑完了。就算 handler 因為收到的東西型不對而報錯,那也發生在指令執行之後。(source: GitHub GHSA)

JFrog 的 PoC 簡化成三步:

  1. 啟動 MP 模式,transport 綁可路由位址:
    python -m lmcache.v1.multiprocess.http_server \
      --http-host 127.0.0.1 --http-port 8080 \
      --host 0.0.0.0 --port 5555 ...
    
  2. 構造一個 REGISTER_KV_CACHE 訊息,cache slot 塞進一個 pickle:
    class EvilPayload:
        def __reduce__(self):
            return (os.system, ("id > /tmp/zmq_pwn 2>&1",))
    evil = pickle.dumps(EvilPayload())
    # 塞進 msgpack ext code 1
    
  3. 從另一個 ZMQ DEALER send_multipart 送過去。完事。官方映像跑 root,所以 cat /tmp/zmq_pwn 回的是 uid=0(root)。(source: GitHub GHSA)

注意 PoC 的 __reduce__ 直接回 (os.system, (cmd,))——pickle 還原時等於幫你呼叫 os.system(cmd)。這不是「解析錯誤」,是格式本身的設計:pickle 天生能帶 code。

誰真的被暴露

部署方式 是否暴露
單機 in-process(IP)模式 否——port 不開啟
MP 模式、transport 綁 localhost(預設) 否——只有同一台 host 連得到
MP 模式、--host 0.0.0.0 / 可路由位址 是
多機 / 分散式(peer 要互連) 是
照官方舊 K8s 範例部署(綁了所有介面) 是(PR #5684 已改回 localhost)

那 9.8 的分數是「可路由配置」的情形。預設單機、留預設 bind 的部署,其他機器連不到,理論上不在暴露集裡——但這不該是你能指望的事。(source: LMCache Team)

修:0.5.6 做了什麼

  • 換掉 pickle:descriptor 改成 versioned、typed 的 MessagePack,先驗證再使用;device wrapper 的型別從一個固定的本機 allowlist 選,類名不再從網路輸入來,而且沒有 pickle fallback。ZeroMQ 和 gRPC 兩條路徑都覆蓋,畸形 descriptor 會被拒。(source: GitHub GHSA, LMCache Team)
  • 官方映像改跑非 root(PR #5685)
  • K8s 範例改回 localhost(PR #5684)

⚠️ 兩個還沒修的尾巴(0.5.6 只修了 device IPC 這條路):

  1. RPC transport 的認證——LMCache server 跟 MP coordinator、frontend 之間的 RPC 目前也不需要認證,計畫 0.5.7 才加。(source: LMCache Team)
  2. engine-driven 路徑的 pickle——CPU-data / retrieve-response 處理裡還用到 pickle,但那条路預設關閉、也從網路打不到,計畫 0.5.8 前移除。

wire format 變了:0.5.6 的 descriptor 格式跟舊版不相容,worker 和 server 要一起升級,不能一新一舊混著跑。(source: GitHub GHSA)

怎麼查你有沒有中招

# 1. 你的 LMCache 版本
pip show lmcache | grep -E '^(Name|Version)'
# 0.3.9 <= v < 0.5.6 且在跑 MP 模式 → 在影響範圍

# 2. transport 有沒有綁可路由位址(關鍵)
sudo ss -ltnp | grep ':5555'
# 0.0.0.0:5555 或 10.x / 192.168.x:5555 = 暴露
# 127.0.0.1:5555 = 只有本機連得到

# 3. 啟動參數裡有沒有 --host 0.0.0.0
ps -eo args | grep -o 'lmcache[^\ ]*multiprocess[^\ ]* --host [^ ]*'

# 4. K8s 的話,看你照的範例 bind 了什麼
grep -rn '0.0.0.0\|host:' <你的 lmcache deployment yaml>

判斷是否被入侵(LMCache 團隊的原話要照做):如果 transport port 曾經被不受信網路到達過,先當 host 已經被入侵處理。因為指令是在訊息被處理之前跑的,所以「log 裡看到一個被拒 / 畸形的請求」不代表攻擊失敗。(source: LMCache Team)

  • 查 transport port 有無來自奇怪來源的連線
  • 查 LMCache 進程有沒有長出不該有的子進程、shell、外連
  • 把受影响容器能碰到的 credential / secret 全部 rotate,再用乾淨映像重部署

If You're Stuck

  • 我不確定自己有沒有開 MP 模式 → 單機用 lmcache 当 vLLM 的 in-process 加速、沒有獨立起 multiprocess.http_server 的,就是 IP 模式,port 不開啟、不受影響。你如果手動 python -m lmcache.v1.multiprocess.http_server 過,就是 MP 模式
  • ss -ltnp 看不到 :5555 但不安 → 確認你的 LMCache 是不是真的在跑 MP;in-process 模式不會開這個 port,看不到是正常的
  • 多機部署想先降風險再升級 → 把 transport 收回 localhost(或只信任的叢集內網)、用 network policy 擋 5555、把進程降成 non-root(0.5.6 官方映像已內建)
  • 升級 0.5.6 後 worker 連不上 server → wire format 變了,所有 worker 跟 server 要同時升,混版本不支援
  • 我在 0.5.6 之後但還想確認 pickle 沒殘留 → 0.5.6 修的是 device IPC 這條路;engine-driven 那條(預設關、網路打不到)要到 0.5.8 才清,短期不用管,但要知道它還在
  • 我想確認 0.5.6 真的上線了 → pip install lmcache==0.5.6 或直接看 PyPI;NVD 紀錄也指向 v0.5.6 release

跟之前幾篇的關係

這個 blog 的 security 系列已經出現過很多次同一個根因:

日期 主題 共同點
11/1 LMDeploy SSRF (CVE-2026-33626) 推論伺服器無認證 → 從裡面打出去
11/3 Langflow 33 CVE Agent builder 預設 auto-login → 不用登入就 RCE
11/4 Node-RED (CVE-2026-58115) IoT 工具預設無認證 → 一筆請求拿 shell
11/6 LMCache (CVE-2026-105192) 推論加速層的內部 transport 無認證 → 一條訊息 pickle RCE

這次多一個層次:它不是「對外的那個 API」,是機器之間那條內部專線。你通常不會把內部專線當攻擊面,但「預設自己人」+「pickle 這種會執行資料的格式」= 攻擊者只要進到你的 trusted network,就有一条路直達 root。(source: JFrog)

🔒 通用教訓:任何「機器之間快速交換資料」的內部 port(ZMQ、gRPC、Redis、etcd、consul)都該當對外的門來保護——加認證、收 bind、放 firewall。內部不代表安全,只代表「攻擊者要先花一步進來」。

Further Reading

Sources