TCPdump 原理与实现
tcpdump 使用 libpcap 这种链路层旁路处理的形式进行包捕获,大大提高了抓包效率。在 这篇文章中 介绍了 tcpdump 的基本使用,本文将介绍其实现的关键 libpcap 和 BPF。BPF 抓包机制将 tcpdump 过滤规则转化为一段 bpf 指令并加载到内核中的 bpf 虚拟机器上执行,本文主要介绍 cBPF 的基本原理,关于 eBPF 指令集,可以参考 这篇文章。文中设计到的代码可以参考我的 Github 。
libpcap 实践
libpcap(Packet Capture Library),即数据包捕获函数库,是 Unix/Linux 平台下的网络数据包捕获函数库,独立于系统的用户层包捕获的 API 接口,为底层网络监测提供了一个可移植的框架。
利用 libpcap 函数库开发应用程序的基本步骤:
- 捕获各种数据包,例如:网络流量统计。
- 过滤网络数据包,例如:过滤掉本地上的一些数据,类似防火墙。
- 分析网络数据包,例如:分析网络协议,数据的采集。
- 存储网络数据包,例如:保存捕获的数据以为将来进行分析。
安装部署
libpcap 库在 linux 上的安装过程
|
|
测试:
|
|
编译:
|
|
常见 API
pcap_lookupdev
- pcap_lookupdev():函数用于查找网络设备,返回可被
pcap_open_live()函数调用的网络设备名指针。
|
|
pcap_lookupnet
- pcap_lookupnet():函数获得指定网络设备的网络号和掩码。
|
|
pcap_open_live
- pcap_open_live(): 函数用于打开网络设备,并且返回用于捕获网络数据包的数据包捕获描述字。对于此网络设备的操作都要基于此网络设备描述字。
|
|
pcap_compile
- pcap_compile(): 函数用于将用户制定的过滤策略编译到过滤程序中。
|
|
pcap_setfilter
- pcap_setfilter():函数用于设置过滤器。
|
|
pcap_loop
- pcap_loop()
|
|
循环捕获网络数据包,直到遇到错误或者满足退出条件。每次捕获一个数据包就会调用 callback 指定的回调函数,所以,可以在回调函数中进行数据包的处理操作。
pcap_dispatch
- pcap_dispatch
|
|
这个函数和 pcap_loop() 非常类似,只是在超过 to_ms 毫秒后就会返回( to_ms 是 pcap_open_live() 的第 4 个参数 )
pcap_close
- pcap_close():函数用于关闭网络设备,释放资源。
|
|
demo 示例
|
|
演示效果:
|
|
BPF 报文过滤机制
BPF,是类 Unix 系统上数据链路层的一种原始接口,提供原始链路层封包的收发。1992 年,Steven McCanne 和 Van Jacobson 写了一篇名为 The BSD Packet Filter: A New Architecture for User-level Packet Capture 的论文。在文中,作者描述了他们如何在 Unix 内核实现网络数据包过滤,这种新的技术比当时最先进的数据包过滤技术快 20 倍。
BPF 在数据包过滤上引入了两大革新:
- 一个新的虚拟机 (VM) 设计,可以有效地工作在基于寄存器结构的 CPU 之上
- 应用程序使用缓存只复制与过滤数据包相关的数据,不会复制数据包的所有信息,这样可以最大程度地减少 BPF 处理的数据
从图中可以看出,BPF 是作为内核报文传输路径的一个旁路存在的,当报文到达内核驱动程序后,内核在将报文上送协议栈的同时,会额外将报文的一个副本交给 BPF。之后,报文会经过 BPF 内部逻辑的过滤(当然,这个逻设置),然后最终送给用户程序(比如 tcpdump)
BPF 虚拟机
tcpdump 如何过滤指定的报文呢? 举个例子,当我使用 tcpdump tcp dst port 8080 时,BPF 的过滤逻辑如何将目的端口为 8080 的 TCP 报文过滤出来? 可能最容易想到的方式就是粗暴的硬编码了, 比如像下面这样编写内核模块。
|
|
但是,这样的方式也太傻了,难道每次抓包都需要加载内核模块? 这显然不是 BPF 能成为经典的原因。
BPF 采用的是一种 Pseudo-Machine 的方式。
什么是 Pseudo-Machine ? 我更愿意将这个词翻译为虚拟机,它是 BPF 过滤功能的核心逻辑。这个虚拟机很简单,只包括:
- 一个累加器( accumulator )
- 一个索引寄存器 ( index register )
- 一小段内存空间 ( memory store )
- 一个隐式的 PC 指针( implicit program counter )
与能够在物理机上直接执行的机器码不同,BPF 指令集是可以在 BPF 虚拟机上执行的指令集。bpf 在内核中实际就是一个虚拟机,有自己定义的虚拟机寄存器组。在最早的 cBPF 汇编框架中的三种寄存器:
|
|
BPF 指令集
它支持的指令集也非常有限,可分为以下几类
- LOAD 指令:将一个数值加载入 accumulator 或者 index register,这个值可以为一个立即数( immediate value )、报文的指定偏移、报文长度或者内存空间存放的值
- STORE 指令:将 accumulator 或者 index register 中存储的值存入内存空间
- ALU 指令:对 accumulator 存储的数进行逻辑或者算术运算
- BRANCH 指令:简单的 if 条件控制指令的执行流
- RETURN 指令:退出虚拟机,若返回 FALSE (0),则表示丢弃该报文
- 其他指令:accumulator 和 index register 的值的相互传递
对应的解释,其中第二列为寻址模式:
=========== =================== =====================
指令 寻址模式 解释
=========== =================== =====================
ld 1, 2, 3, 4, 12 Load word into A
ldi 4 Load word into A
ldh 1, 2 Load half-word into A
ldb 1, 2 Load byte into A
ldx 3, 4, 5, 12 Load word into X
ldxi 4 Load word into X
ldxb 5 Load byte into X
st 3 Store A into M[]
stx 3 Store X into M[]
jmp 6 Jump to label
ja 6 Jump to label
jeq 7, 8, 9, 10 Jump on A == <x>
jneq 9, 10 Jump on A != <x>
jne 9, 10 Jump on A != <x>
jlt 9, 10 Jump on A < <x>
jle 9, 10 Jump on A <= <x>
jgt 7, 8, 9, 10 Jump on A > <x>
jge 7, 8, 9, 10 Jump on A >= <x>
jset 7, 8, 9, 10 Jump on A & <x>
add 0, 4 A + <x>
sub 0, 4 A - <x>
mul 0, 4 A * <x>
div 0, 4 A / <x>
mod 0, 4 A % <x>
neg !A
and 0, 4 A & <x>
or 0, 4 A | <x>
xor 0, 4 A ^ <x>
lsh 0, 4 A << <x>
rsh 0, 4 A >> <x>
tax Copy A into X
txa Copy X into A
ret 4, 11 Return
=========== =================== =====================
寻址模式
寻址模式的定义如下:
=============== =================== ===============================================
寻址模式 语法 解释
=============== =================== ===============================================
0 x/%x Register X
1 [k] BHW at byte offset k in the packet
2 [x + k] BHW at the offset X + k in the packet
3 M[k] Word at offset k in M[]
4 #k Literal value stored in k
5 4*([k]&0xf) Lower nibble * 4 at byte offset k in the packet
6 L Jump label L
7 #k,Lt,Lf Jump to Lt if true, otherwise jump to Lf
8 x/%x,Lt,Lf Jump to Lt if true, otherwise jump to Lf
9 #k,Lt Jump to Lt if predicate is true
10 x/%x,Lt Jump to Lt if predicate is true
11 a/%a Accumulator A
12 extension BPF extension
=============== =================== ===============================================
指令格式
其支持的指令的长度也是固定的:
opcode:16bit opcode,其中包括了特定的指令;jt:jump if truejf:jump if falsek:多功能字段,存放的什么内容,根据 op 类型来解释。
BPF 程序经过 bpf_asm 处理之后变成一个 struct sock_filter 类型的数组 (这个结构体前面介绍过),因此数组中的每个元素都是以如下格式编码。
对应到内核的数据结构:
sock_filter
|
|
sock_fprog
要实现 socket filtering,需要通过 setsockopt(2) 将一个 struct sock_fprog 指针传递给内核(后面有例子)。 这个结构体的定义:
|
|
命令解析
tcpdump 支持使用 -d 参数来显示过滤规则转换后的 bpf 汇编指令。在抓包时我们并不关心如何具体的编写 struct sock_filter 内的东西,因为 tcpdump 已经内置了这样的功能。如想要对所接受的数据包过滤,只想抓取 TCP 协议、端口为 8080 数据包,那么在 tcpdump 当中的命令就是 tcpdump ip and tcp port 8080 。
以下是使用 tcpdump -d 看到过滤规则转换后的 bpf 汇编指令:
$ tcpdump -d "ip and tcp port 8080"
(000) ldh [12] # 加载 Ethernet 偏移 12 字节,即以太网类型字段
(001) jeq #0x800 jt 2 jf 12 # 如果是以太网类型为 0x800 IPv4 则跳到 2, 否则跳到 12
(002) ldb [23] # 加载 Ethernet 偏移 23 字节,也就是 IP 偏移 9 字节,也即传输层协议
(003) jeq #0x6 jt 4 jf 12 # 如果传输层协议是 0x6 (TCP),则跳到 4,否则跳到12
(004) ldh [20] # 加载 Ethernet 偏移 20 字节,也就是 IP 偏移 6 字节,对应于 Fragment Offset
(005) jset #0x1fff jt 12 jf 6 # 如果 Fragment Offset 是全 1,则跳到 12
(006) ldxb 4*([14]&0xf) # 将 IPv4 首部中的 IHL * 4 的值加载到 index register,即得到 IPv4 首部的长度 (为了得到找到 TCP 首部的位置)
(007) ldh [x + 14] # 将 TCP 首部中的 Source Port 的值加载到 accumulator. eg. 不包含 IP 选项时,x = 20, 那么这里就等效于 [34]
(008) jeq #0x1f90 jt 11 jf 9 # 如果端口是 8080(0x1f90),则跳到 11
(009) ldh [x + 16] # 将 TCP 首部中的 Destination Port 的值加载到 accumulator. eg. 不包含 IP 选项时,x = 20, 那么这里就等效于 [36]
(010) jeq #0x1f90 jt 11 jf 12 # 如果端口是 8080(0x1f90),则跳到 11
(011) ret #262144 # 返回非0 表示该报文通过过滤
(012) ret #0 # 返回0 表示该报文需要丢弃
为了进一步分析这条案例的流程,先把数据包的帧格式和 ip 数据报的格式放下面便于分析:
以太网帧格式:
IP 数据报格式:
tcpdump 支持使用 -dd 参数将匹配信息包的代码以 C 语言程序段的格式给出:
|
|
像 c 当中的数组的定义,这个就是过滤 tcp 8080 数据包的 struct sock_filter 的数组代码。
预定义 offset
我们最常见的用法莫过于从数据包中取某个字的数据来做判断。按照 bpf 的规定,我们可以使用偏移来指定数据包的任何位置,而很多协议很常用并且固定,例如端口和 ip 地址等,bpf 就为我们提供了一些预定义的变量,只要使用这个变量就可以直接取值到对应的数据包位置。例如:
|
|
以上提到的负 offset 和具体 extension 的 offset,定义见 include/uapi/linux/filter.h:
|
|
在 kernel/bpf/core.c 等地方使用:
|
|
cBPF 与 eBPF 转换
cBPF 在一些平台还在使用,这个代码就和用户空间使用的那种汇编是一样的,但是在 X86 架构,现在在内核态已经都切换到使用 eBPF 作为中间语言了。由于用户可以提交 cBPF 的代码,所以首先是将用户提交来的结构体数组进行编译成 eBPF 代码(提交的是 eBPF 就不用了)。然后再将 eBPF 代码转变为可直接执行的二进制。eBPF 汇编框架下的 bpf 语句如下:
|
|
关于 eBPF 的指令集,可以参考我的 这篇文章。
加载 BPF 代码
linux 可以通过 setsockopt 接口加载 BFP 代码,从而在内核加上过滤规则
- 在套接字 socket 附加 filter 规则 :
|
|
- 把 filter 从 socket 上移除 :
|
|
下面一段代码演示了通过 setsockopt 将 BPF 代码加载进内核的方法,里面用到的两个结构体 struct sock_filter 和 struct sock_fprog 在前一节介绍过了:
|
|
以上代码将一个 filter attach 到了一个 PF_PACKET 类型的 socket,filter 的功能是 放行所有 IPv4/IPv6 22 端口的包,其他包一律丢弃。
以上只展示了 attach 代码;detach 时,setsockopt(2) 除了 SO_DETACH_FILTER 不需要其他参数; SO_LOCK_FILTER 可用于防止 filter 被 detach,需要带一个整形参数 0 或 1。
注意 socket filters 并不是只能用于 PF_PACKET 类型的 socket,也可以用于其他 socket 家族。
tcpdump 抓包原理
使用 strace 追踪
|
|
创建 PF_PACKET 类型 Socket
可以看到 tcpdump 抓包创建的的套接字类型 AF_PACKET
在 libpcap 库源码中也可以看到有调用 socket 系统调用:
|
|
AF_PACKET 和 socket 应用结合一般都是用于抓包分析,packet 套接字提供的是 L2 的抓包能力。
|
|
系统调用:socket
|
|
socket 创建函数:
|
|
__sock_create
sock_create 函数主要就是创建了 socket ,同时根据之前 PF_PACKET 模块注册到全局变量 net_families。 找到 af_packet.c 中初始化的 static const struct net_proto_family packet_family_ops。
__sock_create函数中 err = pf->create(net, sock, protocol, kern) 最终就会调用 packet_family_ops 里的packet_create
|
|
Linux 内核中定义了 net_proto_family 结构体,用来指明不同的协议族对应的 socket 创建函数,family 字段是协议族的类型,create 是创建 socket 的函数,如下是 PF_PACKET 对应结构体。
|
|
packet_create
找到 AF_PACKET 协议族对应的 create 函数:
- 可以看到
po->prot_hook.func = packet_rcv;po->prot_hook 其实 packet_type,packet_type 结构体: packet_type 结构体第一个 type 很重要,对应链路层中 2 个字节的以太网类型。 - 而 dev.c 链路层抓取的包上报给对应模块,就是根据抓取的链路层类型,然后给对应的模块处理,例如 socket(PF_PACKET, SOCK_DGRAM, htons(ETH_P_ALL)); ETH_P_ALL 表示所有的底层包都会给到 PF_PACKET 模块的处理函数,这里处理函数就是 packet_rcv 函数。
设置了回调函数:packet_rcv,并通过 register_prot_hook(sk)完成了注册,其中注册过程将再下面分析
|
|
packet_sock
|
|
packet_type
|
|
register_prot_hook(sk)
|
|
dev_add_pack
|
|
综上:tcpdump 在刚开始工作时创建了 PF_PACKET 套接字,并在全局的 ptype_all 中挂载了该套接字的 pt(packet_type *pt) ,其中 pt 的字段 func 设置了相应的回调函数 packet_rcv (后面将分析该函数),到此 tcpdump 抓包的 socket(AF_PACKET)创建完成,相应的准备工作完成。
网络收包时抓包
函数调用关系
- netif_receive_skb –>
- netif_receive_skb–>
- netif_receive_skb_internal->
__netif_receive_skb–>__netif_receive_skb_core
- netif_receive_skb_internal->
- netif_receive_skb–>
__netif_receive_skb_core
|
|
__netif_receive_skb_core 函数在遍历 ptype_all 时,同时也执行了deliver_skb(skb, pt_prev, orig_dev);
deliver_skb
deliver 函数调用了 packet_type.func(),也就是 packet_rcv ,如下源码所示:
|
|
packet_rcv
下面将展开 packet_rcv 函数进行分析;函数接收到链路层网口的数据包后,会根据应用层设置的 bpf 过滤数据包,符合要求的最终会加到 struct sock sk 的接收缓存中。使用 BPF 过滤过程将在后面进行分析。
|
|
packet_recvmsg
综上一旦关联上链路层抓到的包就会 copy 一份给上层接口(即 PF_PACKET 注册的回调函数 packet_rev)。而回调函数会根据应用层设置的 bpf 过滤数据包,最终放入接收缓存的数据包肯定是符合应用层想截取的数据。因此最后一步 recvfrom 也就是从接收缓存的数据包 copy 给应用层,如下源码:
|
|
到这,网络接收数据包时的抓包过程就结束了
网络发包时抓包
Linux 协议栈中提供的报文发送函数有两个:
- 一个是链路层提供给网络层的发包函数
dev_queue_xmit() - 一个是软中断发包函数之间调用的
sch_direct_xmit() - 这两个函数最终都会调用
dev_hard_start_xmit()
dev_hard_start_xmit
|
|
xmit_one
发送一个到多个数据包
|
|
dev_queue_xmit_nit
将数据包发送给 driver
|
|
在遍历 ptype_all 时,同时也执行了deliver_skb(skb, pt_prev, orig_dev)
deliver_skb
deliver 函数调用了 paket_type.func(),也就是 packet_rcv ,如下源码所示:
|
|
下面的流程就和网络收包时 tcpdump 进行抓包一样了:
- packet_rcv 函数中会将用户设置的过滤条件,通过 BPF 进行过滤,并将过滤的数据包添加到接收队列中,应用层在 libpcap 库中调用 recvfrom 。
- PF_PACKET 协议簇模块调用 packet_recvmsg 将接收队列中的数据 copy 应用层
BPF 虚拟机过滤报文
如前所述,tcpdump 根据通过 pcap_compile 将用户传来的过滤规则编译成 BPF 代码,然后通过 pcap_setfilter将 BPF 程序注入到内核。在网络收发包路径中会遍历 ptype_all 链表,从而调用到 packet_rcv 函数,进而执行到对应的 BPF 代码根据用户指定的过滤条件进行过滤。其中最关键的函数即是 run_filter
本节围绕的重点是:BPF 的过滤原理,如下源码所示:run_filter(skb, sk, snaplen),本次文章将对 BPF 的过滤原理进行一些分析。
|
|
pcap_setfilter_linux
libpcap 的 pcap_setfilter_linux 在 linux 平台中会调用 pcap_setfilter_linux
|
|
进一步调用 pcap_setfilter_linux_common:
|
|
上面涉及到的 Linux 内核中的 struct sock_fprog 和 libpcap 库中的 struct bpf_program 如下所示:
|
|
install_bpf_program
install_bpf_program(handle, filter) 把 BPF 代码拷贝到 pcap_t 数据结构的 fcode 上:
|
|
set_kernel_filter
pcap_setfilter_linux_common最终会在set_kernel_filter中调用setsockopt系统调用。函数执行到这才真正进入内核,开始在 Linux 内核上安装和设置 BPF 过滤器,通过 SO_ATTACH_FILTER 下发给内核底层,从而让规则生效,设置过滤器。
|
|
在 liunx 上,只需要简单的创建的 filter 代码,通过 SO_ATTTACH_FILTER 选项发送到内核,并且 filter 代码能通过内核的检查,这样你就可以立即过滤 socket 上面的数据了。
setsockopt
Linux 在安装和卸载过滤器时都使用了函数 setsockopt(),其中标志 SOL_SOCKET 代表了对 socket 进行设置,而 SO_ATTACH_FILTER 和 SO_DETACH_FILTER 则分别对应了安装和卸载。
- 在套接字 socket 附加 filter 规则 :
|
|
- 把 filter 从 socket 上移除 :
|
|
Linux 内核在sock_setsockopt函数中进行设置:
|
|
上面出现的 sk_attach_filter() 定义在 net/core/filter.c,它把结构 sock_fprog 转换为结构 sk_filter, 最后把此结构设置为 socket 的过滤器:sk->filter = fp。
run_filter
回到抓包的引入:
|
|
run_filter:
|
|
而 run_filter 的功能就是取下 sk 上设置的 sk_filter 结构, 然后最终执行了 BPF_PROG_RUN:
BPF_PROG_RUN
|
|
原理总结
tcpdump 进行抓包的内核流程梳理
- 应用层通过 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
- 调用系统调用创建 socket,
- 网络收包/发包时,会在各自的处理函数中遍历 ptype_all 链表,并同时执行其回调函数,这里 tcpdump 的注册的回调函数就是 packet_rcv
- 收包时:
__netif_receive_skb_core - 发包时:
dev_queue_xmit_nit
- 收包时:
- packet_rcv 函数中会将用户设置的过滤条件,通过 BPF 进行过滤,并将过滤的数据包添加到接收队列中
- 应用层调用 recvfrom 。 PF_PACKET 协议簇模块调用 packet_recvmsg 将接收队列中的数据 copy 应用层,到此将数据包捕获到。
参考资料
-
No backlinks found.