跳到主要內容

【2026 最新】Plugin4Shell 防護:Claude Code/Codex Plugin 10 分鐘稽核

最後更新: ·
Plugin4Shell 防護:Claude Code 與 Codex Plugin 版本、來源、實際 HEAD 和回滾收據稽核

Plugin4Shell 防護的核心,不是看到 Marketplace 寫著版本或 SHA 就放心,而是確認「宣告要拿的版本」與「磁碟上實際載入的內容」真的是同一份。這篇用 Claude Code 與 Codex 做主線,帶你在 10 分鐘內留下工具版本、來源 selector、實際 HEAD/檔案樹雜湊、能力與權限四層收據;若任何一層對不起來,就先隔離,不讓 Agent 幫你猜。

本文不重現攻擊。AIR Security 在 2026 年 9 月 17 日公開研究 PoC,描述一類 Git checkout 身分錯置:設定檔期待某個 commit,工具卻可能解析到另一個 ref。這是值得修補的供應鏈缺口,但不等於每位 Claude Code/Codex 使用者都遭入侵。完成本文後,你會知道如何判定 PASS、HOLD、STOP,並能留下可回滾的稽核收據。

先說結論:Plugin4Shell 防護要對齊四層

  • 工具版本:先更新 Claude Code/Codex 本體,再處理 Plugin;舊客戶端可能在更新時重走有問題的解析路徑。
  • 來源宣告:記錄 Marketplace、repo、ref、完整 40 字元 SHA 與 manifest 版本。ref 和版本字串都不等於 immutable commit。
  • 實際內容:.git 才驗 HEAD^{commit};被複製成 cache、沒有 Git metadata 時,改記 deterministic tree digest,不能硬猜 HEAD。
  • 執行邊界:盤點 hooks、MCP、LSP、bin/、網路與秘密可見範圍;來源一致不代表程式碼安全。

這裡的「10 分鐘」指例行盤點能完成第一輪分類,不是事件調查的完工時間。若結果落在 HOLD/STOP,後續要由資安或平台負責人保存完整證據、確認受影響時間窗與憑證可達範圍;不要為了追求倒數計時,把未知項目硬改成 PASS。

Plugin 稽核收據四層流程:工具版本、來源宣告、實際內容與權限
PASS 必須是四層收據互相一致;無法還原來源時是 HOLD,不是靠信心補成綠燈。

Plugin4Shell 防護先懂:pin、ref、HEAD 為什麼不同?

把 Plugin 想成機場行李:manifest 的版本是行李牌,Marketplace 的 SHA 是你預期送上的航班,HEAD 才是輸送帶最後送到眼前的箱子。AIR 的重點不是「SHA 太短」,而是 client checkout 後沒有再問一次:目前 HEAD 是否逐字等於預期物件?

OpenAI 的公開修補證據最完整。Codex PR #34644明確描述 Git 可能把要求的 commit SHA 解讀成遠端 default branch 名稱,修法是 checkout 後解析 HEAD 並拒絕不相等;這個 PR 收進Codex 0.146.0。這只能證明相關路徑的修補,不代表所有更舊版本都具備 Plugin 功能,也不代表升級會自動清洗既有 cache。

風險也有邊界。GitHub.com 的官方命名規則拒絕看似完整 40 字元 Git object ID 的 branch/tag,因此 AIR 對 Claude Code/Codex 所述的 SHA 形狀分支手法不適用於 GitHub.com 託管來源;其他 Git host、其他解析路徑仍要個別驗證。主機限制是額外防線,不是跳過 client 更新與本機內容驗證的理由。

2026 年 9 月版本快照:先升級宿主,再信紀錄

截至 2026 年 9 月 22 日,Claude Code 最新公開版本是 2.1.278。AIR 表示 Plugin4Shell 在 2.1.179 修正,但 Anthropic 的該版公開 changelog 沒有點名這項修補,所以本文把 2.1.179 明確視為研究方說法,不包裝成 Anthropic security advisory。更重要的是 2.1.277 才修正部分安裝沒有記錄 commit、以及 pinned-commit 更新後 installed_plugins.json 保留舊 commit 的問題;因此實務上應升到當下最新版,再把紀錄欄位當線索,而不是內容證明。

