前段时间看到有项目需求需要在 Linux 环境下抓取网络封包进行分组解析。因为还没有相关的网络编程开发经历,因此借此机会学习了 Linux 下一个很常用的网络抓包库 libpcap 的抓包方法。

开发环境准备

笔者的开发环境是 Manjaro Linux,其使用的 KDE 版本是 5.18.5。

Manjaro Linux 使用的是 Arch Linux 内核,轻量级的 Linux。

不得不说 KDE 的桌面在 Manjaro Linux 下做得很漂亮。

我们需要使用到 libpcap 库,因此在开发时要确保系统中有这个库,在 Manjaro Linux 桌面环境下默认已经安装好了。如果不确定,可以在终端执行这段命令安装。

sudo pacman -Sy libpcap

抓包基本过程

查阅 libpcap 的官方开发文档,可知 libpcap 是 tcpdump 项目的重要模块之一。

官方文档给出了很详细的实现特定网卡设备抓包的实施方案。

使用 libpcap 库时,需要在代码中包含其头文件。

#include <pcap/pcap.h>

在开始抓取某个网卡的分组的操作之前,首先要取得指定网卡设备的句柄。需要使用到 pcap_create() 函数,这个函数有两个参数。

第一个参数是需要打开的网卡设备的名称的字符串,第二个参数是一个缓冲区,用于接收函数执行过程中出现的错误原因。其返回值便是网卡设备的句柄,打开设备失败返回 NULL。

通常来说,用户设备上的网卡设备多种多样,对应的设备名称也不尽相同,因此我们应该打开用户最常用的网卡设备(LAN 和 WLAN 等)。

为了获取这些网卡设备的名称,可以借助到 libpcap 的 pcap_findalldevs() 函数来查找设备上所有的网卡设备。

这个函数带有两个参数,一个是用于接收回馈来的网卡设备链表结构的头结点指针,另一个参数用于接收函数执行过程中出现的错误信息。返回值为0则函数执行成功,返回值为 PCAP_ERROR 则函数执行失败。

  • 注意:即使设备上没有任何网卡设备,它也同样认为执行成功,返回0。

在执行完 pcap_findalldevs() 后,因为生成的设备链表是在堆中动态分配内存来存放的,所以我们还需要自行调用 pcap_freealldevs() 函数来释放链表,避免内存泄漏。

下面这段代码,用于打印出设备上所有网卡设备名称。在本文中,为获取网卡设备的句柄,我们只关心网卡设备的名称,因此结点结构里包含的其他信息我们不关心。

void showNetworkDevice() {
        char errbuf[PCAP_ERRBUF_SIZE];
        pcap_if_t *head, *node;
        pcap_findalldevs(&head, errbuf);
        
        node = head;
        while(node) {
                printf("%s\n", node->name);
                node = node->next;
        }
        
        pcap_freealldevs(head);
}

上述代码中,showNetworkDevice() 函数的执行结果如下图所示:

设备上的网卡设备名称列表

上图中,第一个输出结果,wlp0s18f2u3,也就是设备链表的头结点的 name 属性,就是笔者设备的 Wi-Fi 网卡设备的名称。接下来我们打开设备获取 Wi-Fi 设备句柄的时候就可直接使用这个设备名称。

另外,笔者在官网文档中查阅到,在较老版本的 libpcap 中,pcap_findalldevs() 函数并不存在,而是借助另一个函数 pcap_lookupdev() 函数来获取「第一个」网卡设备的名称。但这个函数现在已经被否决,为了兼容性现在依然可以使用。

通过调用 pcap_create() 函数取其返回值获得网卡设备的句柄后,为了能激活抓包程序,还需要调用 pcap_activate() 函数。这个函数只有一个参数也就是刚才得到的网卡设备句柄。

激活抓包程序后,我们就可以开始监听网卡设备并抓包了。

抓包需要调用到函数 pcap_dispatch() 函数,当然官网上还说明也可以使用 pcap_loop() 函数,这两个函数的参数相同并且作用类似,但前者还可以读取提前保存在打开的网卡设备句柄的一组分组,而后者则是循环抓包直到中断或者错误发生。笔者这里使用 pcap_dispatch() 函数。

这个函数有4个参数,第一个是网卡设备的句柄;第二个是要抓取分组的数量,-1和0都代表无限抓包直到错误发生或者程序主动调用 pcap_breakloop() 函数跳出抓包事务;第三个参数是回调函数,通常情况下,在抓取的包的数量达到了第二个参数指定的数量时,回调函数会被调用;第四个参数是用户参数,这个参数会在回调函数调用时传递给回调函数(方便在开发时做上下文同步)。这个函数若执行成功会返回0,否则返回 PCAP_ERROR。

  • 注意:通常情况下,只有在抓取的分组达到特定的数量时,回调函数才会被调用,此时有多少个分组就调用多少次回调函数,但并不是每抓到一个分组就立即调用一次回调函数。

回调函数的格式如下:

void callback(u_char *user, const struct pcap_pkthdr *h, const u_char *bytes) 

