Pcap程序设计

Tim Carstens


好,让我们从看看这篇文章写给谁开始。显而易见的,需要一些C语言基础知识,除非你只想了解基本的理论。你不必是一个编码专家,因为这个领域只有经验丰富的程序员涉足,而我将尽可能详细的描述这些概念。另外,考虑到这是有关一个包嗅探器的,所以对网络基础知识的理解是有帮助的。所有在此出现的代码示例都已在FreeBSD 4.3平台上测试通过。


开始:pcap应用程序的格式


我们所要理解的第一件事情是一个基于pcap的嗅探器程序的总体布局。流程如下:


1.    我们从决定用哪一个接口进行嗅探开始。在Linux中, 这可能是eth0, 而在BSD系统中则可能是xl1,等等。我们也可以用一个字符串来定义这个设备,或者采用pcap提供的接口名来工作。


2.    初始化pcap。在这里我们要告诉pcap对什么设备进行嗅探。假如愿意的话,我们还可以嗅探多个设备。怎样区分它们呢?使用文件句柄。就像打开一个文件进行读写一样,必须命名我们的嗅探“会话”,以此使它们各自区别开来。


3.    如果我们只想嗅探特定的传输(如TCP/IP包,发往端口23的包,等等),我们必须创建一个规则集合,编译并且使用它。这个过程分为三个相互紧密关联的阶段。规则集合被置于一个字符串内,并且被转换成能被 pcap 读的格式(因此编译它)。编译实际上就是在我们的程序里调用一个不被外部程序使用的函数。接下来我们要告诉pcap使用它来过滤出我们想要的那一个会话。


4.    最后, 我们告诉pcap进入它的主体执行循环。在这个阶段内, pcap一直工作到它接收了所有我们想要的包为止。每当它收到一个包就调用另一个已经定义好的函数, 这个函数可以做我们想要的任何工作, 它可以剖析所部获的包并给用户打印出结果, 它可以将结果保存为一个文件, 或者什么也不作。


5.    在嗅探到所需的数据后, 我们要关闭会话并结束。

这是实际上一个很简单的过程。一共五个步骤,其中一个(第3个)是可选的。我们为什么不看一看是怎样实现每一个步骤呢?






设置设备


这是很简单的。有两种方法设置想要嗅探的设备。

第一种,我们可以简单的让用户告诉我们。考察下面的程序:

#include <stdio.h>
#include <pcap.h>
int main(int argc, char *argv[])
{
    char *dev = argv[1];
    printf("Device: %s/n", dev);
    return(0);
}
用户通过传递给程序的第一个参数来指定设备。字符串“dev”以pcap能“理解”的格式保存了我们要嗅探的接口的名字(当然,用户必须给了我们一个真正存在的接口)。
另一种也是同样的简单。来看这段程序:
#include <stdio.h>
#include <pcap.h>
int main()
{
    char *dev, errbuf[PCAP_ERRBUF_SIZE];
    dev = pcap_lookupdev(errbuf);
    printf("Device: %s/n", dev);
    return(0);
}

在这个例子里,pcap就自己设置设备。“但是,等一下,Tim”,你会说,“字符串errbuf是做什么的?”大多数的pcap命令允许我们向它们传递字符串作为参数。这个字符串的目的是什么呢?如果命令失败,它将传给这个字符串关于错误的描述。这样,如果pcap_lookupdev()失败,它将在errbuf存储错误信息。很好,是不是?这就是我们怎样去设置设备。


打开设备进行嗅探


创建一个嗅探会话的任务真的非常简单。为此,我们使用pcap_open_live()函数。此函数的原型(根据pcap的手册页)如下:

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

其第一个参数是我们在上一节中指定的设备,snaplen是整形的,它定义了将被pcap捕获的最大字节数。当promisc设为true时将置指定接口为混杂模式(然而,当它置为false时接口仍处于混杂模式的特殊情况也是有可能的)。to_ms是读取时的超时值,单位是毫秒(如果为0则一直嗅探直到错误发生,为-1则不确定)。最后,ebuf是一个我们可以存入任何错误信息的字符串(就像上面的errbuf)。此函数返回其会话句柄。


举个例子,考察以下代码片断:

#include <pcap.h>
    ...
    pcap_t *handle;
    handle = pcap_open_live(somedev, BUFSIZ, 1, 0, errbuf);

