新四季網

內核隔離要不要打開(內核熱補丁真的安全麼)

2023-07-28 17:30:36

簡介: Linux 內核函數的熱替換「撞上」函數調用約定還靠譜嗎?

Linux 內核熱補丁可以修復正在運行的 linux 內核,是一種維持線上穩定性不可缺少的措施,現在比較常見的比如 kpatch 和 livepatch。內核熱補丁可以修復內核中正在運行的函數,用已修復的函數替換掉內核中存在問題的函數從而達到修複目的。

函數替換的思想比較簡單,就是在執行舊函數時繞開它的執行邏輯而跳轉到新的函數中,有一種比較簡單粗暴的方式,就是將原函數的第一條指令修改為「 jump 目標函數」指令,即直接跳轉到新的函數以達到替換目的。

那麼,問題來了,這麼做靠譜嗎?直接將原函數的第一條指令修改為 jump 指令,會破壞掉原函數和它的調用者之間的寄存器上下文關係,存在安全隱患!本文會針對該問題進行探索和驗證。

安全性衝擊:問題呈現

對於函數調用,假設存在這樣兩個函數 funA 和 funB,其中 funA 調用 funB 函數,這裡稱 funA 為 caller(調用者),funB 為 callee(被調用者),funA 和 funB 都使用了相同的寄存器 R,如下所示:

圖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 需要對相應的寄存器進行保存和恢復操作:

Caller-save registers : RDI, RSI, RDX, RCX, R8, R9, RAX, R10, R11Callee-save registers : RBX, RBP, R12, R13, R14, R15

調用約定,gcc 它遵守了嗎?

設問:當函數實現很簡單,只用到了少量寄存器,那沒使用到的還需要保存嗎?

答案:it depends。根據編譯選項決定。

眾所周知,GCC 編譯器有 -O0、-O1、-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 #include #include #include #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 指令將程序中的函數 b 替換為 newb 函數,即,將 y = x x 計算過程替換為 y = x (2x) ^ 3 * x;程序編譯:gcc test.c -o test -O2,這裡我們採用的是與編譯內核相同的優化選項 -O2;程序執行:./test,輸入參數:4,輸出結果:2056;程序錯誤:2056 是錯誤的結果,應該是 2052,而且直接調用 newb 函數編譯執行的結果是 2052。

該例子說明,直接使用 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 選項。如果將該場景應用到內核函數熱替換是否會出現這類問題呢?於是,我們帶著問題繼續探索。

安全性衝擊:探索問題

不再觀察到 bug

我們構造了一個內核函數熱替換的實例,將上面的用戶態的例子移植到我們構造的場景中,通過內核模塊修改原函數的代碼段,用 jump 指令直接替換原來的 b 函數。然而加載模塊後,結果是正確的 2052,經過反彙編我們發現,內核中 a 函數對 edi 寄存器進行了保存操作:

圖4 內核中 a 函數的反彙編

內核和模塊編譯時採用的是 -O2 優化選項,而此處 a 函數並沒有被優化,仍然保存了 edi 寄存器。

此時我們預測:對於內核函數的熱替換來說,使用 jump 做函數替換是安全的。

神奇的 -pg 選項

我們猜想是否是內核編譯時使用其它的編譯選項導致問題不能復現。果不其然,經過探索我們發現內核編譯使用的 -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 文件中,計算結果錯誤,而且,反彙編中沒有保存寄存器,於是我們得到了這樣的猜想結論:

GCC 在編譯某個源文件時,如果文件內的某個函數(比如場景中的函數 a)調用了其它文件中的一個未知函數(比如場景中的 mcount 函數),則 GCC 會在該函數中保存寄存器;開啟 -pg 選項,增加了對 mcount 的調用,因此會在函數中增加對寄存器現場的保存操作,對 -O2 選項的函數調用優化起到了屏蔽作用。

神秘的 -fipa-ra 選項:真正的幕後主使

經過我們的探索和資料的查閱,發現了這個 -fipa-ra 選項,可以說它是優化的幕後主使。GCC 手冊中給出 -fipa-ra 選項的解釋是:

