eBPF 校驗器

eBPF 程式的安全性分兩步確定。

第一步進行 DAG 檢查以禁止迴圈和其他 CFG 驗證。特別是,它會檢測包含不可達指令的程式。(儘管經典 BPF 檢查器允許它們)

第二步從第一條指令開始,並遍歷所有可能的路徑。它模擬每條指令的執行並觀察暫存器和堆疊的狀態變化。

在程式開始時,暫存器 R1 包含一個指向上下文的指標,其型別為 PTR_TO_CTX。如果校驗器看到一條執行 R2=R1 的指令,那麼 R2 現在也具有 PTR_TO_CTX 型別,並且可以用於表示式的右側。如果 R1=PTR_TO_CTX 且指令為 R2=R1+R1,則 R2=SCALAR_VALUE,因為兩個有效指標的相加會產生無效指標。(在“安全”模式下,校驗器將拒絕任何型別的指標算術,以確保核心地址不會洩露給非特權使用者)

如果暫存器從未被寫入,則不可讀

bpf_mov R0 = R2
bpf_exit

將被拒絕,因為在程式開始時 R2 是不可讀的。

在核心函式呼叫之後,R1-R5 被重置為不可讀,R0 具有函式的返回型別。

由於 R6-R9 是被呼叫者儲存的,它們的狀態在呼叫後得以保留。

bpf_mov R6 = 1
bpf_call foo
bpf_mov R0 = R6
bpf_exit

是一個正確的程式。如果這裡是 R1 而不是 R6,它就會被拒絕。

載入/儲存指令只允許使用有效型別的暫存器,即 PTR_TO_CTX、PTR_TO_MAP、PTR_TO_STACK。它們會進行邊界和對齊檢查。例如

bpf_mov R1 = 1
bpf_mov R2 = 2
bpf_xadd *(u32 *)(R1 + 3) += R2
bpf_exit

將被拒絕,因為在執行 bpf_xadd 指令時 R1 沒有有效的指標型別。

開始時,R1 的型別是 PTR_TO_CTX(指向通用 struct bpf_context 的指標)。回撥函式用於自定義校驗器,以限制 eBPF 程式對 ctx 結構中具有指定大小和對齊的某些欄位的訪問。

例如,以下指令

bpf_ld R0 = *(u32 *)(R6 + 8)

打算從地址 R6 + 8 載入一個字並將其儲存到 R0。如果 R6=PTR_TO_CTX,校驗器將透過 is_valid_access() 回撥知道偏移量 8(大小 4 位元組)可以被讀取,否則校驗器將拒絕該程式。如果 R6=PTR_TO_STACK,則訪問應該對齊並在堆疊邊界內,即 [-MAX_BPF_STACK, 0)。在此示例中,偏移量為 8,因此它將失敗驗證,因為它超出邊界。

校驗器將只允許 eBPF 程式在寫入堆疊後從堆疊讀取資料。

經典 BPF 校驗器對 M[0-15] 記憶體槽進行類似檢查。例如

bpf_ld R0 = *(u32 *)(R10 - 4)
bpf_exit

是無效程式。儘管 R10 是正確的只讀暫存器,並且型別為 PTR_TO_STACK,R10 - 4 在堆疊邊界內,但該位置沒有進行儲存操作。

指標暫存器溢位/填充也會被跟蹤,因為四個(R6-R9)被呼叫者儲存的暫存器可能不足以滿足某些程式的需求。

允許的函式呼叫透過 bpf_verifier_ops->get_func_proto() 進行自定義。eBPF 校驗器將檢查暫存器是否符合引數約束。呼叫後,暫存器 R0 將被設定為函式的返回型別。

函式呼叫是擴充套件 eBPF 程式功能的主要機制。套接字過濾器可能允許程式呼叫一組函式,而跟蹤過濾器可能允許完全不同的一組。

如果函式可供 eBPF 程式訪問,則需要從安全形度進行周密考慮。校驗器將確保函式使用有效引數進行呼叫。

seccomp 與套接字過濾器對經典 BPF 有不同的安全限制。Seccomp 透過兩階段校驗器解決此問題:經典 BPF 校驗器之後是 seccomp 校驗器。在 eBPF 的情況下,一個可配置的校驗器被所有用例共享。

