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

一個四十年前就被寫進 C 標準函式庫的字串複製函式,在 Linux kernel 裡花了六年、三百六十多個 patch 才被徹底拔乾淨。它的罪狀只有一條,但這條足夠致命:當來源跟目的一樣長,strncpy() 不會幫你補上字串結尾的那個 NUL。

Linux 花六年拔掉 strncpy()

是一場慢得幾乎不像新聞的工程戰役。據 Phoronix 報導,Linux 7.2 成為第一個 strncpy() 呼叫點掛零的 kernel 版本,而把它推到零的,是一支從 2020 年就開始逐一清點呼叫點的隊伍。值得記下的不是「又一個 unsafe API 被換掉」,而是這件事怎麼在數千萬行程式碼上被做到底——以及一個被誤用了幾十年的 C 字串 API,要怎麼才能真正退場。

先看清楚 strncpy 的契約到底承諾了什麼

多數人對 strncpy(dst, src, n) 的直覺是「把 src 複製到 dst,最多 n 個位元組,順便保證安全」。前半對,後半是幻覺。kernel 的官方棄用文件把問題講得很白:「Use of strncpy() does not guarantee that the destination buffer will be NUL terminated.」——使用 strncpy() 並不保證目的緩衝區會以 NUL 結尾。對一個被當成字串函式來用的 API 來說,這幾乎是契約的反面。

真正反直覺的地方在於它的兩種行為是分裂的。同一份文件接著說:「It also NUL-pads the destination buffer if the source contents are shorter than the destination buffer size, which may be a needless performance penalty for callers using only NUL-terminated strings.」——當來源內容比目的緩衝區短,它會用 NUL 把整個緩衝區補滿;對只想要 NUL 結尾字串的呼叫者來說,這是不必要的效能負擔。把兩句話合起來看,strncpy() 的行為依來源長度而分歧:來源比目的短,它補一堆 NUL;來源等於或超過目的,它一個 NUL 都不補。

換句話說,唯一會讓你拿到一個正常 C 字串的情況,恰好是來源「短於」目的的時候——而這正是你最不需要長度保護的情況。當你真的撞到邊界、來源把緩衝區塞滿,保護消失了,你得到一個沒有終止符的字元陣列。後面任何一個把它當字串讀的 strlenprintk("%s")strcpy,都會一路讀過緩衝區邊界,直到撞上記憶體裡下一個剛好是零的位元組。下面這張表把同一次呼叫在三種來源長度下的結果並排,點欄位標頭可以排序。

click column header to sort · 4 columns × 3 rows

case(strncpy(dst, src, n)) 實際複製進去的 NUL 結尾? 補 NUL?
strlen(src) < n src 全部 把剩餘空間全補滿 NUL
strlen(src) == n 前 n 個字元(src 全部) 沒有 無空間可補
strlen(src) > n 前 n 個字元(被截斷) 沒有 無空間可補
同一次呼叫,行為依 strlen(src)n 的關係分裂。NUL 結尾只在來源短於目的時成立——也就是你最不需要長度保護的那一格。

Kernel Self-Protection Project(KSPP)在追蹤這件事的 issue 裡,把這個契約的後果寫成一句沒有 hedge 的話:「The strncpy() function is actively dangerous to use since it may not NUL-terminate the destination string, resulting in potential memory content exposures, unbounded reads, or crashes.」——strncpy() 用起來是有實際危險性的,因為它可能不替目的字串補上 NUL,導致記憶體內容外洩、無界讀取或崩潰。注意這裡列的三個後果不是抽象風險:記憶體內容外洩是把緩衝區後面的鄰居資料當成字串印出去,無界讀取是讀過界,崩潰是讀到沒對映的頁面。一個字串複製函式能同時引發這三種,問題不在誰用錯,而在這個 API 的預設行為本身就是陷阱。

為什麼這個陷阱這麼難自己暴露?因為它幾乎不會在開發或測試階段觸發。日常測資裡的名字、路徑、識別字大多遠短於緩衝區,落在表格的第一格——複製正確、結尾正常、補零照做,一切看起來都對。只有當某個來源剛好把緩衝區塞滿、落到第二或第三格時,缺少的那個 NUL 才會讓後面某次讀取走過界,而那個讀取往往離出事的 strncpy 隔了好幾個函式、甚至好幾個 subsystem。bug 的因與果在時間和空間上都被拉開,這正是這類記憶體安全問題最難在 code review 裡被肉眼抓到的原因——你看著那行 strncpy,它長得跟對的一模一樣。

替代函式不是一個,而是一張按目的契約分流的表

