vatt'ghern jaskier's ballads
本文 2 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

Cloudflare 的儲存池裡有一個設定,讓剛配置出來的 64 KiB 磁碟區塊不會先歸零就直接能用——一個 Workers Paid 客戶只要寫進 4 KiB,剩下的 60 KiB,理論上該是空的,卻可能是別的租戶用剩的東西。

沒歸零的磁碟區塊

只寫入 4 KiB,dm-thin 卻配置出一整塊 64 KiB;多出來的 60 KiB 原本該在拿到手之前先清乾淨,這次那道清零步驟被跳過了,不同客戶的資料就這樣被疊進同一塊共用磁碟池的區塊裡。這是 Cloudflare 在 2026 年 9 月 24 日公開的一次跨租戶資料暴露漏洞:回報者是 Accomplish 的研究者 Oren Yomtov,透過官方的 bug bounty 專案送出報告,影響範圍是 Cloudflare Containers,以及建立在它之上的 Cloudflare Sandboxes,同一個底層問題,兩個產品都受影響。官方自己的結論是漏洞已經完全修復,也沒有客戶資料外洩的證據。

skip_block_zeroing 做了什麼

Cloudflare Containers 跑在多租戶的基礎設施上——官方原文是「Cloudflare Containers run workloads on multi-tenant infrastructure and automatically assign them to eligible servers; customers cannot select the underlying host.」,等於客戶連自己的 container 落在哪一台實體主機上都不知道。每個 container 都跑在一台專屬的虛擬機裡——「Each container lives inside a dedicated virtual machine powered by the Firecracker virtual machine monitor.」,這台虛擬機看到的根目錄磁碟被掛成 /dev/vdc,底層則是一顆用 dm-thin 做延遲配置(thin provisioning)的磁碟。

dm-thin 的邏輯是延遲配置:官方寫的是「Thin provisioning allocates physical storage only when a virtual disk writes to a previously unmapped region.」;受影響的這幾個儲存池,每次配置的最小單位是 64 KiB——「The affected storage pools used a 64 KiB thin-block size.」。正常情況下,新配置出來的區塊在交給任何人使用之前,應該先歸零,這是避免舊資料外流的最後一道手續。但這幾個池子的設定裡,有一個叫 skip_block_zeroing 的選項是打開的:「With this option configured, dm-thin skips zeroing newly allocated blocks before making them accessible.」——新配置的區塊因此直接可以存取,沒有先清乾淨。

點每一層看它該做什麼、實際做到了什麼 · 4 層

Guest ext4(container 內部) 只寫入 4 KiB dm-thin pool(區塊分配器) 配置一個 64 KiB thin block skip_block_zeroing(清零步驟) 該做,卻被跳過 共用實體區塊池(跨客戶帳號) 回收進池,給下一個客戶用

Guest ext4 · 該做什麼

container 內部的檔案系統只知道自己要寫進哪個邏輯位置,對它來說,這就是一顆掛在 /dev/vdc 上的普通磁碟,跟裸機上的一顆本地磁碟沒有分別。

不知道:底層這個區塊乾不乾淨、之前有沒有別人用過。

dm-thin pool · 該做什麼

只有虛擬磁碟寫進一塊還沒對映過的區域時,才會真的配置實體儲存;受影響的這個池子,每次配置的最小單位是 64 KiB,跟使用者這次實際寫入了多少無關。

不知道:使用者實際只寫了多少位元組進來。

skip_block_zeroing · 該做什麼

正常應該在新配置的區塊交出去之前先歸零;這個選項打開之後,等於把這一步整個跳過,區塊配置好就直接可以存取。

不知道:跳過清零之後,剩下的位元組原本是誰的。

共用實體區塊池 · 該做什麼

一個 container 的根目錄磁碟被刪掉之後,底下的實體區塊會回收進這個服務多個客戶帳號的共用池,下一次有人寫進同一塊區域,用到的可能是完全不同的客戶帳號退回來的空間。

不知道:區塊裡有沒有東西留下來,要等下一個人寫進去、翻出來才會知道。

互動圖表

dm-thin 區塊本該先歸零,skip_block_zeroing 卻跳過這步,回收進共用池後可能留著別租戶的資料。

官方揭露文件本身沒有解釋當初為什麼把這個選項打開;合理的推測是,歸零本身是一次額外的寫入,每配置一次新區塊就要多付一次這個成本,skip_block_zeroing 存在的目的,就是把這筆開銷省下來。這麼做省下一次配置時的清零寫入,賠上的是下一個用到這塊區塊的人,可能看到裡面還留著別人的資料。

