macOS 内核之网络信息抓包(三)

2019年11月4日 · 4 years ago

macOS 内核之网络信息抓包(三)

经过前两篇提到的尝试之后,终于来到 BPF 了。由于 nstat 在内核中定义为私有接口,所以它的数据虽然现成,用起来却一点也不简单。那么有没有更厉害一点的方法呢?

朋友听说我在学习这方面的技术,于是推荐了一个关键词: BPF。我们知道抓包界有一个大名鼎鼎的工具叫做 tcpdump,它的核心原理就是使用了 BPF 技术(基于 pcap 接口)。

一、什么是 BPF?

我阅读了 1992 年 BPF 发表的论文,顺带发现了 Wireshake 的 SharkFest '11 KeynotePDF,才知道原来 TCPDump 是 Steve McCanne 1988 年在加州大学伯克利分校选修编译器课程的时候,跟其他同学一起做的,BPF 可以看做是当时他们做 tcpdump 时顺手开发的。有点像我们上大学时老师要求做的大作业,只不过人家的大作业是改变世界的大作业😂。

当时 Steve 和同学组成一个四个人的 Research Group:

  • Steve McCanne
  • Van Jacobson
  • Sally Floyd
  • Vern Paxson

其中 Steve McCanne 和 Van Jacobson 负责网络抓包的部分(他们俩也是论文的作者)。他们开始用 Sun 的抓包工具但是用起来非常抓狂,于是他们决定写一个自己的工具,也就是后来的 tcpdump。其中跑在 Unix 内核的部分就是 BPF,Berkeley Packet Filter 的缩写,最后于 1992 年 12 月发表论文。

Packet Filter 这种技术是为了网络监控程序设计的,我们知道内核空间与用户空间的虚拟内存实现不同,如果要从内核传递数据到用户空间需要经过地址空间转换,还要 copy 数据,是一种比较耗时的操作。(这里 Unix 和 Linux 的虚拟内存实现还不一样,我尚未仔细学习,目前只知道操作耗时。)

为了减少 copy 操作,早期有些 Unix 系统提供了包过滤技术,比如 CMU/Stanford Packet Filter。BPF 论文发表的时候称性能比 Sun's NIT 快 100 倍,吊打所有对手。这篇论文并不长有兴趣的读者可以看一下: The BSD Packet Filter: A New Architecture for User-level Packet Capture

根据我的阅读理解,Packet Filter 技术应该都会提供 pseudo-machine (伪代码虚拟机)把 bytecode (字节码)转为机器码,也就是虚拟机,著名的虚拟机比如 Java 的 JVM,把源码转成 .class 的字节码然后每个平台各自跑个虚拟机从而实现跨平台。BPF 的操作也是通过 bytecode 编写。FreeBSD, NetBSD 都提供了 JIT 编译器给 BPF,Linux 也有不过默认是关的。

由于 BPF 设计的时候摒弃了以前 Packet Filter 基于栈设计(Stack based)的虚拟机的做法(比如 JVM 就是),改为使用基于寄存器(Register based)设计的虚拟机,充分利用了当时还算新技术的 CPU RISC (精简指令集)的优势。(题外: RISC 的发明者 David Patterson 也是加州大学伯克利分校的)

另外 BPF 还做了一个看似非常小的改进:在内核层接到 device interface 丢过来的包时就进行 filter,不需要的包直接丢弃,不会多出任何无效 copy。从而比旧时代的技术有着显著的性能优势。论文中他们还提到 BPF 的多项优化细节,这里不再赘述,有兴趣的读者可自行阅读论文。

总而言之 BPF 技术提供了一个原始接口,可以获取 Data Link Level (数据链路层)的数据包,并且支持数据包过滤,由于采用虚拟机在内核层直接执行 bytecode,所以过滤逻辑实际上跑在内核层,性能十分优越。在 OSI 模型中,Link Level 是最接近物理层的了,在这一层抓包当然是最王道的选择啦。

P.S. 系统内核是没必要走 Packet Filter 的,这个技术是给用户空间的 App 用的,内核本来就有所有数据包,所以 nstat 不会用到这些技术。

二、BPF/pcap 抓包

2.1 裸写 BPF 指令

如第一节所说,bpf 在内核层实现了一个可以执行 bpf 字节码的虚拟机,所以理论上我们可以裸写 bpf 指令,跟写汇编差不多。XNU 的 BSD 部分实现了 bpf,需要引入头文件:

#import <net/bpf.h>

以下是 BPF program 示例代码(来自 Mac OS X Internals):