有關 eBPF 校驗器的詳細資訊,請參見 kernel/bpf/verifier.c

暫存器值跟蹤

為了確定 eBPF 程式的安全性,校驗器必須跟蹤每個暫存器以及每個堆疊槽中可能值的範圍。這是透過定義在 include/linux/bpf_verifier.h 中的 struct bpf_reg_state 完成的,它統一了標量和指標值的跟蹤。每個暫存器狀態都有一個型別,要麼是 NOT_INIT(暫存器尚未寫入),SCALAR_VALUE(無法用作指標的某個值),要麼是指標型別。指標的型別描述它們的基址,如下所示:

PTR_TO_CTX

指向 bpf_context 的指標。

CONST_PTR_TO_MAP

指向 struct bpf_map 的指標。“Const”是因為禁止對這些指標進行算術運算。

PTR_TO_MAP_VALUE

指向對映元素中儲存的值的指標。

PTR_TO_MAP_VALUE_OR_NULL

要麼是指向對映值的指標,要麼是 NULL;對映訪問(參見 BPF 對映)返回此型別,當檢查 != NULL 時,此型別變為 PTR_TO_MAP_VALUE。禁止對這些指標進行算術運算。

PTR_TO_STACK

幀指標。

PTR_TO_PACKET

skb->data。

PTR_TO_PACKET_END

skb->data + headlen;禁止算術運算。

PTR_TO_SOCKET

指向 struct bpf_sock_ops 的指標,隱式引用計數。

PTR_TO_SOCKET_OR_NULL

要麼是指向套接字的指標,要麼是 NULL;套接字查詢返回此型別,當檢查 != NULL 時,此型別變為 PTR_TO_SOCKET。PTR_TO_SOCKET 是引用計數的,因此程式必須在程式結束前透過套接字釋放函式釋放引用。禁止對這些指標進行算術運算。

然而,指標可能相對於其基址有偏移量(作為指標算術的結果),這分為兩部分進行跟蹤:“固定偏移量”和“可變偏移量”。前者用於將一個精確已知的值(例如立即數運算元)新增到指標時,而後者用於不精確已知的值。可變偏移量也用於 SCALAR_VALUEs,以跟蹤暫存器中可能值的範圍。

校驗器對可變偏移量的瞭解包括

  • 無符號的最小值和最大值

  • 有符號的最小值和最大值

  • 關於各個位值的知識,以“tnum”的形式:一個 u64 的“mask”和一個 u64 的“value”。mask 中的 1 表示未知其值的位;value 中的 1 表示已知為 1 的位。已知為 0 的位在 mask 和 value 中都為 0;任何位都不應該在兩者中都為 1。例如,如果從記憶體中讀取一個位元組到暫存器中,暫存器的最高 56 位已知為零,而最低 8 位未知——這表示為 tnum (0x0; 0xff)。如果我們然後將其與 0x40 進行 OR 運算,我們得到 (0x40; 0xbf),然後如果我們加 1,我們得到 (0x0; 0x1ff),因為存在潛在的進位。

除了算術運算,暫存器狀態也可以透過條件分支更新。例如,如果一個 SCALAR_VALUE 被比較 > 8,在“true”分支中它將具有 umin_value(無符號最小值)9,而在“false”分支中它將具有 umax_value 8。有符號比較(使用 BPF_JSGT 或 BPF_JSGE)將改為更新有符號最小值/最大值。來自有符號和無符號邊界的資訊可以組合;例如,如果一個值首先測試 < 8,然後測試 s> 4,校驗器將得出結論,該值也 > 4 且 s< 8,因為邊界阻止跨越符號邊界。

具有可變偏移部分的 PTR_TO_PACKET 具有一個“id”,所有共享相同可變偏移的指標都具有該 id。這對於資料包範圍檢查很重要:在將變數新增到資料包指標暫存器 A 後,如果您將其複製到另一個暫存器 B,然後向 A 新增一個常量 4,則兩個暫存器將共享相同的“id”,但 A 將具有 +4 的固定偏移量。然後,如果對 A 進行邊界檢查並發現其小於 PTR_TO_PACKET_END,則暫存器 B 現在已知具有至少 4 位元組的安全範圍。有關 PTR_TO_PACKET 範圍的更多資訊,請參閱下面的“直接資料包訪問”。

