eBPF Verifier 內存越界實例分析

​更多內核安全、eBPF 分析和實踐文章,請關注博客:  

https://kernel-security.blog.csdn.net/

eBPF 基礎架構

eBPF 程序分爲兩部分: 用戶態和內核態代碼。

eBPF 內核代碼:

eBPF 用戶態代碼:

eBPF 數據源

性能分析大師 Brendan Gregg(Intel Fellow) 總結的 Linux BPF Tracing Tools 上展示了豐富多彩的 eBPF 鉤子類型,這些鉤子類型提供了可以加載 BPF 程序的範圍。

eBPF 框架的發展歷程

  1. 創建之初,基於上述迷你 libbpf 庫來加載 eBPF 字節碼。

  2. 提供了 Python 接口。

該標準庫由 Huawei 2012 OS 內核實驗室的王楠提交。

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 文件的生成並不需要改動內核,只依賴:

這意味着,我們可以自己爲低版本內核生產 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:

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:

② 寄存器溢出或重新加載後,狀態丟失:

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:

解決方案

在源碼中加入 &= 操作符,引導編譯器生成理想的 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