如果只是把每個 strncpy() 機械地換成某個「安全版」,這個遷移會很快、也會錯。問題是 strncpy() 的呼叫點其實在做三件不同的事,被同一個函式名混在一起:有些要的是 NUL 結尾字串、有些要的是 NUL 結尾且補零、有些根本要的是定寬的非字串欄位(例如塞進一個固定大小的 protocol header field,刻意不要結尾符)。把它們分清楚,才知道該換成哪個函式。

HN 上對這次遷移的討論把替代方案的分流講得很精準:「strscpy() for NUL terminated destinations, strscpy_pad() for NUl-terminated destinations with zero-padding, strtomem_pad() for non-NUL-terminated fixed-width fields.」——strscpy() 給需要 NUL 結尾的目的、strscpy_pad() 給需要 NUL 結尾且補零的、strtomem_pad() 給非 NUL 結尾的定寬欄位。kernel 文件同向地寫道:「When the destination is required to be NUL-terminated, the replacement is strscpy()」,而「Any cases still needing NUL-padding should instead use strscpy_pad().」下面這張圖把目的緩衝區的契約當成入口,點任一塊看它該交給哪個函式、那個函式保證什麼、又刻意不保證什麼。

click any destination contract to see which function owns it · 5 cases

問目的緩衝區的契約,再選函式 要 NUL 結尾字串 strscpy() 要 NUL 結尾且補零 strscpy_pad() 定寬非字串欄位 strtomem() / _pad() 要新配一份字串 kstrndup() 那 strlcpy 呢? 也被勸退 一個 strncpy 被拆成五種意圖,遷移的工作量正來自這裡

strscpy() · 多數呼叫點該去的地方

kernel 文件:「When the destination is required to be NUL-terminated, the replacement is strscpy().」目的字串要 NUL 結尾,就用它。

它保證的:永遠 NUL 結尾。截斷時回傳 -E2BIG,呼叫者拿得到「有沒有放不下」這個信號。

它刻意不做的:不像 strncpy 那樣把剩餘空間全補滿 NUL。

strscpy_pad() · 真的需要補零時

kernel 文件:「Any cases still needing NUL-padding should instead use strscpy_pad().」少數呼叫點是真的依賴「剩餘位元組為零」這個語意(例如要把整個緩衝區寫進磁碟或送上線、不想洩漏舊內容)。

它保證的:NUL 結尾 + 把剩餘空間補零。

合理的推測是:這類呼叫點是少數,多數舊 strncpy 的補零行為其實沒人依賴,只是順帶發生。

strtomem() / strtomem_pad() · 根本不是字串

HN 討論:「strtomem_pad() for non-NUL-terminated fixed-width fields.」有些目的根本是定寬二進位欄位,刻意不要結尾符。

它保證的:把來源搬進一個編譯期已知大小的目的,_pad 版補零。

它解決的真問題:把「我要的就是定寬欄位」這個意圖,從「我忘了加 NUL」這個 bug 裡分離出來。

kstrndup() · 連配置一起做掉

KSPP issue 把它列為 NUL 結尾目的的替代之一。當目的緩衝區本來就要新配一塊(而不是寫進既有固定陣列),kstrndup() 一次把「配記憶體 + 複製 + 補 NUL」做完。

它換掉的:那種「先 kmalloc 再 strncpy」的兩步式樣板,少一個容易忘記補 NUL 的中間態。

strlcpy() · 一個常見的「假安全」答案

很多人第一反應是改用 strlcpy,但 kernel 文件把它也勸退了:「strlcpy() reads the entire source buffer first ... This read may exceed the destination size limit. This is both inefficient and can lead to linear read overflows ... The safe replacement is strscpy().」

關鍵strlcpy 為了回傳來源長度,會先讀完整個來源——當來源不保證在目的大小內結尾,這個讀本身就會越界。安全的替代仍是 strscpy()

這張圖其實就是整場遷移困難度的來源。如果 strncpy 只對應一種正確替代,這會是一個 sed 腳本能跑完的事;但它對應到五種意圖,每一個呼叫點都得有人讀懂「這裡到底想幹嘛」,才能判斷該換成哪個。把判斷塞進工具是做不到的——工具看不出某個塞進 header 的 strncpy 是刻意要定寬欄位、還是單純忘了結尾符。這一步只能靠人,逐個 subsystem 讀過去。

strlcpy 那一格特別值得台灣這邊寫 kernel 或 driver 的人記住。它在 BSD 世界長年被當成「strncpy 的安全升級版」推廣,直覺上把它當答案很自然。但 kernel 的判斷是:strlcpy 為了回傳「來源原本多長」而先掃完整個來源,這個掃描在來源不保證結尾的情境下本身就是 linear read overflow。換句話說,從一個會越界寫的函式逃到一個會越界讀的函式,不算逃出來。正確的終點是 strscpy()

六年、三百六十多個 patch——退場是怎麼一版一版走完的