“id”欄位也用於 PTR_TO_MAP_VALUE_OR_NULL,所有從對映查詢返回的指標副本都具有此 id。這意味著當一個副本被檢查並發現非 NULL 時,所有副本都可以變為 PTR_TO_MAP_VALUEs。除了範圍檢查,跟蹤資訊還用於強制執行指標訪問的對齊。例如,在大多數系統上,資料包指標在 4 位元組對齊後是 2 位元組。如果程式向其新增 14 位元組以跳過乙太網頭,然後讀取 IHL 並新增 (IHL * 4),則結果指標將具有已知為 4n+2(對於某些 n)的可變偏移量,因此新增 2 位元組 (NET_IP_ALIGN) 會給出 4 位元組對齊,因此透過該指標的字大小訪問是安全的。“id”欄位也用於 PTR_TO_SOCKET 和 PTR_TO_SOCKET_OR_NULL,所有從套接字查詢返回的指標副本都具有此 id。這與 PTR_TO_MAP_VALUE_OR_NULL->PTR_TO_MAP_VALUE 的處理方式類似,但它也處理指標的引用跟蹤。PTR_TO_SOCKET 隱式表示對相應 struct sock 的引用。為確保引用不洩漏,必須對引用進行 NULL 檢查,並在非 NULL 情況下,將有效引用傳遞給套接字釋放函式。

直接資料包訪問

在 cls_bpf 和 act_bpf 程式中,校驗器允許透過 skb->data 和 skb->data_end 指標直接訪問資料包資料。例如:

1:  r4 = *(u32 *)(r1 +80)  /* load skb->data_end */
2:  r3 = *(u32 *)(r1 +76)  /* load skb->data */
3:  r5 = r3
4:  r5 += 14
5:  if r5 > r4 goto pc+16
R1=ctx R3=pkt(id=0,off=0,r=14) R4=pkt_end R5=pkt(id=0,off=14,r=14) R10=fp
6:  r0 = *(u16 *)(r3 +12) /* access 12 and 13 bytes of the packet */

從資料包載入這 2 位元組是安全的,因為程式作者在指令 #5 處進行了檢查 if (skb->data + 14 > skb->data_end) goto err,這意味著在透過的情況下,暫存器 R3(指向 skb->data)至少有 14 個可直接訪問的位元組。校驗器將其標記為 R3=pkt(id=0,off=0,r=14)。id=0 意味著沒有向暫存器新增額外的變數。off=0 意味著沒有新增額外的常量。r=14 是安全訪問的範圍,這意味著位元組 [R3, R3 + 14) 是安全的。請注意,R5 被標記為 R5=pkt(id=0,off=14,r=14)。它也指向資料包資料,但向暫存器添加了常量 14,因此它現在指向 skb->data + 14,可訪問範圍是 [R5, R5 + 14 - 14),即零位元組。

更復雜的資料包訪問可能如下所示:

R0=inv1 R1=ctx R3=pkt(id=0,off=0,r=14) R4=pkt_end R5=pkt(id=0,off=14,r=14) R10=fp
6:  r0 = *(u8 *)(r3 +7) /* load 7th byte from the packet */
7:  r4 = *(u8 *)(r3 +12)
8:  r4 *= 14
9:  r3 = *(u32 *)(r1 +76) /* load skb->data */
10:  r3 += r4
11:  r2 = r1
12:  r2 <<= 48
13:  r2 >>= 48
14:  r3 += r2
15:  r2 = r3
16:  r2 += 8
17:  r1 = *(u32 *)(r1 +80) /* load skb->data_end */
18:  if r2 > r1 goto pc+2
R0=inv(id=0,umax_value=255,var_off=(0x0; 0xff)) R1=pkt_end R2=pkt(id=2,off=8,r=8) R3=pkt(id=2,off=0,r=8) R4=inv(id=0,umax_value=3570,var_off=(0x0; 0xfffe)) R5=pkt(id=0,off=14,r=14) R10=fp
19:  r1 = *(u8 *)(r3 +4)