這裡有一個容易被忽略的邊界問題:Firecracker 提供的是運算層的隔離——官方原文只說每個 container 都跑在一台專屬的虛擬機裡。本文的解讀是:這代表記憶體與執行環境彼此分開,這次出問題的是虛擬機底下、大家共用的那顆實體磁碟池;運算層的隔離做得再乾淨,只要儲存層那道歸零手續被跳過,兩個從沒接觸過、彼此互不知情的 container,還是可能透過同一塊磁碟區塊產生交集,而這一層剛好在 Firecracker 的隔離範圍之外。

一次 4 KiB 寫入的代價

研究者的驗證流程第一步很平常:「Create a container using a Workers Paid account.」——任何付費客戶都做得到,不需要特殊權限。接下來的關鍵動作,是刻意讓寫入遠小於區塊:「The proof of concept identified 64 KiB-aligned regions corresponding to free space in the guest's ext4 filesystem and wrote one aligned 4 KiB block into each region.」,在 guest 的 ext4 檔案系統裡,每個 64 KiB 對齊的空閒區域只寫進 4 KiB。 dm-thin 看到這第一次寫入,會照規則配置出一整塊 64 KiB 的實體空間,但研究者實際用到的只有其中十六分之一。剩下的十六分之十五,也就是 60 KiB,會是什麼?官方的用詞是:「The remainder could retain data from the block's previous owner.」,用「could」這種保留的語氣描述。

這裡真正決定殘留範圍大小的,是 dm-thin pool 本身配置的最小單位,不是使用者這次實際寫入了多少。只要落在一塊還沒配置過的邏輯位置,哪怕送出的寫入請求遠小於 64 KiB,底層一樣會一次配好整塊 64 KiB 交出去,研究者送出的 4 KiB 只是用來觸發這次配置;照這個規則推算,換成更小的寫入,應該也會撞出同一塊 64 KiB (本文推論,原文只示範了 4 KiB)。反過來說,如果寫入的目標位置已經配置過,同一個動作就只是覆蓋舊資料,不會觸發任何新的配置,也就不會有殘留的問題。

拖曳分隔線比較同一個 64 KiB 區塊,拿掉設定前後差在哪 · 可拖曳

skip_block_zeroing 打開時 4 KiB 新寫入 60 KiB 可能殘留前一個 租戶的資料(未歸零) 拿掉這個設定後 4 KiB 新寫入 60 KiB 已先歸零 交出去之前清乾淨
示意圖,區塊比例依 64 KiB 與 4 KiB 的實際數字換算所畫;底色與版面為示意,非官方釋出的畫面截圖。

示意圖,區塊比例依 64 KiB 與 4 KiB 的實際數字換算所畫;底色與版面為示意,非官方釋出的畫面截圖

skip_block_zeroing 打開時,60 KiB 殘留可能留著前租戶資料,拿掉後會先歸零,不帶出舊內容。

為什麼一顆新配置的區塊,底層會有「上一個使用者」?官方給的解釋是:「When the thin volume backing a container's root disk was deleted, its physical blocks were returned to a pool that served workloads belonging to multiple customer accounts.」——一個 container 的磁碟被刪掉之後,底下的實體區塊不會憑空消失,會回收進一個服務多個客戶帳號的共用池,下一次有人寫進同一塊區域,用到的完全可能是別人退回來的空間。修復前已經配置好的舊區塊,這時候還留在兩個地方:可能還在某個運作中的 container 磁碟上,也可能留在某台主機準備好的 dm-thin image snapshot 快取裡。

5,614 個區塊裡的陌生人

研究者接著量出了具體數字。他們靠 ext4 的一個特性分辨區塊是不是自己的——官方原文寫的是「When ext4 uses the metadata_csum feature, directory block checksums incorporate values associated with the filesystem and inode.」,開了 metadata_csum 之後,目錄區塊的校驗碼本身就摻進了檔案系統與 inode 相關的值;換了主人的區塊,校驗碼裡摻的還是原本那個檔案系統與 inode 的值,同一條算式套到新主人身上自然對不上,這正是判斷「是不是自己的」的依據。六個正式環境的 placement 裡,一共驗了 5,614 個可測試的目錄區塊,沒有一個屬於研究者自己;用 checksum 分析找出 2,700 個不重複的外來目錄 inode。官方另外寫的整體結果是「The researchers ultimately observed residual material on 18 of 24 placements and 20 of 22 underlying nodes across four continents.」——24 個 placement 裡 18 個、22 個節點裡 20 個,都測到殘留資料。

這兩組數字的量測範圍不一樣:六個 placement 裡的 5,614 個區塊跟 2,700 個外來 inode,來自六個 placement 的樣本;後面 18 of 24、20 of 22,來自 24 個 placement 的樣本,兩者範圍不同,不能直接相加;哪一組先測、彼此是否重疊,官方原文沒有交代。