这个代码片断打开字符串somedev的设备,告诉它读取被BUFSIZ指定的字节数(BUFSIZ在pcap.h里定义)。我们告诉它将设备置为混杂模式,一直嗅探到错误发生,如果有了错误,把它存放在字符串errbuf中。


混杂模式与非混杂模式的区别:这两种方式区别很大。一般来说,非混杂模式的嗅探器中,主机仅嗅探那些跟它直接有关的通信,如发向它的,从它发出的,或经它路由的等都会被嗅探器捕获。而在混杂模式中则嗅探传输线路上的所有通信。在非交换式网络中,这将是整个网络的通信。这样做最明显的优点就是使更多的包被嗅探到,它们因你嗅探网络的原因或者对你有帮助,或者没有。但是,混杂模式是可被探测到的。一个主机可以通过高强度的测试判定另一台主机是否正在进行混杂模式的嗅探。其次,它仅在非交换式的网络环境中有效工作(如集线器,或者交换中的ARP层面)。再次,在高负荷的网络中,主机的系统资源将消耗的非常严重。


过滤通信


通常,我们的嗅探器仅对某特定的通信感兴趣。例如,有时我们想嗅探到端口23(telnet)的包以获得密码;或者我们想截获一个正通过端口21(FTP)传送的文件;可能我们仅想要得到DNS的通信(端口53,UDP)。无论哪种情况,我们都很少盲目的嗅探整个网络的通信。

下面讨论pcap_compile()与pcap_setfilter()。


这个过程非常简单。当我们已经调用了pcap_open_live()从而建立了一个嗅探会话之后就可以应用我们自己的过滤器了。为什么要用我们自己的过滤器呢?有两个原因。第一,pcap的过滤器太强大了,因为它直接使用BPF过滤器,我们通过使用BPF驱动直接过滤跳过了很多的关节。第二,这样做要容易的多。


在使用我们自己的过滤器前必须编译它。过滤表达式被保存在一个字符串中(字符数组)。其句法在tcpdump的手册页中被证明非常好。我建议你亲自阅读它。但是我们将使用简单的测试表达式,这样你可能很容易理解我的例子。


我们调用pcap_compile()来编译它,其原型是这样定义的:

int pcap_compile(pcap_t *p, struct bpf_program *fp, char *str, int optimize, bpf_u_int32 netmask)

第一个参数是会话句柄(pcap_t *handle在前一节的示例中)。接下来的是我们存储被编译的过滤器版本的地址的引用。再接下来的则是表达式本身,存储在规定的字符串格式里。再下边是一个定义表达式是否被优化的整形量(0为false,1为true,标准规定)。最后,我们必须指定应用此过滤器的网络掩码。函数返回-1为失败,其他的任何值都表明是成功的。


表达式被编译之后就可以使用了。现在进入pcap_setfilter()。

仿照我们介绍pcap的格式,先来看一看pcap_setfilter()的原型:


int pcap_setfilter(pcap_t *p, struct bpf_program *fp)


这非常直观,第一个参数是会话句柄,第二个参数是被编译表达式版本的引用(可推测出它与pcap_compile()的第二个参数相同)。

下面的代码示例可能能使你更好的理解:

    #include <pcap.h>

    ...

    pcap_t *handle;                     /* 会话的句柄 */

    char dev[] = "rl0";             /* 执行嗅探的设备 */

    char errbuf[PCAP_ERRBUF_SIZE];    /* 存储错误 信息的字符串 */

    struct bpf_program filter;         /*已经编译好的过滤表达式*/

    char filter_app[] = "port 23";     /* 过滤表达式*/

    bpf_u_int32 mask; /* 执行嗅探的设备的网络掩码 */

    bpf_u_int32 net;    /* 执行嗅探的设备的IP地址 */

    pcap_lookupnet(dev, &net, &mask, errbuf);

    handle = pcap_open_live(dev, BUFSIZ, 1, 0, errbuf);

    pcap_compile(handle, &filter, filter_app, 0, net);

    pcap_setfilter(handle, &filter);


这个程序使嗅探器嗅探经由端口23的所有通信,使用混杂模式,设备是rl0。


你可能注意到前面的示例包含一个我们还没提到的函数:pcap_lookupnet(),向这个函数提供设备接口名,它将返回其IP和网络掩码,这是很基本的,因为我们需要知道网络掩码以便应用过滤器。此函数在此文最后的miscellaneous一节里还有描述。


据我的经验,这个过滤器在所有的操作系统下都不会工作。在我的测试环境里,我发现OpenBSD 2.9默认内核支持这种过滤器,但FreeBSD 4.3默认内核则不支持。你的情况可能会有变化。