暫存器 R3 的狀態為 R3=pkt(id=2,off=0,r=8)。id=2 意味著看到了兩條 r3 += rX 指令,因此 r3 指向資料包內的某個偏移量,並且由於程式作者在指令 #18 處執行了 if (r3 + 8 > r1) goto err,因此安全範圍是 [R3, R3 + 8)。校驗器只允許對資料包暫存器執行“加”/“減”操作。任何其他操作都將把暫存器狀態設定為“SCALAR_VALUE”,並且它將無法用於直接資料包訪問。

操作 r3 += rX 可能會溢位並變得小於原始 skb->data,因此校驗器必須防止這種情況。所以當它看到 r3 += rX 指令並且 rX 大於 16 位值時,任何後續對 r3 與 skb->data_end 的邊界檢查都不會給我們“範圍”資訊,因此嘗試透過指標讀取將導致“無效訪問資料包”錯誤。

例如,在指令 r4 = *(u8 *)(r3 +12)(上文指令 #7)之後,r4 的狀態是 R4=inv(id=0,umax_value=255,var_off=(0x0; 0xff)),這意味著暫存器的高 56 位保證為零,而低 8 位則未知。在指令 r4 *= 14 之後,狀態變為 R4=inv(id=0,umax_value=3570,var_off=(0x0; 0xfffe)),因為將一個 8 位值乘以常量 14 會使高 52 位保持為零,同時最低有效位也將為零,因為 14 是偶數。類似地,r2 >>= 48 將使 R2=inv(id=0,umax_value=65535,var_off=(0x0; 0xffff)),因為移位不是符號擴充套件。此邏輯在 adjust_reg_min_max_vals() 函式中實現,該函式呼叫 adjust_ptr_min_max_vals() 用於將指標新增到標量(反之亦然),並呼叫 adjust_scalar_min_max_vals() 用於對兩個標量進行操作。

最終結果是 bpf 程式作者可以使用普通的 C 程式碼直接訪問資料包,如下所示:

void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
struct eth_hdr *eth = data;
struct iphdr *iph = data + sizeof(*eth);
struct udphdr *udp = data + sizeof(*eth) + sizeof(*iph);

if (data + sizeof(*eth) + sizeof(*iph) + sizeof(*udp) > data_end)
        return 0;
if (eth->h_proto != htons(ETH_P_IP))
        return 0;
if (iph->protocol != IPPROTO_UDP || iph->ihl != 5)
        return 0;
if (udp->dest == 53 || udp->source == 9)
        ...;

這使得此類程式比 LD_ABS 指令更容易編寫,並且速度顯著提高。

剪枝

校驗器實際上不會遍歷程式的所有可能路徑。對於每個要分析的新分支,校驗器會檢視它在當前指令處之前所處的所有狀態。如果其中任何一個狀態包含當前狀態作為子集,則該分支被“剪枝”——也就是說,之前狀態被接受的事實意味著當前狀態也會被接受。例如,如果在之前的狀態中,r1 持有一個數據包指標,而在當前狀態中,r1 持有一個數據包指標,其範圍更長或更長,並且對齊方式至少一樣嚴格,那麼 r1 是安全的。類似地,如果 r2 之前是 NOT_INIT,那麼從那時起它就不能被任何路徑使用,因此 r2 中的任何值(包括另一個 NOT_INIT)都是安全的。該實現在函式 regsafe() 中。剪枝不僅考慮暫存器,還考慮堆疊(以及它可能持有的任何溢位暫存器)。它們都必須是安全的,才能進行分支剪枝。這在 states_equal() 中實現。

有關狀態剪枝實現的一些技術細節可在下文找到。

暫存器活躍度跟蹤

為了使狀態剪枝有效,會跟蹤每個暫存器和堆疊槽的活躍度狀態。基本思想是跟蹤在程式後續執行中實際使用了哪些暫存器和堆疊槽,直到程式退出。從未使用的暫存器和堆疊槽可以從快取狀態中移除,從而使更多狀態等同於快取狀態。這可以透過以下程式說明:

0: call bpf_get_prandom_u32()
1: r1 = 0
2: if r0 == 0 goto +1
3: r0 = 1
--- checkpoint ---
4: r0 = r1
5: exit

假設在指令 #4 處建立了一個狀態快取條目(此類條目在下文中也稱為“檢查點”)。校驗器可以透過兩種可能的暫存器狀態之一到達該指令:

  • r0 = 1, r1 = 0

  • r0 = 0, r1 = 0

然而,只有暫存器 r1 的值對於成功完成驗證是重要的。活躍度跟蹤演算法的目標是發現這個事實並找出這兩種狀態實際上是等價的。

資料結構

活躍度使用以下資料結構進行跟蹤:

enum bpf_reg_liveness {
      REG_LIVE_NONE = 0,
      REG_LIVE_READ32 = 0x1,
      REG_LIVE_READ64 = 0x2,
      REG_LIVE_READ = REG_LIVE_READ32 | REG_LIVE_READ64,
      REG_LIVE_WRITTEN = 0x4,
      REG_LIVE_DONE = 0x8,
};

struct bpf_reg_state {
      ...
      struct bpf_reg_state *parent;
      ...
      enum bpf_reg_liveness live;
      ...
};

struct bpf_stack_state {
      struct bpf_reg_state spilled_ptr;
      ...
};

struct bpf_func_state {
      struct bpf_reg_state regs[MAX_BPF_REG];
      ...
      struct bpf_stack_state *stack;
}

struct bpf_verifier_state {
      struct bpf_func_state *frame[MAX_CALL_FRAMES];
      struct bpf_verifier_state *parent;
      ...
}
  • REG_LIVE_NONE 是新校驗器狀態建立時分配給 ->live 欄位的初始值;

  • REG_LIVE_WRITTEN 表示暫存器(或堆疊槽)的值由在此校驗器狀態的父狀態和校驗器狀態本身之間驗證的某些指令定義;

  • REG_LIVE_READ{32,64} 表示暫存器(或堆疊槽)的值由該校驗器狀態的某個子狀態讀取;

  • REG_LIVE_DONEclean_verifier_state() 使用的標記,用於避免多次處理同一校驗器狀態以及進行一些健全性檢查;

  • ->live 欄位的值是透過使用按位或組合 enum bpf_reg_liveness 值形成的。

暫存器父子鏈

為了在父狀態和子狀態之間傳播資訊,建立了 暫存器父子鏈。每個暫存器或堆疊槽透過 ->parent 指標連結到其父狀態中對應的暫存器或堆疊槽。此連結在 is_state_visited() 中建立狀態時建立,並可能由從 __check_func_call() 呼叫的 set_callee_state() 修改。

暫存器/堆疊槽之間的對應規則如下:

  • 對於當前堆疊幀,新狀態的暫存器和堆疊槽連結到父狀態中具有相同索引的暫存器和堆疊槽。

  • 對於外部堆疊幀,只有被呼叫者儲存的暫存器 (r6-r9) 和堆疊槽連結到父狀態中具有相同索引的暫存器和堆疊槽。

  • 當處理函式呼叫時,會分配一個新的 struct bpf_func_state 例項,它封裝了一組新的暫存器和堆疊槽。對於這個新幀,r6-r9 和堆疊槽的父連結被設定為 nil,r1-r5 的父連結被設定為匹配呼叫者的 r1-r5 父連結。

這可以透過以下圖表說明(箭頭表示 ->parent 指標):

    ...                    ; Frame #0, some instructions
--- checkpoint #0 ---
1 : r6 = 42                ; Frame #0
--- checkpoint #1 ---
2 : call foo()             ; Frame #0
    ...                    ; Frame #1, instructions from foo()
--- checkpoint #2 ---
    ...                    ; Frame #1, instructions from foo()
--- checkpoint #3 ---
    exit                   ; Frame #1, return from foo()
3 : r1 = r6                ; Frame #0  <- current state

           +-------------------------------+-------------------------------+
           |           Frame #0            |           Frame #1            |
Checkpoint +-------------------------------+-------------------------------+
#0         | r0 | r1-r5 | r6-r9 | fp-8 ... |
           +-------------------------------+
              ^    ^       ^       ^
              |    |       |       |
Checkpoint +-------------------------------+
#1         | r0 | r1-r5 | r6-r9 | fp-8 ... |
           +-------------------------------+
                   ^       ^       ^
                   |_______|_______|_______________
                           |       |               |
             nil  nil      |       |               |      nil     nil
              |    |       |       |               |       |       |
Checkpoint +-------------------------------+-------------------------------+
#2         | r0 | r1-r5 | r6-r9 | fp-8 ... | r0 | r1-r5 | r6-r9 | fp-8 ... |
           +-------------------------------+-------------------------------+
                           ^       ^               ^       ^       ^
             nil  nil      |       |               |       |       |
              |    |       |       |               |       |       |
Checkpoint +-------------------------------+-------------------------------+
#3         | r0 | r1-r5 | r6-r9 | fp-8 ... | r0 | r1-r5 | r6-r9 | fp-8 ... |
           +-------------------------------+-------------------------------+
                           ^       ^
             nil  nil      |       |
              |    |       |       |
Current    +-------------------------------+
state      | r0 | r1-r5 | r6-r9 | fp-8 ... |
           +-------------------------------+
                           \
                             r6 read mark is propagated via these links
                             all the way up to checkpoint #1.
                             The checkpoint #1 contains a write mark for r6
                             because of instruction (1), thus read propagation
                             does not reach checkpoint #0 (see section below).

活躍度標記跟蹤

對於每條已處理的指令,校驗器會跟蹤讀取和寫入的暫存器和堆疊槽。該演算法的核心思想是,讀取標記沿著狀態父鏈向後傳播,直到它們遇到寫入標記,該標記“遮蔽”了早期狀態的讀取。有關讀取的資訊由函式 mark_reg_read() 傳播,其總結如下:

mark_reg_read(struct bpf_reg_state *state, ...):
    parent = state->parent
    while parent:
        if state->live & REG_LIVE_WRITTEN:
            break
        if parent->live & REG_LIVE_READ64:
            break
        parent->live |= REG_LIVE_READ64
        state = parent
        parent = state->parent

注意:

  • 讀取標記應用於 狀態,而寫入標記應用於 當前 狀態。暫存器或堆疊槽上的寫入標記意味著它已在從父狀態到當前狀態的直線程式碼中的某個指令更新。

  • 關於 REG_LIVE_READ32 的細節已省略。

  • 函式 propagate_liveness()(參見 快取命中時讀取標記的傳播 一節)可能會覆蓋第一個父連結。有關更多詳細資訊,請參閱 propagate_liveness()mark_reg_read() 原始碼中的註釋。

由於堆疊寫入可能具有不同的大小,REG_LIVE_WRITTEN 標記會保守地應用:堆疊槽僅在寫入大小與暫存器大小對應時才標記為已寫入,例如參見函式 save_register_state()

考慮以下示例:

0: (*u64)(r10 - 8) = 0   ; define 8 bytes of fp-8
--- checkpoint #0 ---
1: (*u32)(r10 - 8) = 1   ; redefine lower 4 bytes
2: r1 = (*u32)(r10 - 8)  ; read lower 4 bytes defined at (1)
3: r2 = (*u32)(r10 - 4)  ; read upper 4 bytes defined at (0)

如上所述,(1) 處的寫入不計為 REG_LIVE_WRITTEN。如果不是這樣,上述演算法將無法將讀取標記從 (3) 傳播到檢查點 #0。

一旦達到 BPF_EXIT 指令,將呼叫 update_branch_counts() 來更新父校驗器狀態鏈中每個校驗器狀態的 ->branches 計數器。當 ->branches 計數器達到零時,校驗器狀態將成為一組快取校驗器狀態中的有效條目。

校驗器狀態快取的每個條目都由函式 clean_live_states() 進行後處理。此函式會將所有沒有 REG_LIVE_READ{32,64} 標記的暫存器和堆疊槽標記為 NOT_INITSTACK_INVALID。以這種方式標記的暫存器/堆疊槽在從 states_equal() 呼叫的 stacksafe() 函式中被忽略,當一個狀態快取條目被考慮與當前狀態等價時。

現在可以解釋本節開頭的示例是如何工作的:

0: call bpf_get_prandom_u32()
1: r1 = 0
2: if r0 == 0 goto +1
3: r0 = 1
--- checkpoint[0] ---
4: r0 = r1
5: exit
  • 在指令 #2 處,達到分支點,狀態 { r0 == 0, r1 == 0, pc == 4 } 被推入狀態處理佇列(pc 代表程式計數器)。

  • 在指令 #4

    • 建立 checkpoint[0] 狀態快取條目:{ r0 == 1, r1 == 0, pc == 4 }

    • checkpoint[0].r0 被標記為已寫入;

    • checkpoint[0].r1 被標記為已讀取;

  • 在指令 #5 處,達到退出點,checkpoint[0] 現在可以由 clean_live_states() 處理。處理後,checkpoint[0].r1 帶有讀取標記,所有其他暫存器和堆疊槽被標記為 NOT_INITSTACK_INVALID

  • 狀態 { r0 == 0, r1 == 0, pc == 4 } 從狀態佇列中彈出,並與快取狀態 { r1 == 0, pc == 4 } 進行比較,這些狀態被認為是等價的。

快取命中時讀取標記的傳播

另一點是當在狀態快取中找到先前驗證過的狀態時,讀取標記的處理。快取命中時,校驗器必須以與當前狀態已驗證到程式退出相同的方式行為。這意味著快取狀態的暫存器和堆疊槽上存在的所有讀取標記必須沿著當前狀態的父鏈傳播。下面的示例說明了為什麼這很重要。函式 propagate_liveness() 處理這種情況。

考慮以下狀態父鏈(S 是起始狀態,A-E 是派生狀態,-> 箭頭表示哪個狀態由哪個狀態派生)

               r1 read
        <-------------                A[r1] == 0
                                      C[r1] == 0
  S ---> A ---> B ---> exit           E[r1] == 1
  |
  ` ---> C ---> D
  |
  ` ---> E      ^
                |___   suppose all these
         ^           states are at insn #Y
         |
  suppose all these
states are at insn #X
  • 首先驗證狀態鏈 S -> A -> B -> exit

  • 在驗證 B -> exit 時,暫存器 r1 被讀取,此讀取標記向上傳播到狀態 A

  • 當狀態鏈 C -> D 被驗證時,狀態 D 被證明與狀態 B 等價。

  • 必須將 r1 的讀取標記傳播到狀態 C,否則狀態 C 可能會被錯誤地標記為與狀態 E 等價,儘管暫存器 r1 的值在 CE 之間存在差異。

理解 eBPF 校驗器訊息

以下是一些無效 eBPF 程式和校驗器錯誤訊息的示例(如日誌中所示):

包含不可達指令的程式

static struct bpf_insn prog[] = {
BPF_EXIT_INSN(),
BPF_EXIT_INSN(),
};

錯誤

unreachable insn 1

讀取未初始化暫存器的程式

BPF_MOV64_REG(BPF_REG_0, BPF_REG_2),
BPF_EXIT_INSN(),

錯誤

0: (bf) r0 = r2
R2 !read_ok

退出前未初始化 R0 的程式

BPF_MOV64_REG(BPF_REG_2, BPF_REG_1),
BPF_EXIT_INSN(),

錯誤

0: (bf) r2 = r1
1: (95) exit
R0 !read_ok

訪問越界堆疊的程式

BPF_ST_MEM(BPF_DW, BPF_REG_10, 8, 0),
BPF_EXIT_INSN(),

錯誤

0: (7a) *(u64 *)(r10 +8) = 0
invalid stack off=8 size=8

在將堆疊地址傳遞給函式之前未初始化堆疊的程式

BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_EXIT_INSN(),

錯誤

0: (bf) r2 = r10
1: (07) r2 += -8
2: (b7) r1 = 0x0
3: (85) call 1
invalid indirect read from stack off -8+0 size 8

呼叫 map_lookup_elem() 函式時使用無效 map_fd=0 的程式

BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_EXIT_INSN(),

錯誤

0: (7a) *(u64 *)(r10 -8) = 0
1: (bf) r2 = r10
2: (07) r2 += -8
3: (b7) r1 = 0x0
4: (85) call 1
fd 0 is not pointing to valid bpf_map

在訪問對映元素之前未檢查 map_lookup_elem() 返回值的程式

BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_ST_MEM(BPF_DW, BPF_REG_0, 0, 0),
BPF_EXIT_INSN(),

錯誤

0: (7a) *(u64 *)(r10 -8) = 0
1: (bf) r2 = r10
2: (07) r2 += -8
3: (b7) r1 = 0x0
4: (85) call 1
5: (7a) *(u64 *)(r0 +0) = 0
R0 invalid mem access 'map_value_or_null'

程式正確檢查 map_lookup_elem() 返回值是否為 NULL,但在“if”分支的一側以不正確的對齊方式訪問記憶體

BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 1),
BPF_ST_MEM(BPF_DW, BPF_REG_0, 4, 0),
BPF_EXIT_INSN(),