Use caller save registers for allocation if those registers are not used by any called function. In that case it is not necessary to save and restore them around calls. This is only possible if called functions are part of same compilation unit as current function and they are compiled before it. Enabled at levels -O2, -O3, -Os, however the option is disabled if generated code will be instrumented for profiling (-p, or -pg) or if callee’s register usage cannot be known exactly (this happens on targets that do not expose prologues and epilogues in RTL).

這裡主要是說,如果開啟這個選項,那麼,callee 中如果沒有使用到 caller 使用的寄存器,就沒有必要保存這些寄存器,前提是,callee 與 caller 在同一個編譯單元中而且 callee 函數比 caller 先被編譯,這樣才可能出現前面的優化。如果開啟了 -O2 及以上的編譯優化選項,則會使能 -fipa-ra 選項,然而,如果開啟了 -p 或者 -pg 這些選項,或者,無法明確 callee 所使用的寄存器,-fipa-ra 選項會被禁用。

這段話,其實已經能 cover 掉我們前面大部分猜想的測試驗證:

-O2 選項自動使能 -fipa-ra 進行優化:在我們的場景中,函數 a 使用的 edi 寄存器,在函數 b 中沒有使用到,因此函數 a 被優化,沒有保存 edi 寄存器,但是在 newb 函數中,使用到了 edi 寄存器,且數據被修改,將 newb 函數替換函數 b,則計算結果出錯;在 -O2 中使用 -pg 選項會禁用 -fipa-ra:編譯時使用 -pg 選項,計算結果是正確的,而且函數 a 保存了 edi 寄存器,說明沒有對函數 a 進行優化;不在同一編譯單元不會被優化:去掉 -pg 選項,在函數 a 中手動調用 mcount 函數,將這個函數放在 test.c(與函數 a 為同一編譯單元)與放在另一個文件 mcount.c(不同編譯單元)中的計算結果不同:同一編譯單元中計算結果是錯誤的,而且函數 a 沒有保存寄存器現場;不在同一編譯單元中,計算結果是正確的,函數 a(caller) 保存了寄存器現場,因為編譯器無法明確函數 b(callee)所使用的寄存器。

notrace:它是二度衝擊嗎?

用過 ftrace 或者內核開發者應該對 notrace 屬性不陌生,內核中有一些被 notrace 修飾的函數。notrace 其實就是給函數增加 no_instrument_function 屬性。例如,在 X86 的定義:

#define notrace __attribute__((no_instrument_function))

字面上來看,notrace 和 -pg 的含義可以說完全對立,-pg 讓 jump 變得安全,是否又會在 notrace 上栽一個跟鬥呢?幸運的是,我們接下來將看到,notrace 僅僅是禁止了 instrument function,而沒有破壞安全性

gcc 手冊中的 -pg 選項給出這樣的解釋:

Generate extra code to write profile information suitable for the analysis program prof (for -p) or gprof (for -pg). You must use this option when compiling the source files you want data about, and you must also use it when linking. You can use the function attribute no_instrument_function to suppress profiling of individual functions when compiling with these options.

這裡主要是說,加上 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 的顧慮。

安全性保障:得出結論

經過上述的探索分析以及官方資料的查閱,我們可以得出結論:

內核函數的熱替換,利用 jump 指令直接跳轉到新函數的方式是安全的;論據:Linux 遵循的 System V ABI 中的 call conversion 在 x86-64 下有且只有一種;GCC -fipa-ra 選項會對 call conversion 進行優化,-O2 選項會自動使能該選項,但是 -pg 選項會禁用 -fipa-ra 優化選項;notrace 屬性無法繞過「 -pg 禁用 -fipa-ra」。ARM64 下的探索驗證

通過翻閱手冊得知,ARMv8 ABI 中對過程調用時通用寄存器的使用準則如下