这是一个空返回值的函数,第一个参数是用户参数,从 pcap_dispatch() 函数的第四个参数传递过来;第二个参数是分组的相关信息(包含有分组抓取的时间,分组数据长度等);第三个参数是这个分组的数据的首数据指针,以字节为单位。

  • 注意:libpcap 抓取到的分组均是处于数据链路层的分组,在计算机网络里称为「帧」(frame)。

在执行 pcap_dispatch() 函数后,程序会处于阻塞状态,直到达到了要抓取的分组数量才会继续。在我们的抓包工作完成后,需要关闭网卡设备,销毁创建的句柄,因此需要使用到 pcap_close() 函数。只需传入第一个参数打开的网卡设备的句柄即可关闭这个设备。

以上就是简单利用 libpcap 实现抓包的基本过程,下面介绍一些进阶性的用法。

设置过滤器抓取指定类型的分组

通常来说,抓取所有的网络分组而不进行任何的分类和过滤是不符合实际的,因此在开发过程中我们常常会需要过滤掉一些不感兴趣的分组,保留我们需要分析研究的分组。下面就以抓取 HTTP 明文协议(IPv4)的分组为例在 libpcap 中设置过滤器。

查阅官网文档,设置抓包过滤器主要用到 pcap_compile() 和 pcap_setfilter() 函数。

pcap_compile() 函数用于编译生成一个过滤器结构,用于传递给后续调用的 pcap_setfilter() 函数。其一共有5个参数。第一个参数是打开的网卡设备句柄;第二个参数用于接收生成的过滤器结构的容器的指针;第三个参数是需要编译生成的过滤器的代码字符串;第四个参数是优化控制开关(我们传入0);第五个参数是子网掩码,我们可以利用这个参数来抓取指定子网掩码的分组,但如果不关心这个,我们可以传入 PCAP_NETMASK_UNKNOWN,抓取任意子网掩码的分组。这个函数的返回值如果为0则成功,否则返回 PCAP_ERROR,相关的错误信息可以使用 pcap_geterr() 函数和 pcap_perror() 函数获取。

在获得编译成功的过滤器结构后,我们可以传入到 pcap_setfilter() 函数里。这个函数用于设置指定网卡设备句柄对应的过滤器。第一个参数便是打开的网卡设备的句柄,第二个参数是过滤器结构的指针。这个函数的返回值如果为0则成功,否则返回 PCAP_ERROR,相关的错误信息可以使用 pcap_geterr() 函数和 pcap_perror() 函数获取。

官网文档中关于过滤器代码的介绍,笔者不加详述。笔者接下来借用官网给出的抓取 HTTP 协议过滤器代码进行演示。

过滤器的代码如下:

tcp port 80 and (((ip[2:2] - ((ip[0]&0xf)<<2)) - ((tcp[12]&0xf0)>>2)) != 0)

上述过滤器代码可以直接以字符串形式传入到 pcap_compile() 函数的第三个参数里。

以下代码用于编译并为网卡设备句柄设置过滤器。

        int ret = pcap_compile(handler, &fp, "tcp port 80 and (((ip[2:2] - ((ip[0]&0xf)<<2)) - ((tcp[12]&0xf0)>>2)) != 0)", 0, PCAP_NETMASK_UNKNOWN);
        if (ret == PCAP_ERROR) {
                printf("设置过滤器失败,错误原因:%s\n", pcap_geterr(handler));
        }
        pcap_setfilter(handler, &fp);

当然,关于设置过滤器的最后,在我们不需要这个编译生成的过滤器时,需要对这个结构进行释放操作。调用 pcap_freecode() 函数来释放过滤器。

导出分组数据到 pcap 文件并使用 Wireshark 工具查看

libpcap 还支持分组的导出,方便我们对分组的转存和后续借助外部工具进一步分析。

借助 pcap_dump() 函数就能实现分组的导出。

调用这个函数,我们首先需要得到导出文件的文件句柄。使用 pcap_dump_open() 函数来打开一个导出文件句柄。向这个函数传入打开的网卡设备句柄和文件名即可,其返回值便是导出文件的句柄。

我们可以在前文提到的 pcap_dispatch() 函数设置的回调函数里,调用 pcap_dump() 函数,向导出文件写入抓取到的分组。这个函数有三个参数。第一个参数是用户参数(可以传入前面打开的导出文件句柄);第二个参数是分组的信息,可以传入回调函数的第二个参数 h;第三个参数是分组的首数据指针,可以传入回调函数的第三个参数。

在写入分组数据到导出文件后,推荐使用 pcap_dump_flush() 函数刷新导出文件。导出工作完成后,还要记得调用 pcap_dump_close() 函数来关闭打开的导出文件。

下面以抓取 HTTP 明文协议为例,取得分组的源 IP 地址和目的 IP 地址,并导出分组数据到文件,最后借助 Wireshark 工具查看分组数据。

代码如下:

#include <stdio.h>
#include <pcap/pcap.h>
#include <arpa/inet.h>

pcap_t *handler;
pcap_dumper_t *pcap_dumper;