同日 Codex 最新 release 是 0.155.1;已知 checkout equality 修補至少包含在 0.146.0。0.155.1 release notes 沒有宣稱「Plugin4Shell 修補」,所以正確說法是「目前版本包含先前公開的 checkout 驗證」,不是替官方創造一則不存在的公告。如果你只想比較兩個 Agent 的定位,可先補讀 Claude Code vs Codex,再回來做同一套收據。

Plugin4Shell 防護 10 分鐘盤點:6 步留下收據

第 0 步:有異常跡象時,先保存再更新

如果已看到來源不明、digest 改變或可疑程序,先關閉所有 Agent session、阻斷 Plugin origin 的網路、複製 cache 與設定到唯讀/離線位置,再依組織事件流程處理。不要先啟動一般 session、跑 Marketplace update、package install、hook、MCP server 或 Plugin 自帶的 scanner;這些動作會改變證據,甚至執行待查內容。下列 CLI inventory 適合例行預防盤點;疑似事件要改查離線副本。

第 1 步:記錄 Claude Code/Codex 本體版本

umask 077
plugin_audit_dir="$(mktemp -d "${TMPDIR:-/tmp}/plugin-audit.XXXXXX")" || exit 1
claude --version | tee "$plugin_audit_dir/claude-version.txt"
codex --version  | tee "$plugin_audit_dir/codex-version.txt"

痛點:Plugin 已更新,不代表負責 checkout 的宿主已修。解法:先依 Claude Code 安裝文件Codex CLI 文件更新本體,再重新記版本。若公司由 MDM/套件庫管理,收據也要記 package source,避免 PATH 前方仍指向舊 binary。

第 2 步:列出已安裝 Plugin 與 Marketplace

claude plugin list --json > "$plugin_audit_dir/claude-plugins.json"
claude plugin marketplace list > "$plugin_audit_dir/claude-marketplaces.txt"

codex plugin list --json > "$plugin_audit_dir/codex-plugins.json"
codex plugin marketplace list --json > "$plugin_audit_dir/codex-marketplaces.json"

痛點:「Installed」只是狀態,不保證本機一定有對應 payload,更不保證 payload 來自哪個 commit。解法:每筆至少保存 qualified ID(plugin@marketplace)、版本、enabled、Marketplace root 與 install policy。依照 Claude Plugin reference,可再用 claude plugin details plugin@marketplace盤點 components;Codex 則依 OpenAI Plugin 結構文件,從 root 的 plugin.jsonskills/mcp.jsonhooks/往下查。

第 3 步:固定「預期來源」,別把 version 當 commit

在 Marketplace manifest 找到 repo、source、refsha與 version,另存一份未更新前的快照。Claude 的 individual Git plugin source 可以宣告完整 sha;Marketplace 本身的 ref仍可能是 branch/tag。Codex 的 Marketplace Git source 與 Plugin entry 也可能使用 refsha只有完整 SHA 能當預期 commit;mainv11.2.3都只是 selector/版本語意。

Claude 的預設 Plugin root 是 ~/.claude/plugins,也可能被 CLAUDE_CONFIG_DIRCLAUDE_CODE_PLUGIN_CACHE_DIR搬走;Codex 的 local cache 通常在 ~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/。請以 inventory 實際 root 為準,不要把教學路徑直接當成你電腦的證據。

第 4 步:在離線副本比對實際 HEAD

若副本保留 .git,用停用系統/全域 Git config、hook、fsmonitor、credential helper 與 file protocol 的只讀查詢。不要 checkout、reset、submodule update 或執行任何檔案:

plugin_copy='/Volumes/offline/plugin-copy'
expected_sha='REPLACE_WITH_40_LOWERCASE_HEX'
[[ "$expected_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1

actual_sha="$(
  GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \
  git -C "$plugin_copy" \
    -c core.hooksPath=/dev/null \
    -c core.fsmonitor=false \
    -c credential.helper= \
    -c protocol.file.allow=never \
    rev-parse --verify 'HEAD^{commit}'
)" || exit 1

[[ "$actual_sha" == "$expected_sha" ]] || {
  printf 'STOP: expected %s, got %s\n' "$expected_sha" "$actual_sha"
  exit 3
}
printf 'PASS: %s\n' "$actual_sha"

這一步只證明 repo identity,還沒證明 worktree 沒漂移。若安裝器把 Plugin 複製成沒有 .git 的 cache,就對所有檔案、相對路徑、mode 與 symlink target 建立 tree SHA-256,再和「同一來源、同一 selector、由乾淨環境取回」的副本比較。只有一個孤立 digest 只能當今天的 baseline;沒有權威 digest 或可重建副本時,結論應是 HOLD。

python3 - "$plugin_copy" "$plugin_audit_dir/tree-receipt.json" <<'PY'
import hashlib, json, os, stat, sys
from pathlib import Path

root = Path(sys.argv[1]).expanduser().resolve(strict=True)
out = Path(sys.argv[2])
tree = hashlib.sha256()
file_count = total_bytes = 0

def sha256_file(path):
    digest = hashlib.sha256()
    size = 0
    with path.open('rb') as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b''):
            digest.update(chunk); size += len(chunk)
    return digest.hexdigest(), size

entries = sorted(
    (p for p in root.rglob('*') if '.git' not in p.relative_to(root).parts),
    key=lambda p: p.relative_to(root).as_posix().encode('utf-8')
)
for path in entries:
    rel = path.relative_to(root).as_posix()
    mode = stat.S_IMODE(path.lstat().st_mode)
    if path.is_symlink():
        kind = 'symlink'; data = os.readlink(path).encode('utf-8')
        item_sha = hashlib.sha256(data).hexdigest(); size = len(data)
    elif path.is_file():
        kind = 'file'; item_sha, size = sha256_file(path)
        file_count += 1; total_bytes += size
    elif path.is_dir():
        kind = 'dir'; item_sha = hashlib.sha256(b'').hexdigest(); size = 0
    else:
        continue
    tree.update(f'{kind}\0{rel}\0{mode:o}\0{size}\0{item_sha}\n'.encode())

receipt = {'file_count': file_count, 'total_bytes': total_bytes,
           'tree_sha256': tree.hexdigest()}
out.write_text(json.dumps(receipt, indent=2) + '\n', encoding='utf-8')
print(json.dumps(receipt, indent=2))
PY

第 5 步:盤點會執行什麼,不要執行它

find "$plugin_copy" -type f \( \
  -name 'plugin.json' -o -name 'hooks.json' -o \
  -name 'mcp.json' -o -name '.mcp.json' -o -name '.lsp.json' \
\) -print
find "$plugin_copy" -type f -perm -111 -print
find "$plugin_copy" -type l -print

Claude 官方文件提醒,command hook 以使用者權限執行;啟用 Plugin 也可啟動 bundled MCP/LSP,而 Claude sandbox 的保證範圍不能直接外推到每個 Plugin subprocess。Codex 的 hook 會以 exact definition hash 要求信任,但「已信任」仍代表 OS account 程式碼;MCP 連到外部服務時也有自己的身份與權限。可先看 AI Agent 密鑰安全,把可見 secret、可寫目錄與網路 destination 壓到任務所需最小值。

第 6 步:用 PASS/HOLD/STOP 決定下一步

  • PASS:宿主已更新、來源可還原、實際 HEAD 或乾淨 tree digest 一致、能力與權限已審核,且新 session 仍指向同一份內容。
  • HOLD:cache 沒有 Git metadata、找不到可信對照物、Plugin list 與本機 payload 對不上,或權限邊界尚未釐清。保持停用/隔離,先補證據。
  • STOP:宿主低於修補版本、完整 SHA 不符、tree 漂移、來源不明,或出現未核准 hook/MCP/可執行檔。保存證據後移除並重建。

