LWN: Control-flow integrity in 5.13!
關(guān)注了就能看到更多這么棒的文章哦~
Control-flow integrity in 5.13
By Jonathan Corbet
May 21, 2021
DeepL assisted translation
https://lwn.net/Articles/856514/
在為 5.13 內(nèi)核合并的許多改動中,有一項是增加了 LLVM control-flow integrity(CFI)機(jī)制的支持。CFI 通過檢查確保間接函數(shù)調(diào)用(indirect function call)未被攻擊者修改過,從而堵住系統(tǒng)漏洞。為了讓這個功能在內(nèi)核中正常用起來,需要做大量的工作。但最終結(jié)果看起來已經(jīng)可以用在生產(chǎn)環(huán)境中了,能夠用來保護(hù) Linux 系統(tǒng)免受這一類的攻擊。
Protecting indirect function calls
內(nèi)核在很大程度上非常依賴間接函數(shù)調(diào)用,也就是那些在編譯時尚未知道目標(biāo)地址的函數(shù)調(diào)用。各種各樣的設(shè)備驅(qū)動、文件系統(tǒng)和其他內(nèi)核子系統(tǒng)跟那些通用的 core 代碼對接的時候,都是通過提供可供調(diào)用的函數(shù)并在其中完成具體的工作。例如,當(dāng)需要打開一個文件(也可能是一個跟設(shè)備對應(yīng)的特殊文件節(jié)點(diǎn))時,core kernel 的代碼將采用間接調(diào)用的方式來跳轉(zhuǎn)到最合適的那個 open()函數(shù)中去,該函數(shù)是定義在相關(guān)的文件中的 file_operations structure 里面。這些間接函數(shù)調(diào)用就幫助在通用代碼和底層代碼之間進(jìn)行了清晰的隔離。
這種機(jī)制很靈活,一直工作得很好。不過,也使得那些間接調(diào)用成為一個吸引攻擊者的攻擊目標(biāo)。如果某個間接調(diào)用可以被重定向來跳轉(zhuǎn)到攻擊者所指定的位置,那么就完全無法控制他干什么壞事了。多年來的許多改動已經(jīng)使得攻擊者很難將自己的代碼注入內(nèi)核,但如果他們可以強(qiáng)制指定一個地址去執(zhí)行代碼,那么就完全不受約束了。請注意,利用漏洞的時候并不是一定要跳轉(zhuǎn)到另一個函數(shù)的開頭位置,其實完全可以跳到內(nèi)核代碼中的任何位置。如果能攻破一處間接函數(shù)調(diào)用的位置的話,內(nèi)核里有無數(shù)有吸引力的代碼可供攻擊者選擇。
CFI 就是希望通過限制間接調(diào)用,讓它們只能跳轉(zhuǎn)到合理的目標(biāo)位置,從而阻止這類攻擊行為。在這種情況下所說的 "合理的" 位置意味著跳轉(zhuǎn)到某個函數(shù)的開始地址,并且目標(biāo)函數(shù)的原型(prototype)要跟調(diào)用者預(yù)期的 prototype 一致。這個判斷條件并不完美,畢竟可能有一些具有相同原型的函數(shù)可能會對攻擊者也是有用的。但這個措施已經(jīng)能大大減少攻擊者可利用的目標(biāo)函數(shù),通常這已經(jīng)就足夠了。
這種檢查通常被稱為 "forward-edge CFI",因為它保護(hù)了對函數(shù)的調(diào)用。相應(yīng)的 "backward-edge" 類型的保護(hù)則確保了位于 stack 中的返回地址沒有被篡改。在 5.13 合并的 patch 主要關(guān)注的是 forward-edge 類型的問題。
LLVM CFI in Linux
具體來說,這個 CFI 功能的實現(xiàn)是通過在鏈接的時候(link time)對整個 kernel image 進(jìn)行檢查來實現(xiàn)的。也就是說,必須要打開 link-time optimization 功能。LLVM 在每發(fā)現(xiàn)一個函數(shù)地址位置的時候,就會記下該函數(shù)及其原型。然后,它會將 "跳轉(zhuǎn)表" 塞到最終構(gòu)建好的內(nèi)核中,其中針對每一類函數(shù)原型都有一項。舉例來說,上面提到的 open()函數(shù)被定義為:
int (*open) (struct inode *inode, struct file *file);
內(nèi)核中有許多符合此原型的函數(shù),它們的地址都被放到某個 file_operations 結(jié)構(gòu)中以供使用。LLVM 將把它們?nèi)渴占揭粋€跳轉(zhuǎn)表中,這個跳轉(zhuǎn)表基本上就是這些函數(shù)的地址列表。
下一步就是修改所有用到該函數(shù)地址的地方,并將這些內(nèi)容修改為跳轉(zhuǎn)表中的相應(yīng)位置。所以像下面這樣的賦值操作:
func_ptr = my_open_function;
將會將跳轉(zhuǎn)表中的一個地址分配給 func_ptr。
最后,每當(dāng)一個間接函數(shù)被調(diào)用時,就會去執(zhí)行一個叫做 __cfi_check() 的特殊函數(shù)。這個函數(shù)收到的參數(shù)除了目標(biāo)地址之外,還會有與被調(diào)用函數(shù)的原型相匹配的跳轉(zhuǎn)表地址。它會檢查目標(biāo)地址是不是符合目標(biāo)跳轉(zhuǎn)表中的一項地址,是的話就從 list 中提取真正的函數(shù)地址然后跳轉(zhuǎn)過去。如果目標(biāo)地址不在跳轉(zhuǎn)表內(nèi),那么缺省的行為就是將其視作攻擊行為,馬上讓系統(tǒng)進(jìn)入 panic。在 config 階段可以選擇一種另一種 permissive mode,也就是僅僅將這次錯誤記錄下來。
Kernel-specific quirks
上面這種極端反應(yīng)很可能是合理選擇,但是如果 kernel 里面真有這樣的情況,也就是內(nèi)核對一個函數(shù)的 indirect call 跟正在使用的函數(shù)指針的原型不是完全匹配的,這肯定會是很煩人的一件事。其實,內(nèi)核里面真有這樣的情況。在 5.13 之前的內(nèi)核中,list_sort() 的聲明都是:
void list_sort(void *priv, struct list_head *head,
int (*cmp)(void *priv, struct list_head *a, struct list_head *b))
這里的 cmp 是一個比較函數(shù),由調(diào)用者傳入進(jìn)來,并通過 indirect call 方式用來比較 list 中的每一項。在 list_sort() 里面,我們看到這一行:
a = merge(priv, (cmp_func)cmp, b, a);
這里的 cmp_func 指向的類型看起來幾乎和 cmp()的原型一樣,但是還是有差別的,就是兩個 list_head 指針帶有 const 標(biāo)志了。這就導(dǎo)致函數(shù)的原型并不相同,于是在運(yùn)行時就會導(dǎo)致 CFI 檢查失敗。這里所采用的 fix 方法是將 const 屬性傳遞給 list_sort()的調(diào)用者,這樣就不需要對函數(shù)指針進(jìn)行類型轉(zhuǎn)換了。然而,這需要在整個內(nèi)核源代碼庫中改變 40 個獨(dú)立文件中的調(diào)用它的代碼。
還有一個有意思的特殊情況,這來自于跳轉(zhuǎn)表是在 link time 建立的這個特點(diǎn)。這對于單一內(nèi)核(monolithic)來說是可行的,可是 loadable module 都是單獨(dú)鏈接的。這些 loadable module 中的 CFI 也是可以工作的,但是每個 module 都會有自己的跳轉(zhuǎn)表。請記住,函數(shù)指針被替換成了指向跳轉(zhuǎn)表的指針,因此,由于各個 module 都有各自獨(dú)立的跳轉(zhuǎn)表,因此它們也會得到不同的指針。換句話說,如果有兩個指向同一個函數(shù)的指針,如果其中之一位于 loadable module 中的話,那么這兩個指針的值可能會是不相同的。
大多數(shù)情況下都還是能正常使用的,畢竟對這兩個不同指針的調(diào)用最終其實都會跳到同一個地方去。但是 __queue_delayed_work() 中的這一行就不行了:
WARN_ON_ONCE(timer->function != delayed_work_timer_fn);
這一行檢測代碼是在 2012 年添加到 3.7 內(nèi)核中的,用來 "檢測是否有一些使用了 delayed_work 的地方會對內(nèi)部 timer 定時器做手腳"。差不多 9 年過去了,人們認(rèn)為這類問題應(yīng)該都已經(jīng)被找出來了,但這個檢測條件仍然存在。但是,如果 CFI 被打開的話,那么從 loadable module 看到的 delayed_work_timer_fn()的地址就跟從 core kernel 看到的地址不一樣,于是檢測出錯。內(nèi)核中有幾個地方都有類似這樣的代碼。現(xiàn)在它們已經(jīng)被 "fix" 了,具體的 fix 方法就是在打開 CFI 時直接將這種代碼禁用掉。
還有其他一些情況也需要 fix,比如有一些情況下必須要使用一個直接指向函數(shù)的指針,而不能使用這種指向跳轉(zhuǎn)表的指針。內(nèi)核中的 CFI 在 5.13 版本中只有在 arm64 架構(gòu)上可用,對于 x86 架構(gòu)的支持正在進(jìn)行中,但還沒有準(zhǔn)備好,暫時無法啟用。關(guān)于這個功能對性能有多大影響,似乎還沒有多少評估,不過在介紹 CFI 的 LLVM 文檔中據(jù)說它的額外開銷 "低于 1%"。
CFI 看起來是一個可能會有一些負(fù)面影響或者邊邊角角沒有考慮周全的新功能。值得注意的是,Kees Cook 在發(fā)送這個請求合并這些由 Sami Tolvanen 撰寫的 patch 的 pull request 時說,CFI "已經(jīng)在 Android 內(nèi)核中應(yīng)用了將近 3 年了"。換句話說,這個功能已經(jīng)在現(xiàn)實世界中廣泛部署了,因此也許不會有多少意外,不過,對于攻擊者就不一樣了,他們會發(fā)現(xiàn)他們以前使用的許多漏洞不再有效。
全文完
LWN 文章遵循 CC BY-SA 4.0 許可協(xié)議。
長按下面二維碼關(guān)注,關(guān)注 LWN 深度文章以及開源社區(qū)的各種新近言論~