实际的嗅探


到此为止,我们已经学习了如何定义一个设备,让它准备嗅探,还有应用过滤器使我们嗅谈到什么或者不嗅探到什么。现在到了真正去捕获一些数据包的时候了。有两种手段捕获包。我们可以一次只捕获一个包,也可以进入一个循环,等捕获到多个包再进行处理。我们将先看看怎样去捕获单个包,然后再看看使用循环的方法。



为此,我们使用函数pcap_next()。

Pcap_next()的原型及其简单:

u_char *pcap_next(pcap_t *p, struct pcap_pkthdr *h)

第一个参数是会话句柄,第二个参数是指向一个包括了当前数据包总体信息(被捕获时的时间,包的长度,其被指定的部分长度)的结构体的指针(在这里只有一个片断,只作为一个示例)。Pcap_next()返回一个u_char指针给被这个结构体描述的包。我们将稍后讨论这种实际读取包本身的手段。


这里有一个演示怎样使用pcap_next()来嗅探一个包的例子:

#include <pcap.h>
    #include <stdio.h>
    int main()
    {
        pcap_t *handle;                 /* 会话句柄 */
        char *dev;                   /* 执行嗅探的设备 */
        char errbuf[PCAP_ERRBUF_SIZE]; /* 存储错误信息的字符串 */
        struct bpf_program filter;            /* 已经编译好的过滤器 */
        char filter_app[] = "port 23";        /* 过滤表达式 */
        bpf_u_int32 mask;         /* 所在网络的掩码 */
        bpf_u_int32 net;             /* 主机的IP地址 */
        struct pcap_pkthdr header;          /* 由pcap.h定义 */
        const u_char *packet;           /* 实际的包 */
        /* Define the device */
        dev = pcap_lookupdev(errbuf);
        /* 探查设备属性 */
        pcap_lookupnet(dev, &net, &mask, errbuf);
        /* 以混杂模式打开会话 */
        handle = pcap_open_live(dev, BUFSIZ, 1, 0, errbuf);
        /* 编译并应用过滤器 */
        pcap_compile(handle, &filter, filter_app, 0, net);
        pcap_setfilter(handle, &filter);
        /* 截获一个包 */
        packet = pcap_next(handle, &header);
        /* 打印它的长度 */
        printf("Jacked a packet with length of [%d]/n", header.len);
        /* 关闭会话 */
        pcap_close(handle);
        return(0);
    }

这个程序嗅探被pcap_lookupdev()返回的设备并将它置为混杂模式。它发现第一个包经过端口23(telnet)并且告诉用户此包的大小(以字节为单位)。这个程序又包含了一个新的调用pcap_close(),我们将在后面讨论(尽管它的名字就足够证明它自己的作用)。

我们可以使用的另一种手段则要复杂的多,并且可能也更为有用。很少有(如果有的话)嗅探器真正的使用pcap_next()。通常,它们使用pcap_loop()或者pcap_dispatch()(它就是用了pcap_loop())。为了理解这两个函数的用法,你必须理解回调函数的思想。


回调函数并不是什么新东西,它在许多API里面非常普遍。回调函数的概念极其简单。设想我有一个程序正等待某种排序的事件。为了达到这个例子的目的,让我们假象我的程序想让用户在键盘上按下一个键,每当他们按下了一个键,我就想调用一个作相应处理的函数。我所用的函数就是一个回调函数。用户每按一个键一次,我的程序就调用回调函数一次。回调函数在应用在pcap里,取代当用户按下键时被调用的函数的是当pcap嗅探到一个数据包时所调用的函数。可以定义它们的回调函数的两个函数就是pcap_loop()和pcap_dispatch()。此二者在它们的回调函数的使用上非常的相似。它们都是每当捕获到一个符合我们过滤器的包时调用器回调函数(当然是存在一个过滤器时,如果不存在则所有被嗅探到的包都被送到会调函数处理)。


Pcap_loop()的原型如下:


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


