Linux 內核角度分析 tcpdump 原理 -2-
本公衆號作者爲 Linux 內核之旅社區成員 - 董旭
上篇文章介紹了在內核角度 tcpdump 的抓包原理 (1),主要流程如下:
-
應用層通過 libpcap 庫:調用系統調用創建 socket,
sock_fd = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
tcpdump 在 socket 創建過程中創建 packet_type(struct packet_type), 並掛載到全局的 ptype_all 鏈表上。(同時在 packet_type 設置回調函數 packet_rcv。 -
網絡收包 / 發包時,會在各自的處理函數 (收包時:
__netif_receive_skb_core
,發包時:dev_queue_xmit_nit
) 中遍歷 ptype_all 鏈表,並同時執行其回調函數,這裏 tcpdump 的註冊的回調函數就是 packet_rcv。 -
packet_rcv 函數中會將用戶設置的過濾條件,通過 BPF 進行過濾,並將過濾的數據包添加到接收隊列中。
-
應用層調用 recvfrom 。PF_PACKET 協議簇模塊調用 packet_recvmsg 將接收隊列中的數據 copy 應用層,到此將數據包捕獲到。
本文圍繞的重點是: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 則分別對應了安裝和卸載。
- 在套接字 socket 附加 filter 規則 :
setsockopt(sockfd, SOL_SOCKET, SO_ATTACH_FILTER, &val, sizeof(val));
- · 把 filter 從 socket 上移除 :
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