typedef struct {
        unsigned char header_len:4;
        unsigned char version:4;
        unsigned char tos:8;
        unsigned short total_len;
        unsigned short ident;
        unsigned short flags;
        unsigned char ttl:8;
        unsigned char proto:8;
        unsigned short checksum;
        unsigned int src_ip;
        unsigned int dest_ip;
} ip_header;

void callback(u_char *user, const struct pcap_pkthdr *h, const u_char *bytes) {
        ip_header *t = (ip_header *)(bytes+14);
        struct in_addr src_addr;
        struct in_addr dest_addr;
        char src[INET_ADDRSTRLEN];
        char dest[INET_ADDRSTRLEN];

        src_addr.s_addr = (in_addr_t)(t->src_ip);
        inet_ntop(AF_INET, &src_addr.s_addr, src, sizeof(src));

        dest_addr.s_addr = (in_addr_t)(t->dest_ip);
        inet_ntop(AF_INET, &dest_addr.s_addr, dest, sizeof(dest));

        printf("src:%s -> dest:%s\n", 
                src,
                dest);

        pcap_dump((u_char *)pcap_dumper, h, bytes);
        pcap_dump_flush(pcap_dumper);
}

int main() {
        char* dev;
        char errbuf[PCAP_ERRBUF_SIZE];
        struct bpf_program fp;
        int ret;

        dev = pcap_lookupdev(errbuf);
        handler = pcap_create(dev, errbuf);
        if (!handler) {
                printf("打开网卡设备句柄失败,错误原因:%s\n", errbuf);
                return 0;
        }
        pcap_activate(handler);

        ret = pcap_compile(handler, 
                &fp, 
                "tcp port 80 and (((ip[2:2] - ((ip[0]&0xf)<<2)) - ((tcp[12]&0xf0)>>2)) != 0)", 
                0, 
                PCAP_NETMASK_UNKNOWN);
        if (ret == PCAP_ERROR) {
                printf("编译过滤器失败,错误原因:%s\n", pcap_geterr(handler));
        }
        pcap_setfilter(handler, &fp);

        pcap_dumper = pcap_dump_open(handler, "mypack.pcap");

        ret = pcap_dispatch(handler, 2, callback, NULL);
        if (ret == PCAP_ERROR) {
                printf("抓包失败,错误原因:%s\n", pcap_geterr(handler));
        }

        pcap_dump_close(pcap_dumper);
        pcap_freecode(&fp);
        pcap_close(handler);
        return 0;
}

以上代码在编译时,如果使用的是 GCC 编译器,需要在命令行上加入任选项 -lpcap,说明程序需要导入 libpcap 库来编译。

gcc pack.c -o pack -lpcap

程序为了能正常工作,需要管理员权限,在执行过程中注意加上 sudo,或者直接以 root 用户来运行。

sudo ./pack
编译和运行结果

在上述代码中,我们实现的是,过滤并保留 HTTP 协议的分组,抓取其中前2个分组。如图所示,执行程序后,程序会阻塞于此,因为只有程序成功抓取2个我们要求的分组后,程序才会恢复执行。下面我们以「无痕浏览模式」在浏览器中访问这段网址,观察终端的输出结果。

http://gaia.cs.umass.edu/wireshark-labs/HTTP-wireshark-file1.html

终端输出结果

如图所示,我们在终端里看到了两条输出,两条输出中的第一条显示第一个分组是从 IP 192.168.0.101 发送至 IP 128.119.245.12;第二条显示第二个分组是从 IP 128.119.245.12 发送至 IP 192.168.0.101。

从图中我们可知,我们成功抓取到两个分组,并对这两个分组进行简单的解析,获取并显示出它们的源 IP 地址和目的 IP 地址。

接下来我们借助 Wireshark 工具查看导出的分组数据文件 mypack.pcap。

Wireshark 查看结果

如图所示,我们可以在 Wireshark 直接打开导出的分组文件 mypack.pcap。Wireshark 里显示了刚才抓取到的两个分组。

利用这种方法,我们就可以对导出的分组进一步查看和分析。

后记

在呈现本文的技术过程中,笔者还得到了一些意外收获。

(收获都是在踩坑之后才得到的嘛~ _(:3」∠)_ )

使用 inet_ntoa() 函数来转换 IP 地址数值到 IP 地址字符串存在一个易错点,就是其返回值(IP 地址字符串的地址)是存放在静态变量内的,在一个过程中不能连续使用这个函数,否则后面得到 IP 地址的字符串永远只会是第一个调用这个函数得到的 IP 地址字符串。因此在开发过程中,更推荐使用 inet_ntop() 函数,它不存在这个问题,并且还支持 IPv6。

本文以抓取 HTTP 明文协议分组为例,但现如今大部分网站已采用更安全的 HTTPS 加密协议来通信。关于如何抓取并解析 HTTPS 协议的分组,则是另外一个值得去深究的课题了。

网络封包的抓取是计算机网络领域的一个重要课题,在网络信息大爆炸的今天,我们有必要去了解并学习其中获取数据的基本方法。

分类: 技术小记

0 条评论

发表回复

Avatar placeholder

您的电子邮箱地址不会被公开。 必填项已用*标注


The reCAPTCHA verification period has expired. Please reload the page.