點底線詞看數字的定義跟量測範圍 · 5 個名詞

研究者靠 metadata_csum 校驗 ext4 開啟這個特性時,目錄區塊的校驗碼本身就摻進了檔案系統與 inode 相關的值;換了主人,校驗碼會直接對不上,這是分辨「這區塊是不是自己的」的依據。本文推測這個方法不需要猜內容或讀懂裡面的檔名路徑,官方原文沒有描述比對細節。 分辨區塊是不是自己的,在 六個正式環境 placement 這是六個 production placement 的樣本範圍,用來算出下面這兩個精確數字;跟後面「18 of 24」的量測範圍不同,是不是先後兩輪測試、彼此有沒有重疊,官方原文沒有交代(本文推測兩者不可互相加總)。 裡驗過的 5,614 個目錄區塊 這六個 placement 裡全部可測試的目錄區塊數量;其中零個被判定為研究者自己的檔案系統,代表全部都是候選的外來區塊。 ,找出 2,700 個外來 inode 2,700 是「distinct」,也就是去重後不重複的目錄 inode 數量,經 checksum 分析判定不屬於研究者自己。 ;整體結果則是 18 of 24 placements、20 of 22 nodes 這是 24 個 placement 樣本的統計;官方原文沒有說明這跟前面六個 placement 是否重疊,只確定量測範圍不同——24 個 placement 裡 18 個、22 個底層節點裡 20 個,橫跨四個大陸都測到殘留資料。 。

攻擊者碰不到的四件事

殘留資料確實存在,但這個手法能做到的事有明確邊界。官方寫得很直接:「An attacker could not select a particular victim or access an actively attached disk.」——攻擊者沒辦法挑特定受害者,也碰不到正在被掛載使用中的磁碟;讀得到的永遠是某個過去用過這塊區塊、現在已經退場的租戶。研究者自己也沒有把手法推得更遠:「The researchers did not demonstrate modification of another customer's active data or impact to workload availability.」,沒有示範能改到別人正在用的資料,也沒有示範能影響別人 workload 的可用性。

以下是本文把邊界對回機制的解讀:挑不了受害者,可能是因為 PoC 找的是 guest 的 ext4 檔案系統裡任何一塊 64 KiB 對齊的空閒區域,實際配置到哪一塊實體位置,是 dm-thin pool 當下的狀態決定的,不是攻擊者能指定的目標。碰不到掛載中的磁碟,可能是因為只有真正回收進共用池、還沒被下一次配置覆蓋的區塊才算「空閒」——官方寫的是「When the thin volume backing a container's root disk was deleted, its physical blocks were returned to a pool」,一顆磁碟只要還掛在某個 container 上、正在被使用,它底下的區塊理論上不在這個可配置的池子裡。至於沒有示範修改資料或影響可用性,官方原文只說 PoC 沒有做到這兩件事;本文推測是因為手法本身只對攻擊者自己建立的磁碟寫入、讀出,沒有觸碰別人磁碟或執行環境的動作。殘留資料不保證存在,則是因為官方自己的用詞是「could retain」——配置到的那塊實體空間,可能剛好是從沒被寫過的全新區域,也可能是別人退役後留下的舊區塊,兩種情況在使用者這一端看起來完全一樣,全憑當下 pool 裡剛好有什麼可以配置。這四條邊界合起來,確實能證明殘留存在、確實讀得到,但無法把它變成針對特定客戶的工具。

項目 做得到 做不到/沒示範
讀取殘留資料 用 Workers Paid 帳號建立 container,可能讀到同主機上前一個租戶留下的殘留區塊 ——
挑選對象 —— 不能挑特定受害者、workload、主機或資料
存取磁碟 —— 碰不到正在被掛載使用中的磁碟
修改資料 —— 沒有示範能修改別人正在用的資料,也沒有示範能影響別人 workload 的可用性
命中率 —— 殘留資料不保證存在
整理自官方揭露文件對這個手法邊界的描述;每一格文字皆為原文對應段落的摘要,非引申推論。

十五天的收尾

9 月 4 日這天,Cloudflare 反應很快:15:26 收到 Oren Yomtov 透過 HackerOne 送來的報告,18:45 就開了事故並確認正式環境的成因,21:27 合併了 runtime 的修復跟重用測試,22:03 再合併新舊 pool 的設定變更, 23:15 開始全機隊上線,整段不到八小時。上線本身跨了整個機隊,到 9 月 7 日 06:13 才全部完成,同時開始清理舊 pool 的資料。中間還有一段插曲:9 月 14 日 10:50,研究者回報他們的 PoC 已經失效;本文的解讀是,這代表新設定已經生效,觸發不出殘留了;兩小時後,Cloudflare 核發了賞金。最後一步是 9 月 19 日 15:03,把修復前所有快取的 image snapshot 全部清乾淨;連同退役的所有還在跑的 container 磁碟,一起確保沒有沒歸零過的舊區塊被重新帶回使用。

