文/扶風、丁緩、 雛雁 linux
Linux 內核熱補丁能夠修復正在運行的 linux 內核,是一種維持線上穩定性不可缺乏的措施,如今比較常見的好比 kpatch 和 livepatch。內核熱補丁能夠修復內核中正在運行的函數,用已修復的函數替換掉內核中存在問題的函數從而達到修復目的。segmentfault
函數替換的思想比較簡單,就是在執行舊函數時繞開它的執行邏輯而跳轉到新的函數中,有一種比較簡單粗暴的方式,就是將原函數的第一條指令修改成「jump 目標函數」指令,即直接跳轉到新的函數以達到替換目的。安全
那麼,問題來了,這麼作靠譜嗎?直接將原函數的第一條指令修改成 jump 指令,會破壞掉原函數和它的調用者之間的寄存器上下文關係,存在安全隱患!本文會針對該問題進行探索和驗證。架構
對於函數調用,假設存在這樣兩個函數 funA 和 funB,其中 funA 調用 funB 函數,這裏稱 funA 爲 caller(調用者),funB 爲 callee(被調用者),funA 和 funB 都使用了相同的寄存器 R,以下所示:app
圖1 funA 和 funB 都使用了寄存器 R,funA 再次使用 R 時已經被 funB 修改函數
所以,當 funA 再次使用到 R 的數據已是錯誤的數據了。若是 funA 在調用 funB 前保存寄存器 R 中的數據,funB 返回後再將數據恢復到 R 中,或者 funB 先保存 R 中原有的數據,而後在返回前恢復,就能夠解決這類問題。工具
那寄存器該由 caller 仍是 callee 來保存?這就須要遵循函數的調用約定(call convention),不一樣的 ABI 和不一樣的平臺,函數的調用約定是不同的,對於 Linux 來講,它遵循的是 System V ABI 的 call convention,x86_64 平臺下函數調用約定有且只有一種,調用者 caller 和被調用者 callee 須要對相應的寄存器進行保存和恢復操做:性能
設問:當函數實現很簡單,只用到了少許寄存器,那沒使用到的還須要保存嗎?
答案:it depends。根據編譯選項決定。測試
衆所周知,GCC 編譯器有 -O0、-O一、-O2 和 -Ox 等編譯優化選項,優化範圍和深度隨 x 增大而增大(-O0是不優化,其中隱含的意思是,它會嚴格遵循 ABI 中的調用約定,對全部使用的寄存器進行保存和恢復)。優化
Linux 內核選用的都是 -O2 優化。GCC 會選擇性的不遵照調用約定,也就是設問裏提到的,不須要保存沒使用到的寄存器。
GCC 之因此能夠作這個優化,是由於 GCC 高屋建瓴,瞭解程序的執行流。當它知道 callee,caller 的寄存器分配狀況,就會大膽且安全地作各類優化。
可是,運行時替換破壞了這個假設,GCC 所掌握的 callee 信息,極有多是錯誤的。那麼這些優化可能會引起嚴重問題。這裏以一個具體的實例進行詳細說明,這是一個用戶態的例子( x86_64 平臺):
//test.c 文件 //編譯命令:gcc test.c -o test -O2 (kernel 採用的是 O2 優化選項) //執行過程:./test //輸入參數:4 #include <sys/mman.h> #include <string.h> #include <stdio.h> #include <math.h> #define noinline __attribute__ ((noinline)) //禁止內聯 static noinline int c(int x) { return x * x * x; } static noinline int b(int x) { return x; } static noinline int newb(int x) { return c(x * 2) * x; } static noinline int a(int x) { int volatile tmp = b(x); // tmp = 8 ** 3 * 4 return x + tmp; // return 4(not 8) + tmp } int main(void) { int x; scanf("%d", &x); if (mprotect((void*)(((unsigned long)&b) & (~0xFFFF)), 15, PROT_WRITE | PROT_EXEC | PROT_READ)) { perror("mprotect"); return 1; } /* 利用 jump 指令將函數 b 替換爲 newb 函數 */ ((char*)b)[0] = 0xe9; *(long*)((unsigned long)b + 1) = (unsigned long)&newb - (unsigned long)&b - 5; printf("%d", a(x)); return 0; }
該例子說明,直接使用 jump 指令替換函數在 -O2 的編譯優化下,會出現問題,安全性受到了質疑和衝擊!!!
上述例子中,咱們將函數 b 用 jump 指令替換爲 newb 函數,在 -O2 的編譯優化下出現了計算錯誤的結果。所以,咱們須要對函數的調用執行過程進行仔細分析,挖掘問題所在。首先,咱們先來查看一下該程序的反彙編(指令:objdump -d test),並重點關注 a、b 和 newb 函數:
圖2 -O2 編譯優化的反彙編結果
彙編解釋:
main: -> 將參數 4 存放到 edi 寄存器中 -> 調用 a 函數: -> 調用 b 函數,直接跳轉到 newb 函數: -> 將 edi 寄存器中的值存放到 edx 寄存器 -> edi 寄存器與自身相加後結果放入 edi -> 調用 c 函數: -> 將 edi 寄存器中的值存到 eax 寄存器 -> edi 乘以 eax 後結果放入 eax -> edi 乘以 eax 後結果放入 eax -> 返回到 newb 函數 -> 將 edx 與 eax 相乘後結果放入 eax -> 返回到 a 函數 -> 將 edi 與 eax 相加後結果放入 eax -> 返回 main 函數
(注意:b 函數中沒有對 edi 寄存器進行寫操做,並且它的代碼段被修改成 jump 指令跳轉到 newb 函數)
數據出錯的緣由在於,在函數 newb 中,使用到了 a 函數中使用的 edi 寄存器,edi 寄存器中的值在 newb 函數中被修改成 8,當 newb 函數返回後,edi 的值仍然是 8,a 函數繼續使用了該值,所以,計算過程變爲:8^3 4 + 8 = 2056,而正確的計算結果應該是 8^3 4 + 4 = 2052。
接下來不進行編譯優化(-O0),其輸出結果是正確的 2052,反彙編以下所示:
圖3 不進行編譯優化的反彙編
從反彙編中能夠看到,函數 a 在調用 b 函數前,將 edi 寄存器的值存在了棧上,調用以後,將棧上的數據再取出,最後進行相加。這就說明,-O2 優化選項將 edi 寄存器的保存和恢復操做優化掉了,而在調用約定中,edi 寄存器本就該屬於 caller 進行保存/恢復的。至於爲何編譯器會進行優化,咱們此刻的猜測是:
a 函數原本調用的是 b 函數,並且編譯器知道 b 函數中沒有使用到 edi 寄存器,所以調用者 a 函數沒有對該寄存器進行保存和恢復操做。可是編譯器不知道的是,在程序運行時,b 函數的代碼段被動態修改,利用 jump 指令替換爲 newb 函數,而在 newb 函數中對 edi 寄存器進行了數據讀寫操做,因而出現了錯誤。
這是一個典型的沒有保存 caller-save 寄存器致使數據出錯的場景。
而編譯內核採用的也是 -O2 選項。若是將該場景應用到內核函數熱替換是否會出現這類問題呢?因而,咱們帶着問題繼續探索。
咱們構造了一個內核函數熱替換的實例,將上面的用戶態的例子移植到咱們構造的場景中,經過內核模塊修改原函數的代碼段,用 jump 指令直接替換原來的 b 函數。然而加載模塊後,結果是正確的 2052,通過反彙編咱們發現,內核中 a 函數對 edi 寄存器進行了保存操做:
圖4 內核中 a 函數的反彙編
內核和模塊編譯時採用的是 -O2 優化選項,而此處 a 函數並無被優化,仍然保存了 edi 寄存器。
此時咱們預測:對於內核函數的熱替換來講,使用 jump 作函數替換是安全的。
咱們猜測是不是內核編譯時使用其它的編譯選項致使問題不能復現。果不其然,通過探索咱們發現內核編譯使用的 -pg 選項致使問題再也不復現。
經過翻閱 GCC 手冊得知,-pg 選項是爲了支持 GNU 的 gprop 性能分析工具所引入的,它能在函數中增長一條 call mount 指令,去作一些分析工做。
在內核中,若是開啓了 CONFIG_FUNCTION_TRACER,則會使能 -pg 選項。
圖5 開啓 CONFIG_FUNCTION_TRACER 使能 -pg 選項
FUNCTION_TRACE 即咱們常說的 ftrace 功能,ftrace 大大提高了內核的運行時調試能力。ftrace 功能除了 -pg 選項,還要求打開 -mfentry 選項,後者的做用是將函數對 mcount 的調用放到函數的第一條指令處,而後經過 scripts/recordmcount.pl 腳本將該條 call 指令修改成 nop 指令。但 -mfentry 與本文主題沒有關聯,再也不細說。
爲了驗證這個結論,咱們回到上一節的用戶態例子,而且增長了 -pg 編譯選項:「gcc test.c -o test -O2 -pg」,此時運行結果果真正確了。查看其反彙編:
圖6 增長 -pg 選項後的彙編
能夠看到,每一個函數都有 call mcount 指令,並且 a 函數中將 edi 寄存器保存到 ebx 中,在 newb 函數中又保存 ebx 寄存器。爲何在增長了 call mount 指令後,會作寄存器的保存操做?咱們猜測,會不會是由於,因爲 call mount 操做至關於調用了一個未知的函數( mcount 沒有定義在同一個文件中),所以,GCC 認爲這樣未知的操做可能會污染了寄存器的數據,因此它才進行了保存現場的操做。
因而咱們去掉了 -pg 選項,手動增長了 call mount 的行爲進行驗證:在另外一個源文件 mcount.c 中增長一個函數 void mcount() { asm("nop\n"); },在 test.c 文件中增長對 mcount 函數的聲明,a 函數中增長對該函數的調用:
extern void mcount(); //聲明 mcount 函數 static noinline int a(int x){ int volatile tmp = b(x); // tmp = 8 ** 3 * 4 mcount(); return x + tmp; // return 4(not 8) + tmp }
通過編譯:gcc test.c mcount.c -O2 後運行,發現計算結果正確,並且反彙編中 a 函數保存了寄存器:
圖7 調用 mcount 函數後的彙編
繼續驗證猜測,將 mcount 函數放在 test.c 文件中,計算結果錯誤,並且,反彙編中沒有保存寄存器,因而咱們獲得了這樣的猜測結論:
通過咱們的探索和資料的查閱,發現了這個 -fipa-ra 選項,能夠說它是優化的幕後主使。GCC 手冊中給出 -fipa-ra 選項的解釋是:
這裏主要是說,若是開啓這個選項,那麼,callee 中若是沒有使用到 caller 使用的寄存器,就沒有必要保存這些寄存器,前提是,callee 與 caller 在同一個編譯單元中並且 callee 函數比 caller 先被編譯,這樣纔可能出現前面的優化。若是開啓了 -O2 及以上的編譯優化選項,則會使能 -fipa-ra 選項,然而,若是開啓了 -p 或者 -pg 這些選項,或者,沒法明確 callee 所使用的寄存器,-fipa-ra 選項會被禁用。
這段話,其實已經能 cover 掉咱們前面大部分猜測的測試驗證:
用過 ftrace 或者內核開發者應該對 notrace 屬性不陌生,內核中有一些被 notrace 修飾的函數。notrace 其實就是給函數增長 no_instrument_function 屬性。例如,在 X86 的定義:
#define notrace __attribute__((no_instrument_function))
字面上來看,notrace 和 -pg 的含義能夠說徹底對立,-pg 讓 jump 變得安全,是否又會在 notrace 上栽一個跟斗呢?幸運的是,咱們接下來將看到,notrace 僅僅是禁止了 instrument function,而沒有破壞安全性。
gcc 手冊中的 -pg 選項給出這樣的解釋:
這裏主要是說,加上 notrace 屬性的函數,不會產生調用 mcount 的行爲,那麼,是否意味着再也不保護寄存器現場,換句話說,notrace 的出現是否會繞過「-pg 選項對 -fipa-ra 優化的屏蔽」?因而咱們又增長 notrace 屬性進行驗證:在 a 函數中增長 notrace 的屬性,由於 a 函數是 caller,編譯時開啓 -pg 選項,而後檢查計算結果及反彙編,最後發現,計算結果正確,並且彙編代碼中保存了寄存器現場。
圖8 給 a 函數追加 notrace 屬性,a 函數沒有調用 mcount 的行爲
咱們又對全部的函數追加了 notrace 屬性,計算結果正確且寄存器現場被保護。可是這些簡單的驗證不足以證實,因而咱們經過閱讀 GCC 源碼發現:
圖9 -pg 能禁用 -fipa_ra 選項
圖10 gcc 處理每個函數時都會檢查 -fipa-rq 選項,若是爲 false,則不對函數進行優化
經過源碼閱讀,能夠肯定的是,當使用了 -pg 選項後,會禁用 -fipa-rq 優化選項,GCC 檢查每個函數的時候都會檢查該選項,若是爲 false,則不會對該函數進行優化。
因爲 flag_ipa_ra 是一個全局選項,並非函數粒度的,notrace 也無能爲力。所以,這裏能夠排除對 notrace 的顧慮。
通過上述的探索分析以及官方資料的查閱,咱們能夠得出結論:
論據:
經過翻閱手冊得知,ARMv8 ABI 中對過程調用時通用寄存器的使用準則以下:
(資料來源:https://developer.arm.com/doc...):
These are used to pass parameters to a function and to return a result. They can be used as scratch registers or as caller-saved register variables that can hold intermediate values within a function, between calls to other functions. The fact that 8 registers are available for passing parameters reduces the need to spill parameters to the stack when compared with AArch32.
If the caller requires the values in any of these registers to be preserved across a call to another function, the caller must save the affected registers in its own stack frame. They can be modified by the called subroutine without the need to save and restore them before returning to the caller.
These registers are saved in the callee frame. They can be modified by the called subroutine as long as they are saved and restored before returning.
Figure 9.1 shows the 64-bit X registers. For more information on registers, see . For information on floating-point parameters, see Floating-point parameters.
Figure 9.1. General-purpose register use in the ABI
可見,ARMv8 ABI 中對函數調用時的寄存器使用有了明確的規定。
咱們對於前面 x86-64 下的探索驗證過程在 arm64 平臺下從新作了測試,相同的代碼和相同的測試過程,得出的結論和 x86-64 下的結論是一致的,即,在 arm64 下,直接利用 jump 指令實現函數替換一樣是安全的。
對於 C 語言而言,在不一樣的架構和系統下都有固定的 ABI 和 calling conventions,可是其它的語言不能保證,好比 rust 語言,rust 自身並無固定的 ABI,好比社區對 rust 定義 ABI 的討論,並且 rustc 編譯器的優化和 gcc 可能會有不一樣,所以可能也會出現上述 caller/callee-save 寄存器的問題。
kpatch 利用的是 ftrace 進行函數替換的,它的原理以下所示:
圖11 kpatch 利用 ftrace 替換函數
ftrace 的主要做用是用來作 trace 的,會在函數頭部或者尾部 hook 一個函數進行一些額外的處理,這些函數在運行過程當中可能會污染被 trace 的函數的寄存器上下文,所以 ftrace 定義了一個 trampoline 進行寄存器的保存和恢復操做(圖11 中的紅框),這樣從 hook 函數回來後,寄存器現場仍然是原來的模樣。
kpatch 用 ftrace 進行函數替換,hook 的函數是 kpatch 中的函數,該函數的做用是修改 regs 中的 ip 字段的值,也就是將新函數的地址給到了 ip 字段,等 trampoline 恢復寄存器現場後,就直接跳轉到新的函數函數去執行了。因此,對於 kpatch 而言,ftrace 的保存和恢復現場操做保護的是 kpatch 中修改 ip 字段函數的過程,而不是它要替換的新函數。
若是修復的是一個熱函數,那麼 ftrace 的 trampoline 會對性能產生必定的影響。因此,若考慮到性能的場景,那麼使用 jump 指令直接替換函數能夠很大的減小額外的性能開銷。
鄧二偉(扶風),2020 年就任於阿里雲操做系統內核研發團隊,目前從事 linux 內核研發工做。
吳一昊(丁緩),2017 年加入阿里雲操做系統團隊,主要經歷有資源隔離、熱升級、調度器 SLI 等。
陳善佩(雛雁),高級技術專家,興趣方向包括:體系結構、調度器、虛擬化、內存管理。
討論這麼熱烈,怎麼能少了組織沉澱?
Cloud Kernel SIG 盛情邀請你的加入
雲內核 (Cloud Kernel)是一款定製優化版的內核產品,在 Cloud Kernel 中實現了若干針對雲基礎設施和產品而優化的特性和改進功能,旨在提升雲端和雲下客戶的使用體驗。與其餘 Linux 內核產品相似,Cloud Kernel 理論上能夠運行於幾乎全部常見的 Linux 發行版中。
在 2020 年,雲內核項目加入 OpenAnolis 社區你們庭,OpenAnolis 是一個開源操做系統社區及系統軟件創新平臺,致力於經過開放的社區合做,推進軟硬件及應用生態繁榮發展,共同構建雲計算系統技術底座。
打開釘釘掃一掃哦