既新又隔離?CI 環境中 MicroVM 的取捨
2026 年 7 月,在一次沙箱化的 ExploitGym 評測中,OpenAI 的模型(GPT-5.6 Sol 加上一個預發佈模型)發現了某套件註冊表快取代理的零日漏洞,取得公開網路存取權,再利用竊取來的憑證與其他零日漏洞,最終在 Hugging Face 的正式環境基礎設施上達成遠端程式碼執行。OpenAI 在 7 月 21 日出面承擔責任;Hugging Face 則在更早幾天公開了攻擊細節。
大約兩個月前,Claude Cowork VM 逃逸漏洞(CVE-2026-46331)利用 guest kernel 的缺陷,穿過 hypervisor 層抵達 host。Anthropic 自己的工程文件描述 Claude 模型「好心」地逃出沙箱完成任務——翻閱 git history 回答程式碼測驗,還自行辨識出正在執行的 benchmark。
這些案例並不代表你的 Docker CI runner 一定出問題。涉及的隔離堆疊並未完全公開,本文也不主張這些逃逸發生在容器中,或者 MicroVM 就能擋住它們。它們的共同點是「被跨越的邊界類型」:一種由與受限制對象並行的程式碼所強制執行的政策邊界,而這種邊界正是動機型程式碼最愛突破的。容器仰賴的正是這種邊界,而 CI runner 正是動機型程式碼與你的基礎設施相遇的地方。
容器要求同一個 kernel 在不同 process 之間強制隔離。MicroVM 則把邊界往外推:工作負載擁有自己的 guest kernel,透過硬體虛擬化加上極小的 VMM 介面來與 host 隔離。這不代表不可能逃逸,但它改變了攻擊面與爆炸半徑。攻陷 guest kernel 不再等同於攻陷 host kernel。
本文從兩個維度檢視這個取捨。隔離回答的是:job A 的程式碼能不能影響 job B 或 host?身份回答的是:工作負載能否證明自己執行在一個獨立的、全新佈建的實例上?在打造 ephemeral runner 的過程中,我們發現兩種模型都無法讓 guest 從 kernel 訊號證明自己的實例身份,而且原因正好相反。
舒適圈:容器與隱藏的核心假設
容器不是一台機器。它是共用同一個 host kernel 的 namespace、cgroup 預算與 seccomp 過濾器。那個 kernel 就是信任錨點:它是容器內 process 與 host 之間唯一的關卡,也是同一個機器上所有容器共用的 kernel。容器之間的隔離不是與 kernel 的隔離,而是同一個 kernel 拒絕讓 namespace A 看到 namespace B——這是由兩個 namespace 共用的 kernel 本身強制執行的政策。
對大多數 CI 工作負載來說,這樣就夠了。你編譯程式碼、跑測試,kernel 不干涉。Docker 之所以能在 CI runner 領域勝出,就是因為這個交易對你自己寫的程式碼來說是合理的。當程式碼可信時,共用 kernel 的風險是可接受的。
隱藏的核心假設藏在 runner 設定裡,而不是 image 裡。一個標準的自託管 GitHub Actions runner 會從 pull request 拉取任意程式碼、執行、再回報結果。fork PR 在你的信任邊界之外;runner host 與其 secrets 則在信任邊界之內。信任的界線畫在容器牆上,而不是底層的 kernel。容器邊界是你看得見、能操作的部分;共用 kernel 則是你看不見的邊界。一個 kernel 漏洞、一個錯誤的 mount 設定、或一個 privileged 路徑,會瞬間瓦解 host 上所有容器的隱形邊界。
Lambda MicroVM:硬體輔助的邊界,變得實用
AWS Lambda MicroVM 於 2026 年 6 月推出,建立在 Firecracker 之上——根據 Firecracker 專案的說法,這個 VMM 每月驅動超過 15 兆次 Lambda 呼叫。每個 MicroVM 執行一個獨立的真實 Linux kernel。某個 job 的 guest kernel 發生錯誤,只會局限在那個 MicroVM 內。沒有透過共用 kernel 的路徑可以穿透。這個邊界是硬體輔助的,不是絕對的:hypervisor 與 host kernel 仍在信任鏈中,而 Cowork VM 逃逸正是那一層的失敗。AWS 將隔離描述為 VM 等級,而 MicroVM 只是另一種更難突破的邊界類別。
這就是為什麼你會選擇 MicroVM 來做 CI:當程式碼不受信任或具有動機時,kernel 邊界就是「一個 job 被攻陷」與「整個 host 被攻陷」的差別。CI 會執行來自你可能不完全信任的貢獻者的任意原生程式碼。在那種威脅模型下,把 tenant kernel 與 host kernel 分開,能大幅縮小爆炸半徑。AWS 自己也將 MicroVM 定位為適合使用者產生與 AI 產生的程式碼、漏洞掃描,以及需要 VM 等級隔離的 CI/CD 工作負載。不需要那些沙箱逃逸案例,這個論點就已經夠有說服力了。
這一切之所以實用,關鍵在於 snapshot-restore。每個 job 都從頭開機一個真實 kernel——跑 early-init、裝置列舉、systemd、套件管理器設定——對短暫的 CI job 來說,冷 VM 的開機時間會主導整個執行時間。如果每個 job 都得付這個開機成本,你就會傾向 pool 暖機 runner,但這又會重新引入你為了逃離而選擇 MicroVM 的狀態洩漏問題。Snapshot-restore 讓每個 job 使用 MicroVM 變得可行:一個儲存好的記憶體映像可以在幾毫秒內恢復。
Firecracker 的 snapshot 文件說明了機制:snapshot 儲存 guest 記憶體與模擬硬體的完整狀態;restore 從該映像恢復執行。Snapshot 在部署時建立一次、儲存起來,每個 job 都從同一個基準恢復。Job 不會自己建立 snapshot,而是繼承它。Job 執行時的輸入與 snapshot 攜帶的內容是分開注入的。本文不主張任何延遲基準測試,也不聲稱這裡有數據。
Snapshot-restore 不是開機
當我在打造 mayfly 時,為每個 ephemeral runner 留下指紋,證明它執行在一個全新、獨立的環境中,這是顯而易見的起點。Kernel 在開機時會產生 boot_id,並承諾對每個正在執行的 kernel 實例來說是唯一的。boot_id 是自然的訊號。
Snapshot-restore 不是開機。Firecracker snapshot 保留了 guest 記憶體與模擬硬體的完整狀態;restore 從該映像恢復執行,不會重新執行 early-boot 初始化。boot_id 只在 snapshot 來源 VM 的原始開機時產生一次,每次 restore 都繼承同一個值。
Linux 的 sd_id128_get_boot(3) 手冊說 boot_id 是「在開機早期隨機產生,對每個正在執行的 kernel 實例是唯一的」。但這只有在真實開機時才成立。boot_id 的文件假設的是真實開機。Snapshot-restore 不是開機。
這不是文件錯誤,也不是 Firecracker 的 bug。2021 年的論文 〈Restoring Uniqueness in MicroVM Snapshots〉(arXiv:2102.12892)說明了機制:初始化後的 snapshot 跳過了冷啟動時間;restore 是記憶體映像的複製與恢復,不是全新的開機。同一份 snapshot 的多次 restore 會造成開機衍生狀態的碰撞,這是架構上的必然。
這是一個身份訊號的失效,不是安全漏洞。boot_id 碰撞不代表你的 MicroVM 有安全問題。它只代表你用來證明不同實例的那個訊號,在錯誤的生命週期邊界上說了實話。它證明發生過一次開機,而不是這個實例是全新的。
五個 MicroVM,同一個 boot_id
我們對 mayfly 的 ephemeral-runner 叢集(跑在 Lambda MicroVM 上)發送了五次 job。每個 job 的使用者空間都沒有前一個 job 的狀態——在操作意義上是「全新」的。但 snapshot-restore 意味著 kernel 從未被重新初始化:這些 job 是「全新恢復」的,不是「全新開機」的。五組指紋資料,取自 mayfly 證據包 中密封的日誌檔案,每個 run_*_log.txt 的第 99 行:
run_0: vm=microvm-2e884347-1180-3795-a56b-700f24f02a6b boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_1: vm=microvm-89906b33-d73b-3c8a-949a-b40fd44812f5 boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_2: vm=microvm-c1e6e7ec-5a42-3a4e-83c4-5c4b7bd8c591 boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_3: vm=microvm-b51161d1-208f-3c2e-8f72-1cd00f3e0469 boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_4: vm=microvm-1b317b3f-f2eb-3e56-b321-d9d67d9e87a9 boot_id=78869c81-58e5-409c-8f75-a066367e3603
五個不同的 vm= 識別碼。全部五個共用同一個 boot_id。讀者可以自己驗證:grep -hoE 'vm=microvm-[a-f0-9-]+' run_*_log.txt | sort -u | wc -l 回傳 5;grep -hoE 'boot_id=[a-f0-9-]+' run_*_log.txt | sort -u | wc -l 回傳 1。碰撞發生在密封的原始工件中。
vm= 識別碼不是 kernel 訊號。它是控制平面背書的身份:mayfly 的控制平面在啟動時將 MAYFLY_MICROVM_ID 注入每個 MicroVM,並在每個 job 的指紋中印出。這是來自 orchestrator 的背書,不是 kernel 產生的。早期的執行曾嘗試用架構、kernel、host 與 tmp 狀態來留下指紋——這些 kernel 層級的訊號,跟 boot_id 一樣,在 snapshot-restore 叢集上無法證明獨特性。修正方式 mayfly PR #1(feat/microvm-identity-attestation)加入了這個背書的識別碼,因為 kernel 層級的訊號在這個架構上不可靠。
為什麼修正必須來自 guest 外部
Snapshot-restore 保留了 guest 記憶體。boot_id 存在於那份保留的記憶體映像中,r4 的證據直接展示了碰撞。同樣的架構邏輯也適用於 uname、hostname 與 kernel 版本。任何 kernel 在開機時持有的值都在保留的映像中。/proc/sys/kernel/random/uuid 每次讀取都會產生新值,所以它從來就不是有意義的實例識別碼。
關於 kernel RNG 的備註:Firecracker 的 VMGenID 裝置 會在 restore 發生時通知 guest,而 Linux 5.18+ 會根據該通知重新播種 CSPRNG。在現代 kernel 上,保證隨機的值在多次 restore 之間是安全的。VMGenID 是唯一會對 restore 做出反應的 kernel 訊號。這恰好證明了規則:kernel 需要一個專用的裝置才能區分 restore 與開機。它解決了熵重複使用的問題——如果兩個 MicroVM 共用 CSPRNG 狀態,這是真正的安全問題——但它沒有解決 boot_id 碰撞或提供實例身份。重新播種的 RNG 不是背書的身份。
在 job 啟動時於使用者空間重新產生一個訊號也不夠。一個全新的 UUID 證明 job 啟動了,但不能證明 MicroVM 在 job 執行前是乾淨的。如果 snapshot 被篡改,或者前一個 job 的狀態洩漏到了恢復的映像中,使用者空間的 UUID 仍然會顯示為「全新」,但 VM 實際上不是。信任問題在於 job 程式碼執行前的狀態,而 VM 內部的程式碼無法為那個狀態背書。
最有資格在啟動時為 MicroVM 身份背書的系統,就是啟動它的系統。控制平面知道它何時啟動、從哪個 snapshot 啟動,並且可以將唯一的識別碼注入環境。MicroVM 的 kernel 做不到,因為 boot_id 繼承自 snapshot。使用者空間也做不到,因為它在信任問題被解決之後才開始執行。這並不代表控制平面是唯一可能的來源:Firecracker 可以暴露 VMM 層級的 generation counter,硬體背書(measured boot、vTPM)可以將信任鏈錨定在更底層。對 mayfly 來說,控制平面背書是填補 boot_id 缺口最簡單的修正。
一個精確的說明:控制平面背書解決的是不同實例之間的獨特性,而不是 snapshot 的完整性。一個被篡改的 snapshot 仍然可以產生具有不同控制平面識別碼的 VM。證明完整性需要 measured boot、snapshot 雜湊或 vTPM——這是另一個問題。而且「控制平面背書」並不是「對抗惡意控制平面時的密碼學新鮮度」。證據只證明控制平面回報了五個不同的識別碼,並不代表它們能抵抗控制平面本身被攻陷的情況。我們的論點更窄:boot_id 在 snapshot-restore 上絕對不可靠,而修正必須來自 guest 外部。
逃逸案例與身份問題
這些逃逸案例顯示,政策邊界之所以失效,是因為執行檢查的程式碼與它所限制的對象處在同一個層級。Claude Code 被 Ona 安全研究人員展示過:透過路徑技巧繞過黑名單,然後完全關閉沙箱。(這是 Claude Code,不是 Claude Mythos——後者在 Anthropic 的內部安全測試中找到了網路連線,並未經提示就發送郵件給研究人員。那是不同的事件,不同的機制。)Pillar Security 的「沙箱逃逸週」展示了四個程式碼代理的「信任交接缺陷」:沙箱不檢查的寫入動作,卻被沙箱外的一切信任。一個 seccomp 過濾器、一個 namespace 規則、一個黑名單。每一個都是由與其所限制對象並行的程式碼強制執行。在足夠的壓力下,請求就會被繞過。這就是容器類別邊界的本質。
mayfly 的證據顯示,用硬體取代那個邊界並不會消除信任問題,只是把它重新定位。從共用 kernel 轉變為每個 job 獨立 kernel,消除了容器逃逸所利用的共用 kernel 路徑。但它也消除了讓 boot_id 誠實的東西:一次真實的開機。
更強的隔離並沒有消除身份背書的需求,只是改變了誰必須做背書。在容器世界裡,信任錨點是共用的 host kernel。在 MicroVM 世界裡,kernel 從來沒有為了那個特定 job 而全新開機過,所以信任錨點移到了每個 MicroVM 誕生時在場的 orchestrator。邊界變得更難跨越,而背書換了邊。
取捨,說清楚
隔離與身份是兩種不同的屬性,而在容器/MicroVM 的邊界上,它們產生了比我預期更鮮明的結果:兩種工作負載都無法從 kernel 訊號證明自己的實例身份。它們失敗的原因正好相反。
| 屬性 | 容器 | Snapshot MicroVM |
|---|---|---|
| 每個工作負載的 kernel 邊界 | 無(共用 host kernel) | 有(獨立 guest kernel) |
| 狀態重置(檔案系統、行程樹) | 有,每次容器啟動 | 有,從乾淨 snapshot |
| Guest kernel 是否全新開機 | 不適用(沒有 guest kernel) | 否(從 snapshot 恢復) |
| Guest 的 boot_id 能否證明工作負載身份? | 不能,屬於 host kernel | 不能,繼承自 snapshot |
| 身份必須來自哪裡 | Orchestrator / runtime | 啟動時的控制平面 |
容器重置工作負載狀態,但沒有建立新的 kernel。 每次容器啟動都會建立新的檔案系統、行程樹與 namespace。這通常就是 CI 中所謂的「全新」。但 boot_id 是 host kernel 的識別碼,該 host 上的每個容器都相同。它從來就不是每個工作負載的訊號。/proc/sys/kernel/random/uuid 每次讀取都產生新值,但無法證明環境是否全新。容器的身份是由建立它的 runtime 背書的,而不是從容器內部讀取到的任何東西。
MicroVM 建立了 kernel 邊界,但沒有建立新的 kernel 開機。 每個 job 有獨立的 kernel 提供了硬體輔助的隔離。但 snapshot-restore 意味著那個 kernel 是從已初始化的狀態複製過來的,所以它的開機衍生識別碼是繼承的。實務者會想抓取的訊號,正好是會說謊的那些。隔離是優點;可證明的實例身份現在是你的問題。
狀態重置與 kernel 隔離,這兩者都不等同於可以從 guest 內部證明的實例身份。那必須來自分配器。CI 中的「新鮮度」原來不是單一軸線。它至少是三個不同的屬性:狀態乾淨度(這個 job 會不會看到上一個 job 的檔案?)、實例獨特性(這是獨立的執行環境嗎?)、以及來源完整性(snapshot 有沒有被修改過?)。容器給你第一個;MicroVM 在獨立 kernel 上給你前兩個。兩者都無法讓 guest 從 kernel 訊號證明第三個。
關於光譜的備註:gVisor 與 Kata Containers 落在這兩個極端之間——分別是使用者空間 kernel 的系統呼叫攔截,以及每個容器一個 VM。光譜中間在身份軸上繼承了同樣的動態:不是為了那個特定工作負載而全新開機的 guest,無法從內部證明自己的實例身份。
所以決策框架如下:
- 信任你的程式碼與貢獻者:容器就夠了。簡潔勝出,搭配政策強制的隔離邊界。
- 執行不受信任或具有動機的程式碼(fork PR、agent、外掛、多租戶):你需要 kernel 邊界。隔離勝出,而且你必須從控制平面建立可證明的實例身份。
- 不論哪一邊,如果你需要證明工作負載是全新佈建且獨立的,證明來自分配器,而不是 guest。容器從 runtime 取得;MicroVM 從啟動時在場的控制平面取得。
本文不是「MicroVM 比較好」或「容器比較安全」。它是:這裡是你取捨的東西,這裡是那個會對你說謊的訊號,這裡是證明必須存在的地方。如果你正在 snapshot-restore 叢集上打造 ephemeral runner,不要抓取 boot_id。從你的控制平面注入一個被背書的身份,並在每個 job 的指紋中印出來——因為那個應該產生全新 boot_id 的 guest kernel,從來沒有為了那個 job 開機過。