拖曳指標看每個時間點在做什麼 · 9 個時間點

9/4 15:26,回報:Oren Yomtov 從 Accomplish 透過 HackerOne 送出報告。
時間軸依官方公布的 UTC 時間繪製,橫軸為距第一次回報的小時數,非等比例的日曆刻度。

把官方時間軸上的九個時間點兩兩相減:9 月 4 日這天,從收到回報到開始全機隊上線,中間不到八小時;上線跨了整個機隊,到全部完成,隔了將近五十五小時,落在 9 月 7 日——從回報算起,全機隊上線完成約兩天半多。上線完成之後,依序經過研究者回報 PoC 失效、Cloudflare 核發賞金,一路到快取層全部清乾淨為止,三段加起來超過十二天——這段時間,才是修復前建立的舊 image snapshot 快取實際清乾淨所花的時間;退役還在跑的 container 磁碟,官方時間軸沒有給單獨的完成時間點。要把修復前就已經配置好的快取一併清乾淨, Cloudflare 在上線完成之後又多花了十二天多。

回報 → 開事故 3.3 小時 開事故 → 合併修復 2.7 小時 合併修復 → 合併設定 0.6 小時 合併設定 → 開始上線 1.2 小時 開始上線 → 上線完成 約 55 小時 上線完成 → PoC 失效 約 173 小時(七天多) PoC 失效 → 核發賞金 2.0 小時 核發賞金 → 清完快取 約 122 小時(超過五天) 橫軸為對數尺度,長度只表示量級差異,實際小時數以標籤為準
八段間隔依官方時間軸的 UTC 時間戳兩兩相減所得;深色三段(上線耗時、PoC 失效回報前的等待、核發賞金到清完快取)合計占了整起事件超過九成的時間。

有沒有其他人在這個漏洞公開之前就已經在用?Cloudflare 自己去比對歷史的磁碟 I/O telemetry:「Using these characteristics, we developed detection signatures and applied them to the historical telemetry available to us.」,做出偵測特徵套用在能拿到的歷史紀錄上,結果是:「We identified activity attributable to the researchers and Cloudflare engineers conducting authorized validation, and did not identify additional activity consistent with the reported technique.」——找到的只有研究者跟自己工程師做授權驗證留下的痕跡,沒有找到其他人用過同一個手法的跡象。這句話本身也帶著限制:找不到痕跡,不代表這個手法在公開之前完全沒人用過,只代表在能拿到的歷史紀錄裡,比對不出符合這個技術特徵的活動。合理的推測是,這些偵測特徵鎖定的應該就是同一批 64 KiB 對齊、只寫 4 KiB 的寫入模式,畢竟這正是官方描述的 PoC 核心動作。

這次修復其實分成三層:設定層先拿掉 skip_block_zeroing,讓新配置的區塊之後都會先歸零;執行層把所有還在跑的 container 磁碟整批退役,不讓修復前就已經配置好、可能帶著舊資料的區塊繼續上線使用;快取層則是把修復前建立的 image snapshot 全部清掉,避免有人拿著一份修復前的快照重新啟動,又把沒歸零過的區塊重新帶回使用中的狀態。設定層、執行層、快取層三步都做完,系統裡才不會留下沒歸零過的舊區塊。

把整起事件擺在一起看,問題出在 skip_block_zeroing 跟區塊會被回收進共用池這兩件事疊在一起。歸零本身確實會多花一次寫入的成本(前面提過,官方沒有明講原因,這是本文的推測),本文的推測是,共用池本身也是多租戶架構的標準做法,這兩者各自存在時都不是問題,只有同時出現,才會讓區塊帶著別人的資料流出去。Cloudflare 的修復對應前面那三步:移除 skip_block_zeroing,讓新配置的區塊之後都會先歸零;退役修復前就已經配置好、還在跑的 container 磁碟;再把修復前建立的快取快照清乾淨。共用池的回收機制本身,官方文件沒有說是否有改變;本文的推測是它照常運作,只是新分配到的區塊,理論上都已經先歸零過。官方原文寫的是「Cloudflare has patched this vulnerability and remediation does not require any further action by Cloudflare customers.」——修復完全發生在 Cloudflare 這一側,不需要客戶自己動手。

給 dm-thin 維運者的三件事:確認儲存池設定裡有沒有打開 skip_block_zeroing;退役修復前就已經配置好、還在跑的磁碟;清掉修復前建立的快取快照——三件事都做完,系統裡才不會留下殘留、沒歸零過的舊區塊。