一張可稽核收據,應該讓別人能重做你的判斷

收據不是終端機截圖,而是一組能機器比較的欄位。第一組記 captured_at_utc、作業系統、binary 的絕對路徑、版本與安裝來源;同一台電腦可能同時留著 npm、Homebrew 與舊版 binary,只寫「最新版」無法重現。第二組保存更新前的 Marketplace manifest bytes/digest、qualified Plugin ID、repo URL、source type、ref、預期 SHA 與 version,並註明哪些欄位只是 metadata。

第三組才是解析結果:Git top-level、完整 actual HEAD、tree digest、檔案數與總 bytes。若 Plugin 在 Marketplace repo 的子目錄,Marketplace HEAD 只描述 catalog snapshot,不自動等於 Plugin source commit;若是遠端 bundle,version 與 repository URL 也不能替代 immutable provenance。把「可直接觀察」與「由官方文件/研究推論」分欄,未能證明的地方寫 unknown,不要留空讓下一個人誤以為已通過。

第四組記錄 fresh session 真正選到的 install path、enabled 狀態、hooks/MCP/LSP/executable 清單、sandbox/approval 模式,以及這些 component 能讀寫哪些資料、連到哪些服務。不要把 token、完整環境變數、connector ID 或秘密內容塞進收據;只記 secret 類別、來源與是否可達。最後加上處置人、PASS/HOLD/STOP、理由、回滾 artifact digest 與再次驗證時間,才能把「我檢查過」升級成「任何人都能重跑」。

無害 fixture 驗證:HEAD 相同,檔案仍可能漂移

為了驗證流程而不重現 exploit,我在一次性本機 Git repo 放入兩個純文字檔:一份 plugin.json與一份不會執行的 SKILL.md。測試全程沒有 SHA 形狀 branch、沒有網路、沒有啟動 Claude Code/Codex,也沒有執行 Plugin。乾淨狀態的 HEAD 是 e1c7e378…,tree digest 是 3951054b…

接著只在 SKILL.md加一行無害文字:HEAD 仍是 e1c7e378…,但 receipt 回報 head-match-tree-dirty,tree digest 變成 ae6c1302…;移除那行後 digest 回到 3951054b…。可觀察結果很直接:HEAD equality 是必要條件,不是完整的 bytes attestation。正式環境要保存 JSON receipt 的 timestamp、binary 版本、expected/actual SHA、tree digest、component inventory 與處置結果。

怎麼安全更新、停用與回滾?

例行修補的順序是:升級宿主 → 保存舊收據 → 停用 Plugin → 更新 Marketplace/重新安裝到新目錄 → 產生新收據 → 關閉並重開 session → 才恢復秘密與網路。Claude 可用 claude plugin disable plugin@marketplace --scope user,必要時重新啟動;本文查核的 Codex 0.155.1 沒有同名的 plugin disable子命令,請在 /plugins切換,或在專案 .codex/config.toml設置對應 Plugin 的 enabled = false

「disabled」不等於檔案被凍結:OpenAI 的 Plugin 文件明示 Marketplace refresh 仍可能安裝/更新停用 Plugin 的檔案;Claude 的 auto-update 文件則說明官方 Marketplace 預設可在啟動後背景更新,而已開啟 session 可能繼續使用舊版本,直到 reload 或下次啟動。若決定移除,Claude 使用 claude plugin uninstall plugin@marketplace,Codex 使用 codex plugin remove plugin@marketplace;先保留可稽核副本,再做這些會改變狀態的動作。

真正的回滾不是把來源 branch 往回指,而是保留前一個已審核 artifact/tree digest,以新目錄原子切換;重啟後再驗一次 inventory、HEAD/tree 與權限。若懷疑內容曾在高權限 session 中執行,依可達範圍撤銷憑證,不要不分青紅皂白旋轉所有秘密,也不要先清 log 再調查。