錯誤

0: (7a) *(u64 *)(r10 -8) = 0
1: (bf) r2 = r10
2: (07) r2 += -8
3: (b7) r1 = 1
4: (85) call 1
5: (15) if r0 == 0x0 goto pc+1
 R0=map_ptr R10=fp
6: (7a) *(u64 *)(r0 +4) = 0
misaligned access off 4 size 8

程式正確檢查 map_lookup_elem() 返回值是否為 NULL,並在 'if' 分支的一側以正確的對齊方式訪問記憶體,但在 'if' 分支的另一側未能做到這一點

BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_LD_MAP_FD(BPF_REG_1, 0),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2),
BPF_ST_MEM(BPF_DW, BPF_REG_0, 0, 0),
BPF_EXIT_INSN(),
BPF_ST_MEM(BPF_DW, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),

錯誤

0: (7a) *(u64 *)(r10 -8) = 0
1: (bf) r2 = r10
2: (07) r2 += -8
3: (b7) r1 = 1
4: (85) call 1
5: (15) if r0 == 0x0 goto pc+2
 R0=map_ptr R10=fp
6: (7a) *(u64 *)(r0 +0) = 0
7: (95) exit

from 5 to 8: R0=imm0 R10=fp
8: (7a) *(u64 *)(r0 +0) = 1
R0 invalid mem access 'imm'

