Linux 內核角度分析 tcpdump 原理 -2-

本公衆號作者爲 Linux 內核之旅社區成員 - 董旭

上篇文章介紹了在內核角度 tcpdump 的抓包原理 (1),主要流程如下:

本文圍繞的重點是:BPF 的過濾原理,如下源碼所示:run_filter(skb, sk, snaplen),本次文章將對 BPF 的過濾原理進行一些分析。

static int packet_rcv(struct sk_buff *skb, struct net_device *dev,
        struct packet_type *pt, struct net_device *orig_dev)
{
  ......
         
 res = run_filter(skb, sk, snaplen);   //將用戶指定的過濾條件使用BPF進行過濾
    ......
 __skb_queue_tail(&sk->sk_receive_queue, skb);//將skb放到當前的接收隊列中
    ......

}

tcpdump 依附標準的的 BPF 機器,tcpdump 的過濾規則會被轉化成一段 bpf 指令並加載到內核中的 bpf 虛擬機器上執行,顯然,由用戶來寫過濾代碼太過複雜,因此 libpcap 允許用戶書寫高層的、容易理解的過濾字符串,然後將其編譯爲 BPF 代碼,tcpdump 自動完成,不爲用戶所見。

一、BPF 彙編指令集

關於 BPF 的介紹以及學習路線可以參考該鏈接的文章 (https://linux.cn/article-9507-1.html),該文章給出了一些閱讀清單。

BPF 指令集

是一個僞機器碼,與能夠在物理機上直接執行的機器碼不同,BPF 指令集是可以在 BPF 虛擬機上執行的指令集。bpf 在內核中實際就是一個虛擬機,有自己定義的虛擬機寄存器組。在最早的 cBPF 彙編框架中的三種寄存器:

Element          Description

  A                32 bit wide accumulator//所以加載指令的目的地址和所有指令運算結果的存儲地址
  X                32 bit wide X register//二元指令計算A中參數的輔助寄存器(例如移位的位數,除法的除數)
  M[]              16 x 32 bit wide misc registers aka "scratch memory
                   store", addressable from 0 to 15// 0-15共16個32位寄存器,可以自由使用

在 cBPF 每條彙編指令如下這種格式:

//  include\uapi\linux\filter.h
struct sock_filter {    /* Filter block */
    __u16   code;   /* 真正的bpf彙編指令 */
    __u8    jt; /* 結果爲true時跳轉指令 */
    __u8    jf; /* 結果爲false時跳轉 */
    __u32   k;      /* 指令的參數 */
};

我們最常見的用法莫過於從數據包中取某個字的數據來做判斷。按照 bpf 的規定,我們可以使用偏移來指定數據包的任何位置,而很多協議很常用並且固定,例如端口和 ip 地址等,bpf 就爲我們提供了一些預定義的變量,只要使用這個變量就可以直接取值到對應的數據包位置。例如:

  len                                   skb->len
  proto                                 skb->protocol
  type                                  skb->pkt_type
  poff                                  Payload start offset
  ifidx                                 skb->dev->ifindex
  nla                                   Netlink attribute of type X with offset A
  nlan                                  Nested Netlink attribute of type X with offset A
  mark                                  skb->mark
  queue                                 skb->queue_mapping
  hatype                                skb->dev->type
  rxhash                                skb->hash
  cpu                                   raw_smp_processor_id()
  vlan_tci                              skb_vlan_tag_get(skb)
  vlan_avail                            skb_vlan_tag_present(skb)
  vlan_tpid                             skb->vlan_proto
  rand                                  prandom_u32()

cBPF 在一些平臺還在使用,這個代碼就和用戶空間使用的那種彙編是一樣的,但是在 X86 架構,現在在內核態已經都切換到使用 eBPF 作爲中間語言了。由於用戶可以提交 cBPF 的代碼,所以首先是將用戶提交來的結構體數組進行編譯成 eBPF 代碼(提交的是 eBPF 就不用了)。然後再將 eBPF 代碼轉變爲可直接執行的二進制。eBPF 彙編框架下的 bpf 語句如下:

//  include\uapi\linux\bpf.h
struct bpf_insn {
    __u8    code;       /* 存放真正的指令碼 */
    __u8    dst_reg:4;  /* 存放指令用到的寄存器號(R0~R10) */
    __u8    src_reg:4;  /* 同上,存放指令用到的寄存器號(R0~R10) */
    __s16   off;        /* signed offset 取決於指令的類型*/
    __s32   imm;        /* 存放立即值 */
};

tcpdump -d

tcpdump 支持使用 - d 參數來顯示過濾規則轉換後的 bpf 彙編指令。在抓包時我們並不關心如何具體的編寫 struct sock_filter 內的東西,因爲 tcpdump 已經內置了這樣的功能。如想要對所接受的數據包過濾,只想抓取 TCP 協議、端口爲 8080 數據包,那麼在 tcpdump 當中的命令就是 tcpdump ip and tcp port 8080 。如果你想讓 tcpdump 幫你編譯這樣的過濾器,則用 tcpdump  -d 'ip and tcp port 8080', 如下案例 (參考《Linux 內核觀測技術 BPF》) 如下圖所示,顯示 “tcpdump 抓取 tcp 端口 8080 數據包” 的 bpf 彙編指令:

(000) ldh      [12]
(001) jeq      #0x800           jt 2 jf 12
(002) ldb      [23]
(003) jeq      #0x6             jt 4 jf 12
(004) ldh      [20]
(005) jset     #0x1fff          jt 12 jf 6
(006) ldxb     4*([14]&0xf)
(007) ldh      [x + 14]
(008) jeq      #0x1f90          jt 11 jf 9
(009) ldh      [x + 16]
(010) jeq      #0x1f90          jt 11 jf 12
(011) ret      #262144
(012) ret      #0

爲了進一步分析這條案例的流程,先把數據包的幀格式和 ip 數據報的格式放下面便於分析:

以太網幀格式:

IP 數據報格式:

001-003 條 bpf 彙編指令進行解釋:

(000) ldh      [12]:

ldh 指令表示累加器在偏移量 12 處進行加載一個半字 (16 位),從以太網幀的格式中可以看到偏移量爲 12 字節處爲以太網類型字段。

(001) jeq      #0x800           jt 2 jf 12:

jeq 指令 bioassay 如果相等則跳轉,也就是檢查上一條指令返回的以太網類型的值是否爲 ox800(ipv4 的標識),如果爲 true(jt), 就跳轉到指令 2,否則跳轉到指令 12。

(002) ldb      [23]:

ldb 指令在偏移量 23 字節處進行加載 (計算一下:以太網幀的頭部是 14 個字節,那麼第 23 個字節,也就是 IP 頭部的第 9 個字節),根據 IP 數據報的格式可以看到第 9 個字節是“協議” 字段。

(003) jeq      #0x6             jt 4 jf 12

jeq 指令根據第 9 個字節的值進行再一次的判斷和跳轉,如果第 9 個字節 “協議字段” 爲 0x6(TCP),如果是 TCP 則跳轉到下一條指令 004,否則跳轉到 012,數據包進行丟棄。

上面的規則對應代碼結構體在 Linux 內核中的表示其實就是 struct sock_filter,在 libpcap 庫中對應的結構體爲struct bpf_insn

struct bpf_insn {
 u_short code;
 u_char  jt;
 u_char  jf;
 bpf_u_int32 k;
};

tcpdump -dd

上述使用 tcpdump -d 看到了過濾規則轉換後的 bpf 彙編指令,上面幾個指令:ld 開頭的表示加載某地址數據,jeq 是比較,jt 就是 jump when true,jf 就是 jump when false,後面表示行號; tcpdump 支持使用 - dd 參數將匹配信息包的代碼以 c 語言程序段的格式給出:

像 c 當中的數組的定義,這個就是過濾 tcp8080 數據包的 struct sock_filter 的數組代碼。

二、tcpdump 設置 BPF 過濾器

在 libpcap 設置過濾規則用到了兩個接口,pcap_compile()和 pcap_setfilter ()

pcap_compile 函數的主要工作就是創建一個 bpf 的結構體,後面 pcap_setfilter 會把生產的規則設置到內核,讓規則生效。

struct pcap

在進行分析 pcap_compile()和 pcap_setfilter ()前,先介紹一個結構體:struct pcap,介紹後面的過程便於查閱該結構體的成員變量,該結構體在 libpcap 源碼的 pcap-int.h 中定義,該結構體抓包過程的一個句柄。

struct pcap 

{
    int fd; /* 文件描述字,實際就是 socket */
    
        /* 在 socket 上,可以使用 select() 和 poll() 等 I/O 複用類型函數 */
    int selectable_fd;

    int snapshot; /* 用戶期望的捕獲數據包最大長度 */
    int linktype; /* 設備類型 */
    int tzoff;        /* 時區位置,實際上沒有被使用 */
    int offset;    /* 邊界對齊偏移量 */

    int break_loop; /* 強制從讀數據包循環中跳出的標誌 */

    struct pcap_sf sf; /* 數據包保存到文件的相關配置數據結構 */
    struct pcap_md md; /* 具體描述如下 */
    
    int bufsize; /* 讀緩衝區的長度 */
    u_char buffer; /* 讀緩衝區指針 */
    u_char *bp;
    int cc;
    u_char *pkt;

    /* 相關抽象操作的函數指針,最終指向特定操作系統的處理函數 */
    int    (*read_op)(pcap_t *, int cnt, pcap_handler, u_char *);
    int    (*setfilter_op)(pcap_t *, struct bpf_program *);
    int    (*set_datalink_op)(pcap_t *, int);
    int    (*getnonblock_op)(pcap_t *, char *);
    int    (*setnonblock_op)(pcap_t *, int, char *);
    int    (*stats_op)(pcap_t *, struct pcap_stat *);
    void (*close_op)(pcap_t *);

    /*如果 BPF 過濾代碼不能在內核中執行,則將其保存並在用戶空間執行 */
    struct bpf_program fcode;

    /* 函數調用出錯信息緩衝區 */
    char errbuf[PCAP_ERRBUF_SIZE + 1];
    
    /* 當前設備支持的、可更改的數據鏈路類型的個數 */
    int dlt_count;
    /* 可更改的數據鏈路類型號鏈表,在 linux 下沒有使用 */
    int *dlt_list;

    /* 數據包自定義頭部,對數據包捕獲時間、捕獲長度、真實長度進行描述 [pcap.h] */
    struct pcap_pkthdr pcap_header;    
};

/* 包含了捕獲句柄的接口、狀態、過濾信息  [pcap-int.h] */
struct pcap_md {
/* 捕獲狀態結構  [pcap.h] */
struct pcap_stat stat;  

    int use_bpf; /* 如果爲1,則代表使用內核過濾*/
    u_long    TotPkts;
    u_long    TotAccepted; /* 被接收數據包數目 */
    u_long    TotDrops;    /* 被丟棄數據包數目 */
    long    TotMissed;    /* 在過濾進行時被接口丟棄的數據包數目 */
    long    OrigMissed; /*在過濾進行前被接口丟棄的數據包數目*/
#ifdef linux
    int    sock_packet; /* 如果爲 1,則代表使用 2.0 內核的 SOCK_PACKET 模式 */
    int    timeout;    /* pcap_open_live() 函數超時返回時間*/
    int    clear_promisc; /* 關閉時設置接口爲非混雜模式 */
    int    cooked;        /* 使用 SOCK_DGRAM 類型 */
    int    lo_ifindex;    /* 迴路設備索引號 */
    char *device;    /* 接口設備名稱 */
    
/* 以混雜模式打開 SOCK_PACKET 類型 socket 的 pcap_t 鏈表*/
struct pcap *next;    
#endif
};

pcap_compile

//函數用於將用戶制定的過濾策略編譯成BPF代碼,然後存入bpf_program結構中
/*
p:pcap_open_live()返回的pcap_t類型的指針
fp:存放編譯後的bpf
buf:過濾規則
optimize:是否需要優化過濾表達式
mask:指定本地網絡的網絡掩碼,不需要時可以設置爲0
*/
pcap_compile(pcap_t *p,struct bpf_program *fp,char *filterstr,int opt,bpf_u_int32 netmask);

pcap_setfilter

//將過濾的規則注入內核
int pcap_setfilter (pcap_t *p, struct bpf_program *fp)

函數原型:

int pcap_setfilter(pcap_t *p, struct bpf_program *fp)
{
 return (p->setfilter_op(p, fp)); //在Linux中將調用pcap_setfilter_linux
}

調用 pcap_setfilter_linux:

static int pcap_setfilter_linux(pcap_t *handle, struct bpf_program *filter)
{
 return pcap_setfilter_linux_common(handle, filter, 0);
}

進一步調用 pcap_setfilter_linux_common:

static int pcap_setfilter_linux_common(pcap_t *handle, struct bpf_program *filter,
    int is_mmapped)
{  
    ......
    struct sock_fprog fcode;
 ......
    if (install_bpf_program(handle, filter) < 0)//把BPF代碼拷貝到pcap_t 數據結構的fcode上
    ......
       /*Linux內核設置過濾器時使用的數據結構是sock_fprog,而不是BPF的結構bpf_program,因此應做結構之間的轉換*/
  switch (fix_program(handle, &fcode, is_mmapped)) {
       //嚴重錯誤直接退出
  case -1:
  default:
   return -1;
       //通過檢查,但不能工作在內核中
  case 0:
   
   can_filter_in_kernel = 0;
   break;
        //BPF可以在內核中工作
  case 1:

   can_filter_in_kernel = 1;
   break;
  }
 }
     //通過檢查後,如果可以則在內核中安裝過濾器
 if (can_filter_in_kernel) {
  if ((err = set_kernel_filter(handle, &fcode)) == 0)
  {

   handlep->filter_in_userland = 0;
  }
    ......

 return 0;
}

上面涉及到的 Linux 內核中的 struct sock_fprog 和 libpcap 庫中的 struct bpf_program 如下所示:

struct sock_fprog {   /* Required for SO_ATTACH_FILTER. */
 unsigned short     len; /* Number of filter blocks */
 struct sock_filter __user *filter;
};
struct bpf_program {
 u_int bf_len;
 struct bpf_insn *bf_insns;//該結構體上面介紹過,相當於Linux內核中的struct sock_filte
};

install_bpf_program(handle, filter)的拷貝過程:

//該函數主要將libpcap bpf規則結構體,轉換成符合liunx內核的bpf規則,同時校驗規則是否符合要求格式。
int install_bpf_program(pcap_t *p, struct bpf_program *fp)
{
 ......
 pcap_freecode(&p->fcode);//釋放可能存在的BPF代碼

 prog_size = sizeof(*fp->bf_insns) * fp->bf_len;//計算過濾代碼的長度,分配內存空間
 p->fcode.bf_len = fp->bf_len;
 p->fcode.bf_insns = (struct bpf_insn *)malloc(prog_size);
 if (p->fcode.bf_insns == NULL) {
  snprintf(p->errbuf, sizeof(p->errbuf),
    "malloc: %s", pcap_strerror(errno));
  return (-1);
 }
    //把過濾代碼保存在捕獲句柄中
 memcpy(p->fcode.bf_insns, fp->bf_insns, prog_size);//p->fcode就是struct bpf_program
 return (0);
}

pcap_setfilter_linux_common最終會在set_kernel_filter中調用setsockopt系統調用 (執行到這才真正進入內核,開始在 Linux 內核上安裝和設置 BPF 過濾器),通過 SO_ATTACH_FILTER 下發給內核底層,從而讓規則生效, 設置過濾器。

static int  set_kernel_filter(pcap_t *handle, struct sock_fprog *fcode)
{
 ......
 ret = setsockopt(handle->fd, SOL_SOCKET, SO_ATTACH_FILTER,
    fcode, sizeof(*fcode)); 
    ......
}

在 liunx 上,只需要簡單的創建的 filter 代碼,通過 SO_ATTTACH_FILTER 選項發送到內核,並且 filter 代碼能通過內核的檢查,這樣你就可以立即過濾 socket 上面的數據了。

三、setsockopt()

Linux 在安裝和卸載過濾器時都使用了函數 setsockopt(),其中標誌 SOL_SOCKET 代表了對 socket 進行設置,而 SO_ATTACH_FILTER 和 SO_DETACH_FILTER 則分別對應了安裝和卸載。

setsockopt(sockfd, SOL_SOCKET, SO_ATTACH_FILTER, &val, sizeof(val));

setsockopt(sockfd, SOL_SOCKET, SO_DETACH_FILTER, &val, sizeof(val));

Linux 內核在sock_setsockopt函數中進行設置:

//:  net\core\sock.c
int sock_setsockopt(struct socket *sock, int level, int optname,
      char __user *optval, unsigned int optlen)
{
 ......
 case SO_ATTACH_FILTER:
  ret = -EINVAL;
  if (optlen == sizeof(struct sock_fprog)) {
   struct sock_fprog fprog;

   ret = -EFAULT;
            //把過濾條件結構體從用戶空間拷貝到內核空間
   if (copy_from_user(&fprog, optval, sizeof(fprog)))
    break;
            //在socket上安裝過濾器
   ret = sk_attach_filter(&fprog, sk);
  }
  break;
    ......

 case SO_DETACH_FILTER:
      //解除過濾器
  ret = sk_detach_filter(sk);
  break;

 ......
}

上面出現的 sk_attach_filter() 定義在 net/core/filter.c,它把結構 sock_fprog 轉換爲結構 sk_filter, 最後把此結構設置爲 socket 的過濾器:sk->filter = fp。

回到文章開始 (抓包的引入):

static int packet_rcv(struct sk_buff *skb, struct net_device *dev,
        struct packet_type *pt, struct net_device *orig_dev)
{
  ......
         
 res = run_filter(skb, sk, snaplen);   //將用戶指定的過濾條件使用BPF進行過濾
    ......
 __skb_queue_tail(&sk->sk_receive_queue, skb);//將skb放到當前的接收隊列中
    ......

}

run_filter:

static unsigned int run_filter(struct sk_buff *skb,const struct sock *sk,unsigned int res)
{
 struct sk_filter *filter;

 rcu_read_lock();
 filter = rcu_dereference(sk->sk_filter);//獲取之前設置的過濾器
 if (filter != NULL)
  res = bpf_prog_run_clear_cb(filter->prog, skb);//進行數據包過濾
 rcu_read_unlock();

 return res;
}

綜上,結合上一篇文章,和本文就將 Linux 內核角度將 tcpdump 的工作原理分析完畢。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/i3YRt2xcPi0Nv_kV8ylHPg