把這件事推到底的是 Kernel Self-Protection Project。追蹤用的 issue(KSPP issue #90,標題「Remove all strncpy() uses」)在 2020 年 8 月 11 日開出,指派給 Kees Cook。從那天到 Linux 7.2,跨度大約六年——HN 上這則新聞的標題直接寫成「Linux eliminates the strncpy API after six years of work, 360 patches」,六年、三百六十個 patch。Phoronix 的報導把 strncpy 形容為「a persistent source of bugs」,多年來持續產 bug 的源頭。

這種規模的清理不可能一刀切。它的節奏是逐版倒數呼叫點:每個 merge window 修掉一批,把全 tree 的 strncpy() 數量往下壓一截,下一版再壓一截,直到 7.2 歸零。下面這條時間軸把這場戰役的幾個錨點攤開,拖動把手看每個時點的狀態——中間的呼叫點數字沒有逐版的公開精確值,標成合理推測,錨定的是兩端確定的事實(2020 年開 issue、7.2 歸零)。

drag the handle along the timeline · 5 anchors over six years

2020.08
拖動把手檢視每個時點的狀態。

互動圖表

2020 年 8 月開 issue 追蹤,逐版倒數呼叫點,六年三百六十多個 patch,到 Linux 7.2 全 tree 歸零。

六年這個數字本身就是這篇值得寫的理由。一個 API 退場,難的從來不是技術——換成 strscpy 的機械動作很簡單。難的是規模乘上判斷:數千萬行裡的每一個呼叫點都要被人看過,判斷它屬於前面那五格的哪一格,而且不能一次全改、得跟著 kernel 正常的 merge 節奏一版一版滲透進各 subsystem,每個 subsystem 有自己的 maintainer、自己的 review 週期、自己的 regression 風險。三百六十個 patch 不是三百六十個機械替換,是三百六十次「讀懂、判斷、送審、過 review」。

這種隊伍要怎麼確定「真的清完了」,而不是漏掉散在某個冷門 driver 裡的最後幾個?關鍵是把判斷的負擔從人腦移回工具,但移的方式很講究。對那些「目的確實是定寬非字串欄位」的呼叫點,正確做法不是繼續用 strncpy,而是給那個欄位標上 __nonstring 屬性,明白告訴編譯器「這塊記憶體不是 C 字串,不要期待它有結尾符」。標完之後,編譯器就能把「對一個 __nonstring 欄位仍當字串處理」當成警告報出來。這一步把原本只存在於原作者腦中的意圖,固化成程式碼裡可被機器檢查的標註——一旦所有合法的非字串用法都標好,剩下任何一個 strncpy 就都是該換成 strscpy 系列的字串用法,工具能逐個盯著、不讓新呼叫點偷偷長回來。

這就是大規模清理的真正形狀:不是一次性的 grep-and-replace,而是「先用標註把意圖顯式化、再讓編譯器當守門員」的雙軌。先標 __nonstring 把刻意的定寬欄位分離出去,剩下的字串用法逐版換成 strscpystrscpy_pad,每換掉一批就把對應的呼叫點從 issue 上勾掉;等到全 tree 一個都不剩,再把「禁止新增 strncpy 呼叫」變成可以長期執行的規則。歸零不是終點上的一刀,是六年裡持續把計數往下壓、同時把守門機制立起來的累積結果。

同一個呼叫點,換之前換之後到底差在哪

把抽象的契約落到一行真實程式碼上,差異會更清楚。考慮一個常見樣板:把某個名字複製進一個固定大小的緩衝區。舊寫法用 strncpy,傳 sizeof(buf) 當長度,看起來人畜無害;但只要 name 的長度剛好等於或超過 sizeof(buf)buf 就沒有結尾符,下一個把它當字串讀的人就會越界。新寫法換成 strscpy,行為兩處改變:保證結尾,而且截斷時回傳 -E2BIG,把「放不下」這件事變成呼叫者能檢查的回傳值,而不是一個埋著的記憶體 bug。拖動分隔線比較同一段邏輯的前後。

drag the divider to compare the same call before and after the fix

BEFORE · strncpy char buf[16]; strncpy(buf, name, sizeof(buf)); name 長 16 時: buf 沒有結尾 NUL 下個 strlen(buf) 讀過界 沒有任何回傳值告訴你被截斷了 AFTER · strscpy char buf[16]; ssize_t r = strscpy( buf, name, sizeof(buf)); name 長 16 時: buf 保證 NUL 結尾 r == -E2BIG(被截斷) 截斷變成可檢查的回傳值

互動圖表

舊用 strncpy 在來源填滿緩衝區時不補結尾符,後續讀取越界;改成 strscpy 後保證結尾,截斷時回傳 -E2BIG 讓呼叫者檢查。

這個對照裡藏著為什麼 strscpy 是對的終點,而不只是「另一個複製函式」。它把兩個原本隱形的事實變成顯式的:結尾這件事不再依賴來源長度,永遠成立;截斷這件事不再是無聲的,而是一個 -E2BIG 回傳值,呼叫者可以選擇報錯、截斷後繼續、或重新配更大的緩衝區。strncpy 的世界裡,這兩個事實都得靠呼叫者自己記得去處理,而幾十年的 CVE 史證明了人記不住。把安全性從「靠紀律」改成「靠 API 預設行為」,這才是整場遷移真正換掉的東西。

一個被誤用幾十年的 API,退場給其他語言社群留下了什麼

strncpy 想成「壞函式」其實不太準。它在 1980 年代被設計出來時有它的用途——填寫定寬的、不需要結尾符的欄位,例如早期 Unix 檔案系統裡的目錄項。在那個情境下,「來源剛好填滿就不補 NUL」「來源較短就補滿剩餘空間」這兩個今天看來分裂的行為,其實是同一個目標的兩半:把一塊定寬空間完整寫好,結尾符不在需求裡。問題出在它的名字以 str 開頭、長得像 strcpy 的安全版,於是被當成字串函式來用了四十年,而它對字串的契約恰好在邊界上失效。這不是某個糟糕實作,是一個語意被誤讀的 API 在巨大程式庫裡複利累積的代價。

這也是為什麼替代方案要拆成一整排、而不是一個更安全的 strncpy。真正的修法不是把舊函式補強,而是承認「定寬欄位」與「NUL 結尾字串」從一開始就是兩種不同的需求,硬塞進同一個 API 才是錯誤的源頭。strtomem 系列把定寬那一半接走、strscpy 系列把字串那一半接走,每個函式只承諾一種契約、不再依來源長度改變行為。當一個函式只做一件事,呼叫點看一眼函式名就知道意圖,code review 也才有辦法在「這裡該不該結尾」上給出確定的判斷——而不是像面對 strncpy 時那樣,得先猜原作者當初想的是哪一種。

HN 上有人把這類工作稱為「the real work of systems engineering」,並說它是「on the scale of decades」——以數十年為尺度的系統工程真功夫。也有人指出更底層的問題出在 C 沒有像樣的 string buffer 與 string view 型別,逼得每個呼叫點自己手動處理長度與結尾。這兩個觀察其實是同一件事的兩面:當語言不在型別層面把「這塊記憶體是不是 NUL 結尾字串」表達出來,這個資訊就只能散在每一次呼叫的腦袋裡,而散出去的東西遲早會錯。

對在台灣寫系統軟體的人,這場戰役有兩個可以直接帶走的東西。第一,下次想用 strncpy 或反射性地改用 strlcpy 時,先問目的緩衝區的契約是哪一格——要 NUL 結尾就 strscpy,要定寬欄位就 strtomem,別讓一個函式名同時承擔互斥的意圖。第二,退役一個被廣泛誤用的 API,預算要按「年」抓而不是按「sprint」抓:真正的成本不在改,在於替每一個呼叫點重建當初寫它的人省略掉的判斷,而這件事規模一大就只能靠一支隊伍長期一版一版磨。

還有一個容易被忽略的層面:維護自己的 out-of-tree 模組或 driver 的人,現在等於繼承了這套規則。kernel 把 strncpy 的去留寫進 Documentation/process/deprecated.rst 這份活文件裡,它不只是歷史記錄,而是會被工具拿來檢查、會被 maintainer 拿來擋 patch 的依據。當主線把字串複製的正確做法收斂到 strscpy 一族,任何還停在舊寫法的程式碼,往後每次 rebase 上新版核心都會更顯眼、更可能撞上新的編譯器警告。與其等到那時候被動處理,不如趁早把自己這邊的呼叫點按同一張契約表盤點一遍——這場清理對主線是六年的終點,對下游卻是一個剛開始的維護義務。

退一步看,這件事真正示範的是 kernel 怎麼對待一個錯誤的預設值。它沒有發一封「請大家不要再用」的公告就當解決了,而是把「禁止」變成可執行的機制——文件、編譯器標註、issue 上逐項勾選、merge window 裡逐版推進——讓正確做法成為阻力最小的那條路,讓錯誤做法逐漸變得寫不出來。一個 API 能不能真的退場,從來不取決於大家是否認同它危險,而取決於有沒有人願意把這套機制建起來、再花數年盯著它跑完。

The lesson:一個 unsafe API 不會因為大家知道它危險就自己消失——它需要一支隊伍花六年、三百六十個 patch,把每個呼叫點背後的意圖重新讀一遍,才換得到 Linux 7.2 那個漂亮的零。