第一个参数是会话句柄,接下来是一个整型,它告诉pcap_loop()在返回前应捕获多少个数据包(若为负值则表示应该一直工作直至错误发生)。第三个参数是回调函数的名称(正像其标识符所指,无括号)。最后一个参数在有些应用里有用,但更多时候则置为NULL。假设我们有我们自己的想送往回调函数的参数,另外还有pcap_loop()发送的参数,这就需要用到它。很明显,必须是一个u_char类型的指针以确保结果正确;正像我们稍后见到的,pcap使用了很有意思的方法以u_char指针的形势传递信息。在我们展示了一个pcap是怎样做的例子之后就很容易去做了。若是还不行就参考你的本地的C引用文本,作为一个指针的解释那就超出了本文的范围。 Pcap_dispatch()的用法几乎相同。唯一不同的是它们如何处理超时(还记得在调用pcap_open_live()时怎样设置超时吗?这就是它起作用的地方)。Pcap_loop()忽略超时而pcap_dispatch()则不。关于它们之间区别的更深入的讨论请参见pcap的手册页。


在提供使用pcap_loop()的示例之前,我们必须检查我们的回调函数的格式。我们不能武断的定义回调函数的原型,否则pcap_loop()将会不知道如何去使用它。因此我们使用这样的格式作为我们的回调函数的原型:

void got_packet(u_char *args, const struct pcap_pkthdr *header, const u_char *packet);

让我们更细致的考察它。首先,你会注意到该函数返回void类型,这是符合逻辑的,因为pcap_loop()不知道如何去处理一个回调返回值。第一个参数相应于pcap_loop()的最后一个参数。每当回调函数被调用时,无论最后一个参数传给pcap_loop()什么值,这个值都会传给我们回调函数的第一个参数。第二个参数是pcap头文件定义的,它包括数据包被嗅探的时间、大小等信息。结构体pcap_pkhdr在pcap.h中定义如下:

struct pcap_pkthdr {

    struct timeval ts; /* 时间戳 */

    bpf_u_int32 caplen; /* 已捕获部分的长度 */

    bpf_u_int32 len; /* 该包的脱机长度 */

};

这些量都相当明了。最后一个参数在它们中是最有意思的,也最让pcap程序新手感到迷惑。这又是一个u_char指针,它包含了被pcap_loop()嗅探到的所有包。


但是你怎样使用这个我们在原型里称为packet的变量呢?一个数据包包含许多属性,因此你可以想象它不只是一个字符串,而实质上是一个结构体的集合(比如,一个TCP/IP包会有一个以太网的头部,一个IP头部,一个TCP头部,还有此包的有效载荷)。这个u_char就是这些结构体的串联版本。为了使用它,我们必须作一些有趣的匹配工作。


首先,在匹配它们之前必须定义这些实际的结构体。下面就是我用来描述一个通过以太网的TCP/IP包的结构体的定义。我使用的所有这些定义都是直接从POSIX库中提取的。通常,我只简单的使用那些库中的定义即可,但据我的经验不同平台的库之间有轻微的差别,这使得它实现起来变得混乱。因此,为达到示例的目的,我就避免那些混乱而简单的复制这些有关的结构体。所有这些都能在你的本地unix系统中的include/netinet中找到。下面就是这些结构体:


struct sniff_ethernet {
    u_char ether_dhost[ETHER_ADDR_LEN]; /* 目的主机的地址 */
    u_char ether_shost[ETHER_ADDR_LEN]; /* 源主机的地址 */
    u_short ether_type; /* IP? ARP? RARP? etc */
};
/* IP数据包的头部 */
struct sniff_ip {
    #if BYTE_ORDER == LITTLE_ENDIAN
    u_int ip_hl:4, /* 头部长度 */
    ip_v:4; /* 版本号 */
    #if BYTE_ORDER == BIG_ENDIAN
    u_int ip_v:4, /* 版本号 */
    ip_hl:4; /* 头部长度 */
    #endif
    #endif /* not _IP_VHL */
    u_char ip_tos; /* 服务的类型 */
    u_short ip_len; /* 总长度 */
    u_short ip_id; /*包标志号 */
    u_short ip_off; /* 碎片偏移 */
    #define IP_RF 0x8000 /* 保留的碎片标志 */
    #define IP_DF 0x4000 /* dont fragment flag */
    #define IP_MF 0x2000 /* 多碎片标志*/
    #define IP_OFFMASK 0x1fff /*分段位 */
    u_char ip_ttl; /* 数据包的生存时间 */
    u_char ip_p; /* 所使用的协议 */
    u_short ip_sum; /* 校验和 */
    struct in_addr ip_src,ip_dst; /* 源地址、目的地址*/
};
/* TCP 数据包的头部 */
struct sniff_tcp {
    u_short th_sport; /* 源端口 */
    u_short th_dport; /* 目的端口 */
    tcp_seq th_seq; /* 包序号 */
    tcp_seq th_ack; /* 确认序号 */
    #if BYTE_ORDER == LITTLE_ENDIAN
    u_int th_x2:4, /* 还没有用到 */
    th_off:4; /* 数据偏移 */
    #endif
    #if BYTE_ORDER == BIG_ENDIAN
    u_int th_off:4, /* 数据偏移*/
    th_x2:4; /*还没有用到 */
    #endif
    u_char th_flags;
    #define TH_FIN 0x01
    #define TH_SYN 0x02
    #define TH_RST 0x04
    #define TH_PUSH 0x08
    #define TH_ACK 0x10
    #define TH_URG 0x20
    #define TH_ECE 0x40
    #define TH_CWR 0x80
   #define TH_FLAGS (TH_FIN|TH_SYN|TH_RST|TH_ACK|TH_URG|TH_ECE|TH_CWR)
    u_short th_win; /* TCP滑动窗口 */
    u_short th_sum; /* 头部校验和 */
    u_short th_urp; /* 紧急服务位 */
};


