LWN:用戶空間的影子堆棧!
關(guān)注了就能看到更多這么棒的文章哦~
Shadow stacks for user space
By Jonathan Corbet
February 21, 2022
DeepL assisted translation
https://lwn.net/Articles/885220/
對系統(tǒng)進(jìn)行攻擊的時(shí)候,為了攻破運(yùn)行中的某個進(jìn)程,攻擊者最喜歡的目標(biāo)就是 call stack 了。只要他們能找到一種方法來將 stack 上的返回地址(return address)改寫掉,那么就可以將系統(tǒng)的控制權(quán)重定向到他們精心選擇的代碼上,從而可以讓 "游戲結(jié)束"。因此,人們在保護(hù)堆棧這個方面做了大量的工作。其中一種很有希望的技術(shù)就是影子堆棧(shadow stock),于是在各種處理器里面都開始添加影子堆棧的支持。要想利用影子堆棧來保護(hù)用戶空間的應(yīng)用程序,則還需要更多時(shí)間。目前內(nèi)核社區(qū)內(nèi)正在進(jìn)行這個話題的討論,但看起來比人們預(yù)想的要棘手。此外,由于這些補(bǔ)丁已經(jīng)存在挺長時(shí)間了,所以它們自己也出現(xiàn)了一些向后兼容問題。
Shadow-stack basics
每當(dāng)一個函數(shù)調(diào)用另一個函數(shù)時(shí),被調(diào)用函數(shù)里的信息,包括所有參數(shù)以及函數(shù)完成工作后應(yīng)該跳轉(zhuǎn)回去的地址都會被放到調(diào)用棧(call stack)里。隨著函數(shù)調(diào)用逐層深入,堆棧中的返回地址的數(shù)量也在迅速增加。通常一切都能按部就班地進(jìn)行,但只要堆棧內(nèi)容有任何一點(diǎn)損壞,都可能會導(dǎo)致一個或多個返回地址被改寫掉,從而導(dǎo)致 CPU 執(zhí)行代碼跳轉(zhuǎn)到一個意料之外的地方。運(yùn)氣好的情況下也會導(dǎo)致應(yīng)用程序崩潰,運(yùn)氣不好的話,也許這個錯誤的數(shù)據(jù)是故意準(zhǔn)備好的,那么系統(tǒng)的執(zhí)行過程繼續(xù)下去就會導(dǎo)致更加棘手的問題。
shadow stack 試圖給堆棧創(chuàng)建一個副本來解決這個問題,這個副本里面(通常)只包含返回地址這類數(shù)據(jù)。每當(dāng)一個函數(shù)被調(diào)用時(shí),返回地址會被同時(shí)寫入常規(guī)堆棧以及影子堆棧中。當(dāng)該函數(shù)返回時(shí),返回地址需要從兩個堆棧中都提取出來進(jìn)行比較,如果不匹配的話系統(tǒng)就會給出紅色警報(bào),并(可能)kill 掉相關(guān)進(jìn)程。影子堆??梢酝耆密浖绞絹韺?shí)現(xiàn),盡管影子堆棧也是可被改寫的,但也也提高了攻擊者的攻擊門檻,他們現(xiàn)在必須要能破壞兩個內(nèi)存區(qū)域了,其中一個不容易確定是在什么位置。不過,硬件支持使影子堆棧的話可以使其更加強(qiáng)大。
英特爾處理器(還有其他一些廠商)就可以提供這種支持。如果一個影子堆棧正確建立起來(這是一個特權(quán)操作),那么后續(xù)將返回地址入棧的操作以及在函數(shù)返回時(shí)進(jìn)行比較的操作全都是由 CPU 硬件自己完成的。同時(shí),影子堆棧通常不能被應(yīng)用程序?qū)懭耄ㄖ挥型ㄟ^函數(shù)調(diào)用以及 RET 指令方式才能寫入),因此不會被攻擊者破壞。硬件還要求影子堆棧本身要有一個特別的 "restore token",用來確保兩個進(jìn)程不會共享同一個影子堆棧,因?yàn)檫@種情況也被利用來作為攻擊突破口。
Supporting user-space shadow stacks
當(dāng)前版本的影子堆棧支持 patch 是由 Rick Edgecombe 發(fā)布的,其中大部分 patch 本身是由 Yu-cheng Yu 編寫的,這項(xiàng)工作的許多早期版本都是他發(fā)布的。要啟用這個功能需要 35 個規(guī)模很大的 patch,而且這個問題還沒有完全解決。人們可能會想知道這里有什么難點(diǎn),畢竟影子堆棧似乎是一個大多數(shù)代碼中幾乎可以完全忽略的功能,但生活從來沒有那么簡單。
可以想到,內(nèi)核里必須要準(zhǔn)備代碼來管理用戶空間的影子堆棧。這其中包括在處理器上啟用該功能、為每個特定的進(jìn)程來進(jìn)行處理。每個進(jìn)程都需要有自己的影子堆棧,并設(shè)置好自己的 restore token,然后修改影子堆棧指針寄存器(這是個特權(quán)操作)來指向它。還需要處理那些 fault,包括正常的 page fault,也包括比如違背了完整性校驗(yàn)的那些 integrity-violation trap 等。還需要管理許多關(guān)于上下文切換的信息。對于這種新功能來說,這些都是很正常的工作。
為影子堆棧本身所分配的內(nèi)存空間必須要特別對待。它是屬于用戶空間的,但通常不允許用戶空間的代碼對其進(jìn)行寫入。處理器也必須要能識別出這些專門用于影子堆棧的內(nèi)存,所以在頁表中要有特別的標(biāo)記,這就導(dǎo)致事情變得有點(diǎn)復(fù)雜了。在每個頁表項(xiàng)(PTE, page-table entry)中預(yù)留了許多 bit 用來描述相應(yīng)的保護(hù)措施以及其他各種狀態(tài),但 X86 架構(gòu)定義中未包括影子堆棧相關(guān)的 bit。這里有一些 PTE bit 是留給操作系統(tǒng)使用的,Linux 并未用完所有的 PTE bit,因此可以為這個目的而留出一個 bit,但顯然有一些其他的操作系統(tǒng)中沒有多余的 PTE bit,所以如果為這個目的來占用一個 bit 的計(jì)劃并不受歡迎。
硬件工程師得出的解決方案看起來可能有點(diǎn) hack。如果某個 page 的 write-enable bit 是 0(表明它不能被寫入),但 dirty bit 又是 1(表明它已經(jīng)被寫入過了),那么 CPU 就判定這些 page 是影子堆棧的一部分。因?yàn)檫@個組合在正常使用中應(yīng)該是沒有意義的,所以這種做法看起來很合理。
不幸的是,Linux 內(nèi)核開發(fā)者們在許多年前就得出了類似的結(jié)論,所以 Linux 對 PTE bit 出現(xiàn)這種組合的情況已經(jīng)有了自己特有的解釋。內(nèi)核用這種方法來標(biāo)記寫時(shí)復(fù)制的頁面(copy-on-write page)。如果某個進(jìn)程試圖寫入該頁,因?yàn)槿狈懭霗?quán)限所以會觸發(fā)一個 trap,而 dirty 位是 1 就讓內(nèi)核知道需要對該頁進(jìn)行一次復(fù)制,并把寫入權(quán)限賦予這個進(jìn)程。這個機(jī)制一直運(yùn)行得很好,但是現(xiàn)在 CPU 開始對這種組合給出了它自己特有的解釋。所以大部分的 patch 都是用來為一個新增的 _PAGE_COW flag 來尋找一個尚未使用的 PTE bit,并修改內(nèi)存管理代碼從而適配起來。
當(dāng)然,影子堆棧還帶來了其他一些復(fù)雜的問題。如果一個進(jìn)程調(diào)用了 clone(),那么就必須為子進(jìn)程分配一個新的影子堆棧,內(nèi)核會自動處理好這個任務(wù)。但是 signal 又一次來 kernel 開發(fā)者們添加了麻煩,因?yàn)?signal 也涉及到各種對堆棧的操作。如果某個進(jìn)程用 sigaltstack() 來給 signal handler 處理程序設(shè)置了一個替代堆棧,情況就更糟糕了。當(dāng)時(shí)的 patch set 根本就無法處理這種情況。因此基于這些細(xì)節(jié)(有很多)出發(fā),引出了這個很長的 patch 系列。
ABI issues
影子堆棧的使用,對于大多數(shù)應(yīng)用程序來說應(yīng)該是完全透明的。畢竟,開發(fā)人員正常情況下很少考慮 call stack。但是總有一些應(yīng)用程序會對它們的 stack 做一些特別的操作。首先是多線程程序,它們會明確管理每個線程的堆棧區(qū)域。還有一些程序可能會把他們自己特別制作的 thunk 代碼(參見?https://en.wikipedia.org/wiki/Thunk?) 放到堆棧上,甚至可能還會放其他一些更隱蔽的內(nèi)容。如果沒有特別處理的話,在這些程序用在影子堆棧環(huán)境下就會出問題。這種大規(guī)模的 regression 問題也正是安全功能(security features)不受歡迎的原因,所以開發(fā)者采取了各種措施來避免這種情況。
他們深思熟慮之后,提出的計(jì)劃是對準(zhǔn)備用影子堆棧運(yùn)行的應(yīng)用程序進(jìn)行標(biāo)記(在.note.gnu.property ELF section 有一個特殊的 property)。沒有對堆棧進(jìn)行特殊使用的應(yīng)用程序可以直接重新編譯一下,后續(xù)就可以在運(yùn)行時(shí)支持影子堆棧了。對于那些更復(fù)雜的情況,我們定義了一組 arch_prctl() 操作來實(shí)現(xiàn)對影子堆棧的顯式操作。GNU C 庫也添加了功能,從而可以在應(yīng)用程序啟動時(shí)使用這些調(diào)用來正確配置好環(huán)境,而內(nèi)核將在運(yùn)行這些標(biāo)記好的程序的時(shí)候啟用影子堆棧。包括 Fedora 和 Ubuntu 在內(nèi)的一些發(fā)行版,已經(jīng)針對影子堆棧構(gòu)建好了他們平臺上的二進(jìn)制文件,他們所需要的只是一個合適的內(nèi)核來運(yùn)行額外的保護(hù)。
不過,根據(jù)尚未被接受和合并的內(nèi)核功能來發(fā)布代碼,總是很容易出問題的做法。這次影子堆棧又變成了典型的例子。根據(jù)當(dāng)前 patch 系列的封面所說,arch_prctl() API "因?yàn)樘婀侄环艞壛?。但是,那些部署在世界各地的系統(tǒng)上的針對影子堆棧準(zhǔn)備好的二進(jìn)制文件在構(gòu)建時(shí)就認(rèn)為是一定會有那個 API 的,才不管它是不是奇怪。如果內(nèi)核會審查 ELF 文件中的標(biāo)記從而為這些程序來啟用影子堆棧,那么其中一些程序就會出問題。這就會導(dǎo)致全世界的系統(tǒng)管理員至少在 2040 年之前都會禁用影子堆棧,從而使整個工作的目的完全無法落實(shí)了。
解決這個問題的一個明顯辦法是,永遠(yuǎn)不要認(rèn)可當(dāng)前 ELF 中的影子堆棧特有標(biāo)記,而是新創(chuàng)建一個標(biāo)記來利用內(nèi)核實(shí)際會支持的接口。然而,這里的最終決定是讓內(nèi)核完全不去處理識別二進(jìn)制文件是否支持影子堆棧,而是讓 C 庫來處理這個功能。所以,如果這個版本的 ABI 被采用的話,內(nèi)核就永遠(yuǎn)不會啟用影子堆棧了,除非用戶空間顯式要求。
The proposed interface
對影子堆棧功能的整體控制是通過一個(人們假設(shè)這個接口并不奇怪)arch_prctl() 的調(diào)用來實(shí)現(xiàn)的:
status = arch_prctl(ARCH_X86_FEATURE_ENABLE, ARCH_X86_FEATURE_SHSTK);
還有一個 ARCH_X86_FEATURE_DISABLE 操作用來關(guān)閉影子堆棧,以及 ARCH_X86_FEATURE_LOCK 用來防止未來的改動。
雖然大多數(shù)應(yīng)用程序不需要擔(dān)心影子堆棧,但其中有一些需要有能力創(chuàng)建新的影子堆棧。使用 makecontext() 系列接口的應(yīng)用程序就是一個很顯著的例子。創(chuàng)建影子堆棧需要有內(nèi)核的支持,而相關(guān)的內(nèi)存又必須有上述的特殊 page bit 組合設(shè)置,而且還必須包括 restore token。所以針對這些操作要有一個新的系統(tǒng)調(diào)用:
void *map_shadow_stack(unsigned long size, unsigned int flags);
所需堆棧的大小就是 size 指定的,而 flags 只可以是 SHADOW_STACK_SET_TOKEN,用來請求在堆棧中存儲一個 restore token。成功后的返回值就是這個堆棧的基地址。
在使用這個新的堆棧的時(shí)候,需要執(zhí)行 RSTORSSP 指令來進(jìn)行切換,這很可能是作為線程之間的用戶空間上下文切換的一部分來完成。該指令會在進(jìn)行切換前先對 page 權(quán)限以及 restore token 進(jìn)行必要驗(yàn)證。它還會將新的影子堆棧上的 token 標(biāo)記為此時(shí)在使用中,從而防止該堆棧被其他進(jìn)程使用。
那些做了特別棘手的操作的應(yīng)用程序可能就需要能對影子堆棧進(jìn)行寫入了。由于眾所周知的原因,通常不允許這種操作,但正如 Edgecombe 所指出的,這 "限制了那些潛在的有用的應(yīng)用程序,他們可能想犧牲一點(diǎn)安全,以此為代價(jià)來實(shí)現(xiàn)一些奇怪的工作"。對于這些特殊情況,可以通過 arch_prctl() 來打開另一個特性(LINUX_X86_FEATURE_WRSS),它可以啟用 WRSS 指令,用它就能寫入影子堆棧的內(nèi)存區(qū)域了。在這種情況下,仍然不可以直接根據(jù)指針來對該內(nèi)存進(jìn)行寫入。
What next?
這項(xiàng)工作并不是個新的工作,它的早期版本在 2018 年的 LWN 文章中就有所涉及。影子堆棧 patch set 之前經(jīng)過了 30 個版本。關(guān)于 control-flow integrity 的工作(indirect branch tracking)也達(dá)到了第 29 版本,不過這個當(dāng)前已經(jīng)被擱置了(盡管 Peter Zijlstra 剛剛提出了另一種實(shí)現(xiàn))。隨著有一個新的開發(fā)者領(lǐng)導(dǎo)這項(xiàng)工作,以及縮小了的目標(biāo),還有一些人們要求要做的改動,我們希望這項(xiàng)工作能夠最終合入 mainline。
有很多因素讓這個希望看起來很可能會落實(shí)。雖然有人對這套 patch 的各個部分都提出了一些意見,似乎目前沒有多少人反對它的工作方式。不過,開發(fā)者們確實(shí)對缺乏支持另一個 signal stack 而感到擔(dān)憂。這個功能在某種程度上來說肯定是必要的,所以在這個功能被合并之前,還是需要先看看如何解決這個問題。
還有一個另一個討論,是關(guān)于用戶空間中的檢查點(diǎn)/恢復(fù)(CRIU, Checkpoint/restore in user space)的,這個功能為了實(shí)現(xiàn)目標(biāo)而采用了一些不正當(dāng)?shù)氖侄?。checkpoint 生成過程中牽涉到將 "parasite" 代碼注入到需要抓取快照的目標(biāo)進(jìn)程中,來抓取所需的信息,然后完成一個特殊的 return 操作來恢復(fù)正常執(zhí)行。這正是影子堆棧所要防止的那種控制流篡改問題(control-flow tampering)。我們討論了各種可能的解決方案,但目前還沒有落實(shí)到代碼上。正如 Thomas Gleixner 所說,在影子堆??梢员缓喜⒅埃残枰冉鉀Q這個問題:"我們不能因?yàn)閮?nèi)核升級而破壞 CRIU 機(jī)制"。
最后,這個功能所支持的硬件范圍肯定需要再擴(kuò)大一些。有一些 AMD 的 CPU 也實(shí)現(xiàn)了影子堆棧,看起來實(shí)現(xiàn)方式可以兼容,但是在這個 patch set 中只支持了英特爾的 CPU。人們認(rèn)為原因可能是無法進(jìn)行測試。這一點(diǎn)至少是需要改變的,才能讓工作繼續(xù)推進(jìn)下去。影子堆棧在 32 位系統(tǒng)上也無法支持。要解決這個問題可能更加困難,不清楚人們是否有動力去做這項(xiàng)工作。不過,無論是否支持 32 位系統(tǒng),在這段代碼進(jìn)入 mainline 之前都仍有工作要做。不要期望它能在不久的將來出現(xiàn)在 Linux 的某個版本中。
全文完
LWN 文章遵循 CC BY-SA 4.0 許可協(xié)議。
長按下面二維碼關(guān)注,關(guān)注 LWN 深度文章以及開源社區(qū)的各種新近言論~