(資料來源:https://developer.arm.com/documentation/den0024/a/The-ABI-for-ARM-64-bit-Architecture/Register-use-in-the-AArch64-Procedure-Call-Standard/Parameters-in-general-purpose-registers):

Argument registers (X0-X7)

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.

Caller-saved temporary registers (X9-X15)

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.

Callee-saved registers (X19-X29)

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.

Registers with a special purpose (X8, X16-X18, X29, X30)

X8 is the indirect result register. This is used to pass the address location of an indirect result, for example, where a function returns a large structure.X16 and X17 are IP0 and IP1, intra-procedure-call temporary registers. These can be used by call veneers and similar code, or as temporary registers for intermediate values between subroutine calls. They are corruptible by a function. Veneers are small pieces of code which are automatically inserted by the linker, for example when the branch target is out of range of the branch instruction.X18 is the platform register and is reserved for the use of platform ABIs. This is an additional temporary register on platforms that don't assign a special meaning to it.X29 is the frame pointer register (FP).X30 is the link register (LR).

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 的真面目

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 是一個開源作業系統社區及系統軟體創新平臺,致力於通過開放的社區合作,推動軟硬體及應用生態繁榮發展,共同構建雲計算系統技術底座。

「連結」

本文為阿里雲原創內容,未經允許不得轉載。

,
同类文章
葬禮的夢想

葬禮的夢想

夢見葬禮,我得到了這個夢想,五個要素的五個要素,水火只好,主要名字在外面,職業生涯良好,一切都應該對待他人治療誠意,由於小,吉利的冬天夢想,秋天的夢是不吉利的
找到手機是什麼意思?

找到手機是什麼意思?

找到手機是什麼意思?五次選舉的五個要素是兩名士兵的跡象。與他溝通很好。這是非常財富,它擅長運作,職業是仙人的標誌。單身男人有這個夢想,主要生活可以有人幫忙
我不怎麼想?

我不怎麼想?

我做了什麼意味著看到米飯烹飪?我得到了這個夢想,五線的主要土壤,但是Tu Ke水是錢的跡象,職業生涯更加真誠。他真誠地誠實。這是豐富的,這是夏瑞的巨星
夢想你的意思是什麼?

夢想你的意思是什麼?

你是什​​麼意思夢想的夢想?夢想,主要木材的五個要素,水的跡象,主營業務,主營業務,案子應該抓住魅力,不能疏忽,春天夢想的吉利夢想夏天的夢想不幸。詢問學者夢想
拯救夢想

拯救夢想

拯救夢想什麼意思?你夢想著拯救人嗎?拯救人們的夢想有一個現實,也有夢想的主觀想像力,請參閱週宮官方網站拯救人民夢想的詳細解釋。夢想著敵人被拯救出來
2022愛方向和生日是在[質量個性]中

2022愛方向和生日是在[質量個性]中

[救生員]有人說,在出生88天之前,胎兒已經知道哪天的出生,如何有優質的個性,將走在什麼樣的愛情之旅,將與生活生活有什么生活。今天
夢想切割剪裁

夢想切割剪裁

夢想切割剪裁什麼意思?你夢想切你的手是好的嗎?夢想切割手工切割手有一個真正的影響和反應,也有夢想的主觀想像力。請參閱官方網站夢想的細節,以削減手
夢想著親人死了

夢想著親人死了

夢想著親人死了什麼意思?你夢想夢想你的親人死嗎?夢想有一個現實的影響和反應,還有夢想的主觀想像力,請參閱夢想世界夢想死亡的親屬的詳細解釋
夢想搶劫

夢想搶劫

夢想搶劫什麼意思?你夢想搶劫嗎?夢想著搶劫有一個現實的影響和反應,也有夢想的主觀想像力,請參閱週恭吉夢官方網站的詳細解釋。夢想搶劫
夢想缺乏缺乏紊亂

夢想缺乏缺乏紊亂

夢想缺乏缺乏紊亂什麼意思?你夢想缺乏異常藥物嗎?夢想缺乏現實世界的影響和現實,還有夢想的主觀想像,請看官方網站的夢想組織缺乏異常藥物。我覺得有些東西缺失了