注:在Slackware Linux 8(内核版本2.2.19)上我发现使用以上结构体的代码将不能通过编译。后来证明问题在于include/fearures.h,它只实现了一个POSIX接口,除非定义BSD_SOURCE。如果它没有被定义,我就只能使用一个不同的结构体去定义TCP头部。使它们工作在FreeBSD或OpenBSD系统上的更为通用的解决方法如下:

#define  _BSD_SOURCE  1

事先要包含你自己的所有头文件。这将确保正常使用BSD风格的API。如果不想这样做,那你可以改变TCP头结构(点此链接即可,内含注释)。


那么所有这些与pcap还有神秘的u_char是怎么关联的呢?看,幸运的是pcap嗅探数据包时正是使用的这些结构。接下来,它简单的创建一个u_char字符串并且将这些结构体填入。那么我们怎样才能区分它们呢?准备好见证指针最实用的好处之一吧(在此,我可要刺激刺激那些坚持说指针无用的C程序新手了)。


我们再一次假定要对以太网上的TCP/IP包进行处理。同样的手段可以应用于任何数据包,唯一的区别是你实际所使用的结构体的类型。让我们从声明分解u_char包的变量开始:

const struct sniff_ethernet *ethernet; 
const struct sniff_tcp *tcp; /* TCP包头部 */
const char *payload; /* 数据包的有效载荷*/
/*为了让它的可读性好,我们计算每个结构体中的变量大小*/
int size_ethernet = sizeof(struct sniff_ethernet);
int size_ip = sizeof(struct sniff_ip);
int size_tcp = sizeof(struct sniff_tcp);
现在我们开始让人感到有些神秘的匹配:
ethernet = (struct sniff_ethernet*)(packet);
ip = (struct sniff_ip*)(packet + size_ethernet);
tcp = (struct sniff_tcp*)(packet + size_ethernet + size_ip);
payload = (u_char *)(packet + size_ethernet + size_ip + size_tcp);


此处如何工作?考虑u_char在内存中的层次。基本的,当pcap将这些结构体填入u_char的时候是将这些数据存入一个字符串中,那个字符串将被送入我们的会调函数中。反向转换是这样的,不考虑这些结构体制中的值,它们的大小将是一致的。例如在我的平台上,一个sniff_ethernet结构体的大小是14字节。一个sniff_ip结构体是20字节,一个sniff_tcp结构体也是20字节。 u_char指针正是包含了内存地址的一个变量,这也是指针的实质,它指向内存的一个区域。简单而言,我们说指针指向的地址为x,如果三个结构体恰好线性排列,第一个(sniff_ethernet)被装载到内存地址的x处则我们很容易的发现其他结构体的地址,让我们以表格显示之:


Variable Location (in bytes)

sniff_ethernet X

sniff_ip X + 14

sniff_tcp X + 14 + 20

payload X + 14 + 20 + 20


结构体sniff_ethernet正好在x处,紧接着它的sniff_ip则位于x加上它本身占用的空间(此例为14字节),依此类推可得全部地址。


注意:你没有假定你的变量也是同样大小是很重要的。你应该总是使用sizeof()来确保尺寸的正确。这是因为这些结构体中的每个成员在不同平台下可以有不同的尺寸。


到现在,我们已经知道了怎样设置回调函数,调用它,弄清被嗅探到的数据包的属性。你可能正期待着写出一个可用的包嗅探器。因为代码的长度关系,我不想列在这篇文章里。你可以点击这里下载并测试它。