同一段程式碼,2018 年寫下時是安全的,2025 年一行都沒改,卻變成了一個能拿 root 的漏洞。中間發生了什麼——答案不在這個檔案裡。
潛伏六年的那道邊界檢查
CVE-2026-31694 的技術描述短得像一句抱怨:fs/fuse/readdir.c 少了一道邊界檢查,讓一個非特權的本地使用者「write a controlled 24 bytes past the end of a kernel page」——把受控的 24 個 bytes 寫過一個 kernel page 的尾端。少寫一道檢查造成溢位,這種事每年有幾百件,本身不算新聞。真正值得停下來的是它的時間軸:這道檢查從 2018 年就一直缺著,那段程式碼在 2018 到 2026 之間一行都沒被改過,卻在 2026 年 4 月才被當成漏洞修掉。
所以這篇不是一則「有人發現了新 bug」的發現史,而是一道謎題:一個少寫的檢查,為什麼能在 kernel 裡潛伏六年都不算 bug,然後在某一天突然變成一個。要回答它,得先接受一件反直覺的事——這段程式碼在 2018 年是對的。不是「當時沒人注意到的錯」,是真的對。它後來變錯,靠的是一個離它三個檔案遠、跟它看起來毫無關係的改動。
FUSE(Filesystem in Userspace)本身值得先說一句,因為它是這整件事能發生的舞台。它讓一支普通的 user-space 程式扮演檔案系統:kernel 把每個檔案系統操作——open、read、readdir——包成請求丟給那支程式(daemon),daemon 回覆什麼,kernel 就當成檔案系統的真實內容。sshfs、gvfs、各種容器的 overlay 都建在它上面。這個架構的代價,是 kernel 必須信任一個它管不到的 user-space 程式餵回來的資料,而這次出事的,正是 kernel 對那份資料「不會太大」的一個沒寫下來的信任。
一個少寫的檢查,安靜躺了六年
先把時間軸攤開。這個漏洞牽涉三個 commit,橫跨六年多,而其中真正「修 bug」的只有最後一個;中間那個,作者當時甚至不知道自己碰了任何跟安全有關的東西。
drag the handle along the timeline · 3 commits over 6+ years
69e3455「fuse: allow caching readdir」引入 readdir cache 與那道換頁檢查。此時 FUSE_NAME_MAX 是 1024,最大 record 只有 1048 bytes,約四分之一頁——缺的檢查毫無差別。要看懂這段程式碼在哪裡出手,得先知道 readdir cache 是什麼。當你 ls 一個 FUSE 目錄,kernel 會向 daemon 要一批目錄項,然後把回覆逐筆存進一串 page cache,之後同一個目錄再被讀時就不必再打擾 daemon。「逐筆存進 page」這個動作,就是 fuse_add_dirent_to_cache() 幹的活,也是這次的案發現場。它每收到一筆 entry,就要決定放在當前這一頁的什麼位置,放不下再翻頁——問題就出在「放不下」的判斷少了一種情況。
換頁的邏輯本身沒問題,甚至寫得很守規矩。fuse_add_dirent_to_cache() 把一筆 directory entry 寫進 readdir 的 page cache 前,會檢查它放不放得下當前這一頁剩下的空間;放不下,就換一頁重新從頭放。用作者的話說,這段程式碼「checks whether the entry fits in the space remaining on the current page, and if not it advances to a fresh page, but it never checks whether the entry fits in a page _at all_」。前半句是它做的,後半句是它漏掉的:它從不檢查一筆 entry 到底放不放得進「一整頁」。
這裡有個容易被忽略的細節值得停一下:「放不下當前頁剩餘空間就換一頁」這個處理,對一筆本來就放得進一頁的 entry 是完全正確的。假設一頁已經寫了 4000 bytes,來了一筆 500 bytes 的 entry,4000 + 500 > 4096,於是換到新頁,在空頁的開頭寫下這 500 bytes——毫無問題。這段邏輯唯一沒設防的情況,是那筆 entry 本身就比一整頁還大。換到全新的空頁也沒用,因為空頁也只有 4096 bytes。程式碼卻天真地以為「換了新頁 = 一定放得下」,直接 memcpy。這個天真在 2018 年是無害的,因為那個「比一整頁還大的 entry」在當時根本不存在。
這個漏掉在 2018 年不重要,因為當時根本沒有任何一筆 entry 大到需要問「放不放得進一頁」。要看懂這點,得先算一下 record 有多大。
為什麼六年沒事:那筆 record 從來碰不到頁尾
FUSE 的每筆 directory entry 在 cache 裡佔的空間叫 reclen,演算法是把一個 24-byte 的 header(FUSE_NAME_OFFSET)加上檔名長度,再對齊:reclen = FUSE_REC_ALIGN(FUSE_NAME_OFFSET + namelen)。檔名能多長,由 FUSE_NAME_MAX 這個常數封頂。2018 年那個常數是 1024。
代進去:一筆檔名頂到上限的 entry,最大 record 是 FUSE_REC_ALIGN(24 + 1024),也就是 1048 bytes。x86 的 PAGE_SIZE 是 4096。1048 大約是一頁的四分之一。作者對這個處境的描述很精確:「A record could never approach PAGE_SIZE, so the absence of a reclen > PAGE_SIZE check made no difference. The assumption ('any single record fits in a page') was true, just never written down.」——record 永遠碰不到 PAGE_SIZE,所以缺不缺那道 reclen > PAGE_SIZE 檢查毫無差別。那個假設「任何單筆 record 放得進一頁」是真的,只是從來沒有被寫下來。
這是整篇的樞紐,值得咀嚼。程式裡有一個成立的不變式——一筆 record 一定放得進一頁——但它不是被一道 if 保證的,而是被一個常數的數值默默撐著。1024 這個數字讓不變式為真。沒有人寫下「因為 FUSE_NAME_MAX 是 1024,所以 record 不可能超過一頁」這句話,因為當時它顯而易見到不值得寫。程式碼是對的,只是它的正確性寄託在別處,寄託在一個此刻還沒有人打算去動的常數上。
這種「正確性寄託在別處」的形態,在大型 codebase 裡比想像中普遍。一段程式碼看起來自洽,它處理的每個 case 都對,靜態分析掃不出問題,fuzzer 也很難構造出觸發條件——因為在當下的常數配置下,觸發條件根本不可能成立。這類程式碼有個共同特徵:它們的安全不是自證的,而是需要引用一個外部前提才成立,而那個前提往往住在另一個檔案、由另一個人維護、有著自己獨立的演化步調。只要那個前提不變,它們就永遠正確;一旦有人基於完全正當的理由去動那個前提,它們就在無人知曉的情況下同時失效。這裡沒有惡意,甚至沒有疏忽——2018 年的作者做對了他該做的,2024 年的作者也做對了他該做的,錯的是這兩份正確之間,沒有任何機制把彼此的前提綁在一起。
click a column header to sort · 3 commits × 4 attributes
| commit / date | 做了什麼 | FUSE_NAME_MAX | 碰了 readdir.c? |
|---|---|---|---|
69e3455Oct 2018 · v4.20 |
引入 readdir cache 與換頁檢查 | 1024 | 是(寫下這段程式) |
27992efDec 2024 · v6.15 |
把上限提高到 PATH_MAX-1 | 4095 | 否(上膛卻沒碰它) |
51a8de620 Apr 2026 |
拒絕過大的 dirent | 4095 | 是(補上那道檢查) |
把三筆 commit 並排看,中間那一列是整個故事的關鍵。它把常數改大了,卻沒碰那段依賴這個常數的程式碼——因為改它的人,多半根本不知道有這麼一段程式碼在遠處默默依賴著這個常數。
三個檔案外的一行改動,把它上了膛
2024 年 12 月,commit 27992ef「fuse: Increase FUSE_NAME_MAX to PATH_MAX」把 fc->name_max 從舊的 1024 提高到 PATH_MAX - 1,也就是 4095。理由很正當:FUSE daemon 應該能表達完整長度的檔名,1024 這個上限本來就偏保守。這個 commit 做的事看起來乾淨俐落,唯獨作者說了一句要命的話——它「never touches fs/fuse/readdir.c, because nobody connected a name-length ceiling to a readdir-cache page-boundary check three files away」。它從沒碰過 fs/fuse/readdir.c,因為沒有人把一個「名字長度上限」,連到三個檔案外的一道「readdir cache 頁邊界檢查」。
常數一改大,那個一直為真的假設就在無人察覺的瞬間翻了面。現在檔名可以長到 4095,代進同一個公式:reclen = FUSE_REC_ALIGN(24 + 4095) = 4120。4120 大於 4096。第一次,一筆 record 大過了一整頁。而那段 2018 年的程式碼,只會問「放得下當前頁剩餘空間嗎」,答不下就換一頁重來——換到一張全新的空頁,它以為問題解決了,於是安心地 memcpy。可是連一張全空的頁都容不下 4120 bytes,超出的 24 bytes 就這麼寫進了下一個 physical page。
4120 減 4096,剩下的正是那個在漏洞描述裡出現的數字:24。header 是 24 bytes,對齊後的溢位量也恰好是 24 bytes,這不是巧合而是算術的必然。而這 24 bytes 的內容不是隨機的——它們來自 record 的尾端,也就是攻擊者控制的檔名的最後幾個位元組。換句話說,攻擊者不只能造成溢位,還能決定溢出去的那 24 bytes 寫的是什麼。這一點稍後會變成整個 exploit 的彈頭。
下面這個 widget 讓你自己把檔名長度從短拖到長,看 reclen 怎麼爬過 PAGE_SIZE 那條線。2018 年的滑桿最遠只能到 1024,永遠碰不到紅線;2024 年之後它能一路拉到 4095,越線的那一刻就是漏洞誕生的那一刻。
drag namelen and watch reclen cross PAGE_SIZE · toggle the 2018 ceiling
把天花板套回 1024,你會看到 reclen 的線爬到大約 1048 就撞上滑桿盡頭,離那條虛線的紅線還有一大截空白。這片空白就是那六年的安全感所在。它不是被檢查守住的,是被一個常數的數值頂住的——常數一動,空白就消失。
24 個 bytes 怎麼變成一個 root shell
越過頁尾寫 24 個受控 bytes,聽起來像個很小的原語,離「拿到 root」隔著十萬八千里。事實上這正是這類 kernel 漏洞最見功夫的部分:溢位本身只是入場券,怎麼安排記憶體版面讓那 24 bytes 精準落在有價值的地方,才是整個 exploit 的重量所在。作者把這條路拆成幾個環節,每一環都在對付 page allocator 的隨機性。核心的想法很直接:readdir cache 那一頁是溢位的來源,攻擊者要做的,是讓某一個有價值的目標剛好躺在這一頁的正後方,好讓那 24 bytes 落在目標身上。這裡選的目標是 /etc/passwd——把 root 的那一行改成空密碼,就等於一條無門檻的提權捷徑。
把 24-byte 溢位變成改寫 /etc/passwd 的四步
click any step above · 4 steps
page allocator 的隨機性是攻擊者的敵人:你需要那個溢位的頁,剛好緊鄰你想改寫的頁。第一步是製造可預測性。exploit「reads /proc/meminfo, computes roughly 10% of MemFree, and holds that much memory in large mmap(MAP_POPULATE) blocks」,再把 per-CPU 的 PCP(per-CPU pageset)freelist 抽乾,讓後續的頁配置不再從那層快取隨機取用,而是走可控的路徑。
目標是 /etc/passwd 對應的那一頁 page cache。用 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) 告訴 kernel「這段我不再需要了」,把它從 page cache 逐出,那一頁就被釋放回 allocator。現在攻擊者手上有一個剛剛空出來、位置已知的頁框。
接下來要讓溢位的來源頁與被害的目標頁在物理上相鄰。作者的做法是「drain the PCP lists, then orchestrate the two allocations (the readdir cache page and the passwd page-cache page) so they come from the same split」——讓 readdir cache 頁與重新 fault 進來的 passwd 頁來自同一次 buddy allocator 的 split。因為 PCP 已抽乾,「that allocation is likely to land on the PFN just freed」,作者自己用的是 likely,這一步本質上是機率性的,不是保證。
版面就位後觸發一次 readdir,那 24 bytes 就落進緊鄰的 passwd 頁。payload 是一行 root::0:0:x:.:——空密碼的 root。巧思在於接續的位元組:「the # characters on the second 'line' begin a comment in the passwd format, absorbing whatever partial text remains from the old root entry so the file still parses cleanly」。# 在 passwd 格式裡是註解開頭,把舊 root entry 殘留的半截文字整行吃掉,於是這個被踩過的 /etc/passwd 仍能被系統乾淨地 parse。
值得單獨點出的,是這條攻擊鏈為什麼有辦法從容做完這些安排。grooming 這種事最怕的是時間——你在調整記憶體版面的每一微秒,系統其他部分也在配置與釋放頁,隨時可能把你剛擺好的棋子踢亂。FUSE 卻替攻擊者把時間停住了。它的協定從 daemon 角度是同步的:「the kernel writes a FUSE_READDIR request to the fd, and blocks the calling thread until the daemon writes a reply」——kernel 把 readdir 請求寫進那個檔案描述符後,會阻塞呼叫的執行緒,直到 daemon 回覆。而 daemon 正是攻擊者自己寫的。它可以收下請求、然後不急著回,慢條斯理地把 freelist 抽乾、把目標頁逐出、把版面對齊,全部就緒了才吐出那個帶著超長檔名的回覆。整個 kernel 這一路上都在等它。
這個「同步」的性質也解釋了為什麼這類漏洞在 FUSE 上特別好利用。一般的 kernel heap 溢位,攻擊者得跟系統賽跑:先觸發配置、再搶在被覆寫前把敵對物件塞進正確的坑,時間窗常常只有幾微秒,成功率低到得跑上千次。FUSE 把這個賽跑變成了考試交卷——時間由攻擊者自己決定。daemon 收到 readdir 請求後想拖多久就拖多久,這段期間攻擊者可以慢慢確認 /proc 底下的記憶體狀態、反覆調整,確定版面對了才交卷。這不是 FUSE 的 bug,是它同步語意的必然副作用,卻讓每一個 FUSE 裡的記憶體錯誤都比它在別處的同類危險得多。
至於這段程式碼一般使用者碰不碰得到,答案是碰得到,而且不只一條路。第一條是 fusermount3,作者形容它是「the small setuid-root helper that performs the privileged mount(2) on the user's behalf」——一支 setuid-root 的小助手,代普通使用者去做需要特權的 mount。它跟著 GNOME 的 gvfs-fuse 依賴,裝在 Ubuntu、Fedora Workstation、Linux Mint 上。第二條更直接:「Since Linux 4.18, an unprivileged user can create a user + mount namespace (unshare -Ufirmp), gain CAP_SYS_ADMIN inside it, open /dev/fuse (typically mode 0666), and mount a FUSE filesystem directly.」Linux 4.18 起,非特權使用者可以自建一個 user + mount namespace,在裡面拿到 CAP_SYS_ADMIN,打開通常是 0666 的 /dev/fuse,自己 mount 一個 FUSE 檔案系統。這在 Debian 11+、Arch、RHEL 9+、Fedora 上預設開著。
修法是一行:把假設寫下來
知道了根因,修法就短得幾乎有點反高潮。2026 年 4 月 20 日,commit 51a8de6 併入上游,正文只加了一個判斷:
if (offset + reclen > PAGE_SIZE) {
+ if (reclen > PAGE_SIZE)
+ return;
index++;
offset = 0;
}
原本的邏輯是:如果放不下當前頁剩餘空間(offset + reclen > PAGE_SIZE),就換到下一頁從頭放。新增的兩行補的正是那個從 2018 年就漏掉的問題——換頁之前,先問一句:這筆 record 連一整頁都放不進去嗎?是的話,直接放棄這一筆(return),不要硬塞。作者對這個修法的定性很到位:「No interface is reworked and nothing is restructured. The assumption that was always implicit ('a record fits in a page') is simply made explicit and enforced.」沒有重構任何介面,什麼都沒有打掉重來。那個一直隱含著的假設——「一筆 record 放得進一頁」——只是被明白地寫出來、並強制執行了。
一行修法配上六年潛伏,這個落差本身就是這篇的重點。漏洞不是寫在 2024 年那個把常數改大的 commit 裡,那個 commit 從各方面看都是對的、該做的、乾淨的。漏洞也不完全是寫在 2018 年那段程式碼裡,它在當時的世界裡確實正確。漏洞是寫在兩者之間那片沒有人負責的空隙裡:一個跨檔案的不變式,一頭是 fs/fuse/readdir.c 裡「record 放得進一頁」的默默假設,另一頭是三個檔案外那個餵養這個假設的常數,而沒有任何一道 if、任何一行註解、任何一個測試,把這兩頭綁在一起。改常數的人看不見那段程式碼,寫那段程式碼的人也想不到六年後有人會去動這個常數。
如果要問「當初怎樣才能避免」,答案其實就藏在修法裡,只是時序顛倒了。這一行 if (reclen > PAGE_SIZE) return; 如果 2018 年就寫下——不是為了防某個當時不存在的攻擊,而純粹是為了把那個隱含假設變成可執行的斷言——那麼 2024 年改大常數的那天,即使沒有人聯想到 readdir,這道檢查也會默默兜住底線:超大的 record 會被直接丟棄,daemon 那筆惡意回覆頂多讓某個目錄項顯示不出來,而不是踩穿一頁記憶體。防禦性的斷言之所以值得寫,不是因為你預見了未來的攻擊,而是因為它把「此刻為真的前提」釘死成「未來也必須為真的契約」;當有人日後動了餵養這個前提的常數,斷言會替那段沒人再看的程式碼把關。差別只在一行,和六年。
順帶一提,這個修補的 commit tag 裡還掛著一行「with an assist from Bynario AI」——連補這一行的過程都有工具介入了。而回報這個漏洞的是 Qi Tang 與 Zijun Hu,root-cause 與 exploitation 的完整分析則出自 cyberstan 本人之手,也是本篇所有數字與機制的唯一來源,讀的時候可以把它當成單一觀點的深入剖析,而不是多方交叉驗證過的定論。
下次先看哪裡:當你把一個上限、一個緩衝區大小、一個對齊常數改大時,別假設你的注意力停在同一個檔案就夠了——grep 這個常數被餵進去的每一處算術,因為某段程式碼的正確性,可能正默默地寄託在它原本那個較小的數值上,而那份寄託從來沒有被寫成一道檢查。