int installFilter(int   fd, 
         unsigned char  Protocol, 
             unsigned short Port)
{
    struct bpf_program bpfProgram = {0};

    /* Dump IPv4 packets matching Protocol and (for IPv4) Port only */

    /* @param: fd - Open /dev/bpfX handle.               */

    const int IPHeaderOffset = 6 + 6 + 2; /* 14 */

    /* Assuming Ethernet (DLT_EN10MB) frames, We have: 
     *  
     * Ethernet header = 14 = 6 (dest) + 6 (src) + 2 (ethertype)
     * Ethertype is 8-bits (BFP_P) at offset 12
     * IP header len is at offset 14 of frame (lower 4 bytes). 
     * We use BPF_MSH to isolate field and multiply by 4
     * IP fragment data is 16-bits (BFP_H) at offset  6 of IP header, 20 from frame
     * IP protocol field is 8-bts (BFP_B) at offset 9 of IP header, 23 from frame 
     * TCP source port is right after IP header (HLEN*4 bytes from IP header)
     * TCP destination port is two bytes later
     *
     * Note Port offset assumes that this Protocol == IPPROTO_TCP!
     * If it isn't, adapting this to UDP port is left as an exercise to the reader,
     * as is extending this to support IPv6, as well..
     */

 struct bpf_insn insns[] = {

 /* Uncomment this line to accept all packets (skip all checks) */
 // BPF_STMT(BPF_RET + BPF_K, (u_int)-1),                   // Return -1 (packet accepted)

 BPF_STMT(BPF_LD  + BPF_H   + BPF_ABS, 6+6),             // Load ethertype 16-bits from 12 (6+6)
 BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ETHERTYPE_IP, 0, 10), // Test Ethertype or jump(10) to reject
 BPF_STMT(BPF_LD  + BPF_B   + BPF_ABS, 23),              // Load protocol (= IP Header + 9 bytes) 
 BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K  , Protocol, 0, 8),  // Test Protocol or jump(8) to reject 
 BPF_STMT(BPF_LD  + BPF_H   + BPF_ABS, IPHeaderOffset+6),// Load fragment offset field 
 BPF_JUMP(BPF_JMP + BPF_JSET+ BPF_K  , 0x1fff, 6, 0),    // Reject (jump 6) if more fragments
 BPF_STMT(BPF_LDX + BPF_B   + BPF_MSH, IPHeaderOffset),  // Load IP Header Len (x4), into BPF_IND
 BPF_STMT(BPF_LD  + BPF_H   + BPF_IND, IPHeaderOffset),  // Skip hdrlen bytes, load TCP src
 BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K  , Port, 2, 0),      // Test src port, jump to "port" if true

 /* If we're still here, we know it's an IPv4, unfragmented, TCP packet, but source port
  * doesn't match - maybe destination port does? 
  */

 BPF_STMT(BPF_LD  + BPF_H   + BPF_IND, IPHeaderOffset+2), // Skip two more bytes, to load TCP dest
/* port */
 BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K  , Port, 0, 1),       // If port matches, ok. Else reject
/* ok: */
 BPF_STMT(BPF_RET + BPF_K, (u_int)-1),                    // Return -1 (packet accepted)
/* reject: */
 BPF_STMT(BPF_RET + BPF_K, 0)                             // Return 0  (packet rejected)
    };

先初始化一个 bpf_program 结构体:

struct bpf_program {
    u_int bf_len;
    struct bpf_insn *bf_insns;
};

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

然后编写指令 bpf_insn,看上去像写汇编一样差不多(虽然我不会)。

2.2 使用 libpcap

除了写 *pcap 的人之外,在 Unix 上,一般开发者都用 bpf 作者写的 libpacp 封装来操作 bpf。我在 macOS 10.15 Catalina (19A583) 上用 libpcap 实现了一个简单的抓包逻辑,我们可以看一下去掉错误处理的关键代码:

// 创建一个 bpf_program
struct bpf_program fp;

// 找一下 device interface
char *dev = pcap_lookupdev(errbuf);

// 获取 IP 和 netmask
bpf_u_int32 mask;
bpf_u_int32 net;
pcap_lookupnet(dev, &net, &mask, errbuf);

// 打开一个 pcap session
pcap_t *handle = pcap_open_live(dev, BUFSIZ, 1, 1000, errbuf);

我们看下这个函数原型:

pcap_t *pcap_open_live(char *device, int snaplen, int promisc, int to_ms,
        char *ebuf)

第一个参数 device 就是 pcap_lookupdev 拿到的 device 了,第二个 snaplen 是 pcap 可以捕获的最大长度,这里填 stdio.h 定义的值 BUFSIZ,也就是 1024 bytes(官网教程说的是 pcap.h 有但是我没找到,只在 stdio.h 里找到了)。