執行套接字查詢然後不檢查就將指標設定為 NULL 的程式

BPF_MOV64_IMM(BPF_REG_2, 0),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_2, -8),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_MOV64_IMM(BPF_REG_3, 4),
BPF_MOV64_IMM(BPF_REG_4, 0),
BPF_MOV64_IMM(BPF_REG_5, 0),
BPF_EMIT_CALL(BPF_FUNC_sk_lookup_tcp),
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),

錯誤

0: (b7) r2 = 0
1: (63) *(u32 *)(r10 -8) = r2
2: (bf) r2 = r10
3: (07) r2 += -8
4: (b7) r3 = 4
5: (b7) r4 = 0
6: (b7) r5 = 0
7: (85) call bpf_sk_lookup_tcp#65
8: (b7) r0 = 0
9: (95) exit
Unreleased reference id=1, alloc_insn=7

執行套接字查詢但未對返回值進行 NULL 檢查的程式

BPF_MOV64_IMM(BPF_REG_2, 0),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_2, -8),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -8),
BPF_MOV64_IMM(BPF_REG_3, 4),
BPF_MOV64_IMM(BPF_REG_4, 0),
BPF_MOV64_IMM(BPF_REG_5, 0),
BPF_EMIT_CALL(BPF_FUNC_sk_lookup_tcp),
BPF_EXIT_INSN(),

錯誤

0: (b7) r2 = 0
1: (63) *(u32 *)(r10 -8) = r2
2: (bf) r2 = r10
3: (07) r2 += -8
4: (b7) r3 = 4
5: (b7) r4 = 0
6: (b7) r5 = 0
7: (85) call bpf_sk_lookup_tcp#65
8: (95) exit
Unreleased reference id=1, alloc_insn=7