最常踩的 5 個坑

  1. 先更新再取證:Marketplace 與 cache 已變,事後無法重建當時狀態。
  2. 只看版本/資料夾名:它們是 metadata,不是實際 bytes 的證明。
  3. 只比 HEAD:tracked file 可能被改、cache 也可能根本沒有 .git
  4. 在可疑樹上跑 scanner:安裝相依套件、import module 或啟動 MCP 都可能執行候選內容。
  5. 把 sandbox 當萬靈丹:hook、外部 MCP、LSP 與遠端服務各有執行/授權邊界;要逐項盤點。

Plugin4Shell 防護常見問題 FAQ

1. 我裝過 Claude Code/Codex Plugin,就一定受影響嗎?

不能這樣判定。還要同時看 client 版本、Plugin 類型、Git host/解析路徑、來源 repo 控制權、是否發生 install/update,以及 Plugin 是否有會被啟動的 component。先做四層收據,不用產品名稱代替調查。

2. Plugin 在 GitHub.com,是否就不用更新?

不是。GitHub.com 阻擋的是 AIR 所述 40-hex branch 變體的必要前提;其他供應鏈風險、其他 source type 與本機漂移仍存在。宿主更新與內容驗證照做。

3. 升到 Claude 2.1.179 或 Codex 0.146.0,舊 cache 就乾淨嗎?

不能這樣推論。修補未來 checkout 路徑,不等於重新驗證或清除既有檔案;而 Claude 2.1.179 的修補歸因仍來自 AIR。升到最新版後,仍要重建/比對 cache 並重啟 session。

4. Cache 沒有 .git,要怎麼驗?

產生 deterministic tree digest,與同一來源 selector 在乾淨環境取得的副本或官方 digest 比較。若沒有可信對照,只能保存 baseline 並標成 HOLD,不能杜撰 commit。

5. Manifest version 和 commit SHA 有什麼差別?

version 是作者/catalog 的發行語意;SHA 是 Git object identity。作者可忘記改 version,branch/tag 也可移動,所以要分欄記錄。

6. Disable 後就能立刻繼續工作嗎?

先關閉舊 session。Claude 已載入版本可能持續到 reload/重啟;Codex disable 也不保證 Marketplace refresh 不碰檔案。重啟後重新確認 selected path 與 receipt。

7. 發現 mismatch,要立刻旋轉所有 API Key 嗎?

先依事件流程判斷該 Plugin 在可疑時間窗能讀到哪些 credential、session log 與網路目的地,再撤銷可達秘密。全面旋轉可能增加混亂,範圍過窄又會漏掉真正暴露面。

8. 多久做一次 Plugin 稽核?

至少在首次安裝、宿主升級、Marketplace/Plugin 更新、來源擁有者或權限改變時產生新收據;團隊可再依風險設定週期。重點是能比較前後差異,不是累積一堆無法對照的截圖。

給新手的 6 個帶走重點

  • 先升級宿主,才處理 Plugin。
  • 版本、ref、SHA、HEAD、tree digest 是五個不同欄位。
  • .git驗 HEAD;沒有就驗可重建的檔案樹。
  • 先保存證據,再 update/remove。
  • Plugin 的 hooks/MCP/LSP/bin 決定真正 blast radius。
  • 最好的修補結果不是「看起來最新版」,而是能交出可重跑、可比較、可回滾的收據。

接著閱讀

左右滑動查看更多推薦

結語:不要只問「有沒有更新」,要問「現在究竟跑哪一份」

Plugin4Shell 防護把一個常被忽略的問題變得具體:pin 是意圖,checkout 後的 HEAD/檔案樹才是結果。今天先挑一個已安裝 Plugin,留下四層 receipt;若拿不出可信來源或乾淨對照,就停在 HOLD。能安全暫停、重建、重新驗收,比任何「Official」「Latest」標籤更接近真正的供應鏈控制。

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多三封:一封 Weekly 週報與最多兩封關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。