第三个参数 promisc 是 promiscuous mode 是否打开。promiscuous mode 中文翻译为混杂模式,没打开的时候我们只能获取目标地址为该 interface 的包,打开了之后经过它的包也可以被我们抓到。

第四个参数 to_ms 是设置超时时间,以 ms 为单位,填 0 就是不设置超时。

最后一个参数 ebuf 就是错误信息返回了。传入 char *errbuf[PCAP_ERRBUF_SIZE]; 就行。

上一篇我们讲过 PPP 和 Ethernet 包有所不同,如果你只想处理 Ethernet 包的话你可以通过 pcap_datalink() 接口判断 link-layer header。

if (pcap_datalink(handle) != DLT_EN10MB) {
        fprintf(stderr, "Device %s doesn't provide Ethernet headers - not supported\n", dev);
        return(2);
}

前面说过 bpf_program 里都是存的字节码指令,所以我们得编译一下:

char filter_exp[] = "port 23";
pcap_compile(handle, &fp, filter_exp, 0, net)

最后把 filter 设置好:

pcap_setfilter(handle, &fp)

然后我们就可以愉快地抓包了。使用 pcap_next() 可以获得一个 filter 过的包。

/* Grab a packet */
packet = pcap_next(handle, &header);
/* Print its length */
printf("Jacked a packet with length of [%d]\n", header.len);
/* And close the session */
pcap_close(handle);

完整示例可以参考 tcpdump 官网的这篇文章: Programming with pcap

2.3 pcap_loop

一般情况下我们不会只抓一个包,我们可以用 pcap_loop() 来循环抓包:

int pcap_loop(pcap_t *p, int cnt, pcap_handler callback, u_char *user)

第一个参数就是上面创建的 handle 了,第二个参数 cnt 是说抓了多少个包之后回调给你。第三个函数 pcap_handler 就是你的回调函数,最后一个是上下文参数,透传的。

回调函数 pcap_handler 的原型如下:

typedef void (*pcap_handler)(u_char *arg, const struct pcap_pkthdr *, const u_char *packet);

第一个参数 arg 就是 pcap_loop() 注册时最后一个上下文参数,你自己传的。

第二个参数 pcap_pkthdr 是 pcap 包头,第三个参数 packet 就是网络包啦,解析这两个参数我们就能获得包信息。

struct pcap_pkthdr {
    struct timeval ts;   time stamp 
    bpf_u_int32 caplen;  length of portion present 
    bpf_u_int32;         lebgth this packet (off wire) 
}

因为前面可以设置抓包阈值,所以包本身的时间放在 pcap_pkthdr 里面。

我们只关心外网 IP 包,不关心 ARP 包,另外 PPP 先不处理,所以过滤一下:

if (ntohs (eptr->ether_type) == ETHERTYPE_IP) {}

然后可以打印出来了:

int i;
u_char *ptr; /* printing out hardware header info */
/* copied from Steven's UNP */
ptr = eptr->ether_dhost;
i = ETHER_ADDR_LEN;
printf(" Destination Address:  ");
do{
    printf("%s%x",(i == ETHER_ADDR_LEN) ? " " : ":",*ptr++);
}while(--i>0);
printf("\n");

ptr = eptr->ether_shost;
i = ETHER_ADDR_LEN;
printf(" Source Address:  ");
do{
    printf("%s%x",(i == ETHER_ADDR_LEN) ? " " : ":",*ptr++);
}while(--i>0);
printf("\n");

输出结果:

Ethernet type hex:800 dec:2048 is an IP packet
 Destination Address:   0:0:c:7:ac:ec
 Source Address:   dc:a9:4:77:9c:41
Ethernet type hex:800 dec:2048 is an IP packet
 Destination Address:   0:0:c:7:ac:ec
 Source Address:   dc:a9:4:77:9c:41

这样,所有的 IP packet 的 Mac 地址都被我们打印出来了。如果我想打印 IPv4 地址,以及 TCP 协议的端口呢?

2.4 处理 TCP 包

TCP 是 IP 上层的协议,如果我们要抓 TCP 的包我们可以判断一下 IP packet 里的 protocol number。不过在那之前,我们要先从 packet 里面解出 IP 信息和 TCP 信息。我们参考一下整个包的内存结构:

