eBPF Verifier 內存越界實例分析
更多內核安全、eBPF 分析和實踐文章,請關注博客:
https://kernel-security.blog.csdn.net/
eBPF 基礎架構
eBPF 程序分爲兩部分: 用戶態和內核態代碼。
eBPF 內核代碼:
-
這個代碼首先需要經過編譯器(比如 LLVM)編譯成 eBPF 字節碼,然後字節碼會被加載到內核執行。所以 這部分代碼理論上用什麼語言編寫都可以,只要編譯器支持將該語言編譯爲 eBPF 字節碼即可;
-
目前絕大多數工具都是用的 C 語言來編寫 eBPF 內核代碼,包括 BCC。bpftrace 提供了一種易用的腳本語言來幫助用戶快速高效的使用 eBPF 功能,其背後的原理還是利用 LLVM 將腳本轉爲 eBPF 字節碼;
eBPF 用戶態代碼:
-
這部分代碼負責將 eBPF 內核程序加載到內核,與 eBPF MAP 交互,以及接收 eBPF 內核程序發送出來的數據;
-
這個功能的本質上是通過 Linux OS 提供的 syscall(bpf syscall + perf_event_open syscall)完成的,因此這 部分代碼你可以用任何語言實現。比如 BCC 使用 python,libbpf 使用 c 或者 c++,TRACEE 使用 Go 等等;
eBPF 數據源
性能分析大師 Brendan Gregg(Intel Fellow) 總結的 Linux BPF Tracing Tools 上展示了豐富多彩的 eBPF 鉤子類型,這些鉤子類型提供了可以加載 BPF 程序的範圍。
-
fentry/fexit
-
Tracepoints
-
network devices (tc/xdp)
-
network routes
-
TCP congestion algorithms
-
sockets (data level)
-
kernel functions (kprobes)
-
userspace functions (uprobes)
-
system calls
eBPF 框架的發展歷程
-
2014 年 9 月 引入了 bpf() syscall,將 eBPF 引入用戶態空間。自帶迷你 libbpf 庫,簡單對 bpf() 進行了封裝,功能是將 eBPF 字節碼加載到內核。
-
2015 年 2 月份 Kernel 3.19 引入 bpf_load.c/h 文件,對上述迷你 libbpf 庫再進行封裝,功能是將 eBPF elf 二進制文件加載到內核(目前已過時,不建議使用)。
-
2015 年 4 月 BCC 項目創建,提供了 eBPF 一站式編程。
-
創建之初,基於上述迷你 libbpf 庫來加載 eBPF 字節碼。
-
提供了 Python 接口。
- 2015 年 11 月 Kernel 4.3 引入標準庫 libbpf
該標準庫由 Huawei 2012 OS 內核實驗室的王楠提交。
- 2018 年 爲解決 BCC 的缺陷,CO-RE(Compile Once, Run Everywhere)的想法被提出並實現,最後達成共識:libbpf + BTF + CO-RE 代表了 eBPF 的未來,BCC 底層實現逐步轉向 libbpf。
eBPF 可移植性痛點和解決方案
在內核版本 A 上編譯的 eBPF 程序,無法直接在另外一個內核版本 B 上運行。造成可執行差的根本原因在於 eBPF 程序訪問的內核數據結構 (內存空間)是不穩定的,經常隨內核版本更迭而變化。
目前使用 BCC 的方案通過在部署機器上動態編譯 eBPF 源代碼可以來解決移植性問題。每一次 eBPF 程序運行都需要進行一次編譯,而且需要在部署機器上按照上百兆大小的依賴,如編譯器和頭文件 Clang/LLVM + Linux headers 等。同時在 Clang/LLVM 編譯過程中需要消耗大量的資源(CPU / 內存),對業務性能也會造成很大影響。
解決方案(CO-RE Compile Once,Run Everywhere):
1)BTF:將內核數據結構信息高效壓縮和存儲(相比於 DWARF,可達到超過 100 倍的 壓縮比)
2)LLVM/Clang 編譯器:編譯 eBPF 代碼的時候記錄下 relocation 相關的信息
3)Libbpf:基於 BTF 和編譯器提供的信息,動態 relocate 數據結構
其中 BTF 爲重要組成部分,Linux Kernel 5.2 及以上版本自帶 BTF 文件,低版本需要手動移植。通過分析內核源碼,可以發現 BTF 文件的生成並不需要改動內核,只依賴:
-
帶有 debug info 的 vmlinux image
-
pahole
-
LLVM
這意味着,我們可以自己爲低版本內核生產 BTF 文件,以此讓低內核版本支持 CORE。
eBPF 程序實例分析
eBPF 程序會被 LLVM 編譯爲 eBPF 字節碼,eBPF 字節碼需要通過 eBPF Verifier 的 (靜態) 驗證後,才能真正運行。邊界檢查是 eBPF Verifier 的重點工作,目的是爲了防止 eBPF 程序內存越界訪問。
接下來通過在 eBPF 程序中簡單的增加、刪減 print 打印信息觸發不同原因的幾種邊界檢查異常導致驗證失敗的例子,進一步講解深層的原理。
程序實驗環境:
1)LLVM 11
2)Linux Kernel 5.8
3)Libbpf commit @9c44c8a
1) 內存越界:
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
// 獲取一個數組指針array(數組MAX_SIZE爲16個字節)
u32 key = 0;
char *array = bpf_map_lookup_elem(&array_map, &key);
if (array == NULL)
return 0;
// 獲取當前運行程序的CPU編號(當前機器的CPU有16個核)
unsigned int pos = bpf_get_smp_processor_id();
// 根據下表修改數組的值
array[pos] = 1;
return 0;
}
上述代碼編譯運行後,提示 Verifier 失敗,然後使用 objdump 命令來看一下具體的字節碼,通過以下字節碼程序,可以看到 Verifier 失敗的原因在於第 14 行 R6 寄存器 (變量 pos) 沒有進行邊界檢查導致。
Root Cause:
- 當 eBPF Verifier 走到第 14 行的時候嘗試去訪問 array 數組,但是此時數組的下標 pos 是來自 bpf_get_smp_processor_id 獲取到的 unsigned int 類型的動態變量,此時 Verifier 無法判斷變量的具體數值,所以會保守認爲可能會達到最大值,這樣的話就會超出 array 數組的範圍,造成內存越界。
0000000000000000 <do_unlinkat>:
; int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name) 0: r1 = 0
; u32 key = 0;
1: *(u32 *)(r10 - 4) = r1
2: r2 = r10
3: r2 += -4
; char *array = bpf_map_lookup_elem(&array_map, &key); 4: r1 = 0 ll
6: call 1
7: r6 = r0
; if (array == NULL)
8: if r6 == 0 goto +6 <LBB0_2>
; unsigned int pos = bpf_get_smp_processor_id();; 9: call 8
; array[pos] = 1; 10: r0 <<= 32
11: r0 >>= 32
12: r6 += r0
13: r1 = 1
; array[pos] = 1;
14: *(u8 *)(r6 + 0) = r1
添加邊界檢查代碼
if (pos < MAX_SIZE)
if r0 > 15 goto +3 <LBB0_3>
2)Verifier 驗證機制和編譯器優化機制不一致導致邊界檢查不通過
① 使用錯誤寄存器做邊界檢查:
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
// 獲取一個數組指針array(數組MAX_SIZE爲16個字節)
u32 key = 0;
char *array = bpf_map_lookup_elem(&array_map, &key); if (array == NULL)
return 0;
// 獲取當前運行程序的CPU編號(當前機器的CPU有16個核)
unsigned int pos = bpf_get_smp_processor_id();;
// 修改數值
if (pos < MAX_SIZE){
array[pos] = 1;
pos += 1;
}
// debug代碼,輸出一些上下文信息
bpf_printk("debug %d %d %d\n", bpf_get_current_pid_tgid() >> 32, bpf_get_current_pid_tgid(), array[1]);
// 修改數值
if (pos < MAX_SIZE)
array[pos] = 1;
return 0;
}
編譯這個代碼後 Verifier 驗證通過,可以正常運行。但是此時如果把 bpf_printk 打印信息刪掉,竟然提示 Verifier 驗證失敗,原因是 R0 寄存器(變量 pos)沒有通過邊界檢查,但是明明已經加了邊界檢查代碼,怎麼還會出現問題,這麼神奇!
Root Cause:
-
由於編譯器的優化策略,導致刪減 bpf_printk 後編譯生成的 eBPF 字節碼使用寄存器 r1(表示 pos 變量)來進行邊界檢查,但是卻用 r0+1(同樣表示 pos 變量)來訪問數組 array;
-
相比之下,從 eBPF verifier 的角度來看,由於在編譯過程中,r1 和 r0+1 的關聯性丟失了,導致 eBPF verifier 無法知道 pos 變量已經通過了檢查,因此錯誤的認爲 pos 變量沒有進行邊界檢查,不允許程序運行;
② 寄存器溢出或重新加載後,狀態丟失:
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
// 獲取一個數組指針array(數組MAX_SIZE爲16個字節)
u32 key = 0;
char *array = bpf_map_lookup_elem(&array_map, &key); if (array == NULL)
return 0;
// 獲取當前運行程序的CPU編號(當前機器的CPU有16個核)
unsigned long pos = bpf_get_smp_processor_id();;
// 修改數值
if (pos < MAX_SIZE){
for (unsigned long i = 0; i < MAX_SIZE; i++)
bpf_printk("debug %d %d %d\n", bpf_get_current_pid_tgid() >> 32, \
bpf_get_current_pid_tgid(), array[i]);
array[pos] = 1;
}
return 0;
}
在上述邊界檢查代碼中添加一段 print 調試打印信息後編譯驗證又會出現 Verifier 失敗,通過排查發現不是已知的兩類問題,依然使用 objdump 查看添加後的字節碼信息。
Root Cause:
-
加入 bpf_printk 後通過字節碼可以看到, 代碼先使用 R0(表示 pos 變量)進行邊界檢查。由於當前寄存器數量不足,編譯器決定將將 R0 臨時保存到棧上的空間(R10-16,在 eBPF 字節碼中,R10 存儲存放着 eBPF 棧空間的棧幀指針的地址),這樣 R0 就可以空閒出來,留給其他代碼使用,我們稱這種行爲爲寄存器溢出(register spill);
-
當真正需要使用 pos 變量的時候,編譯器會從棧上(R10-16)將之前保存的內容取出來賦給 R1(也表示 pos 變量),然後使用 R1 對數組 array 進行訪問。但神奇的是,當寄存器溢出發生時,pos 變量的狀態丟失了,eBPF 忘記了該變量曾經進行了邊界檢查,導致程序無法通過驗證;
解決方案:
在源碼中加入 &= 操作符,引導編譯器生成理想的 eBPF 字節碼
array[pos &= MAX_SIZE - 1] = 1;
如果上述方法失效,無法引導編譯器,那麼針對出錯的部分源代碼人工編寫 eBPF 字節碼,替代編譯器生成的字節碼
#define STR(s) #s #define XSTR(s) STR(s)
#define asm_variable_bound_check(variable) \
({ \
asm volatile ( \
"%[tmp] &= " XSTR(MAX_SIZE - 1) " \n" \
:[tmp]"+&r"(variable) \
); \
})
asm_check(pos);
array[pos] = 1;
總結
eBPF 作爲 Linux 內核一項革命性的技術,起源於 Linux 內核,該技術可以安全而高效地拓展內核的能力,但快速發展的同時,也會存在很多新鮮出爐的問題,給廣大開發者尤其是入門者帶來個很大的困擾,本文從幾個實例的角度來對問題進行分析和解答,有相關開發疑惑的同學可以參考借鑑。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/9_s6znt1J0ayMbNKpj9vhA