Variable Location (in bytes)
Ethernet x
IP x + SIZE_ETHERNET
TCP x + SIZE_ETHERNET + {IP header length}
payload x + SIZE_ETHERNET + {IP header length} + {TCP header length}
// 原型可见 bsd/netinet/ip.h
// 这里参考 https://www.tcpdump.org/pcap.html
struct sniff_ip {
#ifdef _IP_VHL
    u_char  ip_vhl;         /* version << 4 | header length >> 2 */
#else
#if BYTE_ORDER == LITTLE_ENDIAN
    u_int   ip_hl:4,        /* header length */
        ip_v:4;         /* version */
#endif
#if BYTE_ORDER == BIG_ENDIAN
    u_int   ip_v:4,         /* version */
        ip_hl:4;        /* header length */
#endif
#endif /* not _IP_VHL */
    u_char  ip_tos;         /* type of service */
    u_short ip_len;         /* total length */
    u_short ip_id;          /* identification */
    u_short ip_off;         /* fragment offset field */
#define    IP_RF 0x8000            /* reserved fragment flag */
#define    IP_DF 0x4000            /* dont fragment flag */
#define    IP_MF 0x2000            /* more fragments flag */
#define    IP_OFFMASK 0x1fff       /* mask for fragmenting bits */
    u_char  ip_ttl;         /* time to live */
    u_char  ip_p;           /* protocol */
    u_short ip_sum;         /* checksum */
    struct  in_addr ip_src,ip_dst;  /* source and dest address */
};

出于学习目的我们只看 Ethernet 包,Ethernet 包的包头规定是 14 byets,所以我们偏移 14 bytes 就能得到包体。

#define SIZE_ETHERNET 14
ip = (struct sniff_ip*)(packet + SIZE_ETHERNET);

IP 协议的规定比较复杂,他的 ip header 长度不是固定的,而是 4 字节长度的 word 的个数。

#define IP_HL(ip)      (((ip)->ip_vhl) & 0x0f)

ip = (struct sniff_ip*)(packet + SIZE_ETHERNET);
size_ip = IP_HL(ip)*4;

TCP header 也不是定长的,同样也是取 4 字节 word 长度的个数。

tcp = (struct sniff_tcp*)(packet + SIZE_ETHERNET + size_ip);
size_tcp = TH_OFF(tcp)*4;

// 剩下的就是 payload 了
payload = (u_char *)(packet + SIZE_ETHERNET + size_ip + size_tcp);

2.5 打印数据

fprintf(stdout,"IP: %s", inet_ntoa(ip->ip_src));
fprintf(stdout,"Port: %s", ntohs(tcp->th_sport));


fprintf(stdout,"IP: %s", inet_ntoa(ip->ip_dst));
fprintf(stdout,"Port: %s", ntohs(tcp->th_dport));

这样我们就获得所有 TCP 包的数据了。

这里使用 ntohs() 进行转换是因为网络层的 byte order 和 host (CPU 架构)的不一样,network byte order 是用大端(big-endian),host 则根据 CPU 架构来,从 Mac OS X 支持 i386 开始就是小端了(little-endian)。所以必须把内存里的数据转换一下才能得到正确的数值。

inet_ntoa() 则是把 network byte order 的结构体 in_addr 转换成一个 IPv4 的 string。

三、小结

以上是如何使用 pcap() 接口抓包。由于我们在 link level 抓的包全都是 packet 数据,可以承载 TCP/UDP, IP/ARP, Ethernet/PPP 等多种非常"原始"的数据,所以处理起来非常感人。

作为学习之用我觉得挺好的,要付诸生产环境还需要不少功夫。

这些 packet 包本身是不带进程信息 pid 的,如果我们要把这些包跟进程关联到一起就还需要额外的处理。一种解决方法是根据每个 TCP 连接中系统给分配的 port,从系统调用反查该 port 对应的进程。但是有可能当我们去查询的时候这个连接已经断开了(虽然讲道理 bpf 截获数据包比真正接包的应用还早,但我们可以设置回调间隔,所以不一定),所以也不一定靠谱。我本来也研究了一下如何从系统获取所有 process 和对应分配的 port,但是很笨地跟上面那一堆 pcap 代码一起忘记 commit 了。所以我重新学习了一遍 pcap 使用,但是不想再去尝试 process 获取 port 了 XD。

网络层是我目前学习内核遇到最复杂的一部分,涉及的知识点太多,接口非常古老,缺乏文档,需要好好理解上述代码如何处理 packet 的话,我还得阅读 RFC 对 TCP/UDP/IP 等协议的规定。所以我选择了放弃,还是学点其他的知识好了。

在阅读 BPF 论文的时候,也对这些能做出厉害东西的程序员十分叹服。同时也觉得有些时候我们认为一些技术非常神秘难懂,觉得非常黑科技,但如果能有源码可读,能有论文可辅助,其实原理并不是很难。难的是发明这些技术的人,不仅能理解和掌握这么复杂的技术,而且能把这些离散的点连接起来创造出厉害的东西。

内核系列文章

参考资料