用pcap编写网络嗅探器(Programming with pcap译文)

鉴客 发布于 2010/05/18 09:49
阅读 4K+
收藏 4

首先明确本文的阅读对象。显然,你需要一些C语言的基本知识,除非只想了解pcap的基本原理。你不用是一个编程高手;对于想深入 了解该领 域的编程者,我保证会尽量详细描述相关概念。另外,网络方面的一些知识对阅读本文是有帮助的。本文给出了一个网络包嗅探器,所有的代码已在默认内核 的FreeBSD 4.3上测试通过。

开始: pcap应用程序的格局

首先要了解的是pcap嗅探器的总体布局。代码流程如下:

  1. 我们首先要做的是决定要嗅探的接口。在Linux里它可能是eth0,BSD里可能是xl1等等。我们可以用字符串定义它,也可 以询问pcap得到所要使用的接口的名称。
  2. 初始化pcap。这里我们要告诉pcap对什么设备进行嗅探。如果愿意,我们可以嗅探多个设备。如何区分它们呢?答案是文件句柄 (File Handle)。和打开文件读写一样,我们必须为我们的嗅探“会话(session)”命名,以便区分其它的任务会话。
  3. 如 果我们只想嗅探特定通信(例如:仅TCP/IP包,仅流向23端口的包等等),我们必须建立一个规则集,“编译”之,然后应用它,这三个步骤关系密切。规 则集用一个字符串保存并转换成pcap认识的格式(因此要编译它),编译工作实际上只是在程序中调用一个函数,不涉及外部程序。然后告诉pcap在我们指 定的会话上应用这个规则。
  4. 最后,我们让pcap进入它的主循环。这时,pcap等待有数据包流入。每次得到新数据包,它就会调用我们指定的函数,我们可以 在这个函数里做任何我们想做的事:它可以解析数据包并输出给用户,也可以把数据保存成一个文件,或者什么也不做。
  5. 在嗅探到我们需要的东西以后,关闭会话,任务完成。

实际上这是一个很简单的过程。总共五步,其中一步是可选的(就是那个使你感到困惑的第三步)。下面我们开始研究每个步骤及如何实现它 们。

设置嗅探设备

这一步极其简单。有两种方法来设置我们要嗅探的设备。

第一种是简单地让用户告诉我们,考虑下面的程序:

  1. #include <stdio.h>
  2. #include <pcap.h>
  3.  
  4. int main(int argc, char *argv[])
  5. {
  6.     char *dev = argv[1];
  7.  
  8.     printf("Device: %s ", dev);
  9.     return(0);
  10. }

用户指定设备名作为程序的第一个参数。现在,字符串"dev"保存了我们要嗅探的,pcap可以认识的接口名(当然,假设用户给我们的 是真实的接口)。

另一种方法也同样简单,看这个程序:

  1. #include <stdio.h>
  2. #include <pcap.h>
  3.  
  4. int main(int argc, char *argv[])
  5. {
  6.     char *dev, errbuf[PCAP_ERRBUF_SIZE];
  7.  
  8.     dev = pcap_lookupdev(errbuf);
  9.     if (dev == NULL) {
  10.         fprintf(stderr, "Couldn't find default device: %s ", errbuf);
  11.         return(2);
  12.     }
  13.     printf("Device: %s ", dev);
  14.     return(0);
  15. }

在这里,由pcap自己来设置设备。“等一下,Tim,”你会说:“errbuf字符串代表什么?”。很多pcap命令允许我们把这个 字符串作为参 数。它的用途是:如果命令执行失败,它会得到出错的细节信息。在这段代码里,如果pcap_lookupdev()失败,errbuf会保存有一个错误信 息。很好,不是吗?

打开设备

建立嗅探会话的任务真的很简单,用pcap_open_live()就可以了。这个函数的原型(取自pcap man)如下:

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

第一个参数是设备名,我们在上一节已经介绍过了。snaplen是一个整型值,定义由pcap抓取的包的最大字节数。 promisc,当设置为true时,使接口处于混杂模式(不管怎样,即使设置为false,在一些特定情形下接口可能还是处于混杂模式)。to_ms是 读超时(read time out),单位为毫秒(0表示没有超时;在一些平台上,这意味着你可能会一直等待直到收到足够数量的包,所以你应该使用一个非零值)。最后,ebuf用于 保存出错信息(就象我们前面的errbuf)。函数返回会话句柄。

为了演示,考虑这个代码段:

  1. #include <pcap.h>
  2. ...
  3. pcap_t *handle;
  4.  
  5. handle = pcap_open_live(somedev, BUFSIZ, 1, 1000, errbuf);
  6. if (handle == NULL) {
  7.     fprintf(stderr, "Couldn't open device %s: %s ", somedev, errbuf);
  8.     return(2);
  9. }

这个代码打开"somedev"字符串指定的设备,告诉它每次读BUFSIZ字节(定义于pcap.h中),置设备于混杂模式。嗅探直 到有错误发生,错误信息保存到errbuf中,用于后面的错误信息输出。

关于混杂模式vs.非混杂模式:这是两个非常不同的风格。非混杂模式嗅探只监听与本地有直接关系的包。只有发往、源自或本地路由的包会 被嗅 探器捕获。另一方面,混杂模式监听所有线上的通信。在无交换环境中(non-switched environment),所以网络通信都会被监听。它可以让我们得到更多的包,但是,这是可以被检测的:可 以通过测试强可靠性来发现网络中是否有主机正在以混合模式监听,另外混杂工作模式仅仅在非交换式的网络中有效,而且在一个高负载的网络环境中,混杂模式将 消耗大量的系统资源。

通信过滤

通常我们只对特定网络通信感兴趣。比如我们只打算嗅探23端口(telnet)用于搜索密码信息,或者劫持发往21端口的文件 (FTP), 也可能是DNS通信(port 53 UDP)。无论哪种情形,我们很少会盲目地嗅探所有的网络通信。考虑使用pcap_compile()和 pcap_setfilter()函数。

这个步骤也是相当的简单。调用pcap_open_live()之后我们已经有了一个可用的嗅探会话,可以应用我们的过滤器。为什么不 用if/else语句?两个原因:首先,pcap的过滤器有更好的效率,因为它直接作用于BPF(BSD Packet Filter)同时又减少了直接操作BPF所需的大量步骤。第二,这样简单得多:)

应用我们的过滤器之前,我们必须“编译”它。过滤器表达式为一个规则字符串(char数组)。在tcpdump的man文档里有其语法 的说明 书。阅读语法说明的工作得你自己去做。尽管如此,我们将会尽量使用简单的过滤器表达式,因此你也许足够聪明从而可以从我的例子中领悟出来。

通过pcap_compile()函数来“编译”。它的原型为:

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

第一个参数是我们的会话句柄(前文的例子里是pcap_t *handle)。接下来的参数用于指向存放编译后过滤器的空间。然后是过滤表达式。下一个optimize整数决定表达式是否是“优化的”(0为 false、1为true)。最后,我们要指定网络掩码。函数失败时返回-1;其它值表示成功。

表达式被编译之后,就可以应用它了。使用pcap_setfilter()函数,下面是pcap_setfilter()原型:

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

很直白,第一个参数是会话句柄,第二个是编译后的表达式。

也许这个代码示例可以帮助你更好地理解:

  1. #include <pcap.h>
  2. ...
  3. pcap_t *handle; /* 会话句 柄 */
  4. char dev[] = "rl0"/* 被嗅探的 设备 */
  5. char errbuf[PCAP_ERRBUF_SIZE]; /* 错误信息 */
  6. struct bpf_program fp; /* 编译后的过滤表达式 */
  7. char filter_exp[] = "port 23"/* 过 滤表达式 */
  8. bpf_u_int32 mask; /* 嗅探设备的网络掩码 */
  9. bpf_u_int32 net; /* 嗅探设备 的IP */
  10.  
  11. if (pcap_lookupnet(dev, &net, &mask, errbuf) == -1) {
  12.     fprintf(stderr, "Can't get netmask for device %s ", dev);
  13.     net = 0;
  14.     mask = 0;
  15. }
  16. handle = pcap_open_live(dev, BUFSIZ, 1, 1000, errbuf);
  17. if (handle == NULL) {
  18.     fprintf(stderr, "Couldn't open device %s: %s "
  19.             somedev, errbuf);
  20.     return(2);
  21. }
  22. if (pcap_compile(handle, &fp, filter_exp, 0, net) == -1) {
  23.     fprintf(stderr, "Couldn't parse filter %s: %s "
  24.             filter_exp, pcap_geterr(handle));
  25.     return(2);
  26. }
  27. if (pcap_setfilter(handle, &fp) == -1) {
  28.     fprintf(stderr, "Couldn't install filter %s: %s ",
  29.             filter_exp, pcap_geterr(handle));
  30.     return(2);
  31. }

这个程序在rl0设备上以混杂模式嗅探发往或源自23端口的所有通信。

你可能注意到了,这个例子中有一个之前没讨论过的函数:pcap_lookupnet()。给一个设备名,得到它的IP和网络掩码。为 了应用过滤器,我们就要知道网络掩码,这个函数就可以派上用场了。

经试验发现这个过滤器并不能在所有的操作系统上正常工作。在我的测试环境中,我发现OpenBSD 2.9支持这种过滤器,而FreeBSD 4.3却不行。

正式开始嗅探

到这里我们已经学习了如何定义设备、准备嗅探以及应用过滤器来过滤我们不想嗅探的部分。现在是时候嗅探数据包了。

嗅探数据包有两种主要方法。我们可以一次捕获一个单独的包,也可以进入一个循环,等待N个包流入。我们首先关注如何捕获单个包,之后再 研究循环的方法。就单个包而言,我们用pcap_next()。

pcap_next()的原型很简单:

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

首个参数是会话句柄。第二个参数是一个指针,它指向的结构用于存放数据包的一般信息,如捕获的时间,包长度,组成包的各部分长度。 pcap_next()返回的*u_char指向捕获的包,稍后我们将会讨论读取数据包本身的方法。

这个例子演示怎样使用pcap_next()来嗅探数据包:

  1. #include <pcap.h>
  2. #include <stdio.h>
  3.  
  4. int main(int argc, char *argv[])
  5. {
  6.     pcap_t *handle; /* 会话句柄 */
  7.     char *dev; /* 嗅探的设备 */
  8.     char errbuf[PCAP_ERRBUF_SIZE]; /* 错误信息 */
  9.     struct bpf_program fp; /* 编译的过滤器 */
  10.     char filter_exp[] = "port 23"/* 过 滤表达式 */
  11.     bpf_u_int32 mask; /* 网 络掩码 */
  12.     bpf_u_int32 net; /* IP */
  13.     struct pcap_pkthdr header; /* pcap头 */
  14.     const u_char *packet; /* 数据包 */
  15.  
  16.     /* 定义设备 */
  17.     dev = pcap_lookupdev(errbuf);
  18.     if (dev == NULL) {
  19.         fprintf(stderr, "Couldn't find default device: %s ", errbuf);
  20.         return(2);
  21.     }
  22.     /* 取得设备属性 */
  23.     if (pcap_lookupnet(dev, &net, &mask, errbuf) == -1) {
  24.         fprintf(stderr, "Couldn't get netmask for device %s: %s "
  25.                 dev, errbuf);
  26.         net = 0;
  27.         mask = 0;
  28.     }
  29.     /* 以混杂模式打开会话 */
  30.     handle = pcap_open_live(dev, BUFSIZ, 1, 1000, errbuf);
  31.     if (handle == NULL) {
  32.         fprintf(stderr, "Couldn't open device %s: %s ",
  33.                 somedev, errbuf);
  34.         return(2);
  35.     }
  36.     /* 编译并应用过滤器 */
  37.     if (pcap_compile(handle, &fp, filter_exp, 0, net) == -1) {
  38.         fprintf(stderr, "Couldn't parse filter %s: %s "
  39.                 filter_exp, pcap_geterr(handle));
  40.         return(2);
  41.     }
  42.     if (pcap_setfilter(handle, &fp) == -1) {
  43.         fprintf(stderr, "Couldn't install filter %s: %s ",
  44.                 filter_exp, pcap_geterr(handle));
  45.         return(2);
  46.     }
  47.     /* 抓取一个数据包 */
  48.     packet = pcap_next(handle, &header);
  49.     /* 输出数据包长度 */
  50.     printf("Jacked a packet with length of [%d] ",
  51.             header.len);
  52.     /* 关闭会话 */
  53.     pcap_close(handle);
  54.     return(0);
  55. }

这个程序用pcap_lookupdev()取得设备并将其设置为混杂模式,然后开始嗅探。它取得23端口(telnet)上的首个包 后输出这个包 的大小(字节)。此外,这个程序有一个新的函数:pcap_close(),我们等会儿再讨论它(尽管函数名已经说明了一切)。

另一种方法要复杂一些,不过更有用。通常很少有嗅探器直接调用pcap_next()函数(如果有的话),它们更常用的是 pcap_loop()或pcap_dispatch()。要学会这两个函数,你必须先理解回调函数。

回调函数并不是新鲜事物,它们在不少API中普遍存在。回调的概念很简单。假设我的程序要等待某个事件,为简单起见,就说是等待用户输 入 吧。用户每按一次键,我想要通过函数来决定接下来做什么。这个函数就可以是回调函数,每次按下键盘,我的程序就会调用这个回调函数。回到pcap中,回调 函数被调用的时机由用户按下一个键改为pcap嗅探到一个数据包。pcap_loop() 和 pcap_dispatch() 的用法很相似。每次嗅探到一个符合过滤要求(如果存在过滤器的话)的包后就会调用回调函数。

pcap_loop()的原型是:

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

首个参数是会话句柄。接下来的cnt参数告诉pcap_loop()返回之前应该嗅探到多少个包(负数表示一直嗅探直到出错为止)。第 三个就是之前讨论的回调函数啦。最后一个参数的作用是传递附加的自定义数据给回调函数,在 一些应用时有用,很多时候直接设为NULL就行。后面我们将会以例子的形式看到pcap用u_char指针传递一些很有意思的信息。 pcap_dispatch()的用法几乎一样,唯一的区别是pcap_dispatch()只处理第一批从系统中收到的包,而pcap_loop()会 继续处理接下来的包直到达到指定数量为止。关于它们的细节差异,请参考pcap的man文档。

拿出cap_loop()的例子之前,我们得了解一下回调函数的原型:

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

首先,它是一个无返回值的函数。这是可以理解的,因为pcap_loop()不知道怎样处理回调函数的返回值。

第一个参数就是我们传给pcap_loop()的最后一个数据。每次回调函数被调用时都可以取得这个数据。

第二个参数是pcap头结构,它含有包何时到达,多大等信息。pcap_pkthdr结构定义于pcap.h之中:

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

结构成员名称完全可以自解释了。

最后的那个参数const u_char *packet是我们最关心的,也是最容易引起pcap初学者混乱的。它是一个u_char指针,指向被pcap_loop()嗅探到的整个数据包的第一 个字节。

怎样使用这个packet参数呢?数据包有很多属性,只要思考一下就会知道,它不是一个真正的字符串,而是一系列的结构(例如, TCP/IP包应该有以太头、IP头、TCP头,最后,还有包的载荷)。这个u_char指针指向的正是这些数据结构的序列化版本,所以在使用之前,要做 类 型转换工作。

首先,我们要定义这些结构,下面定义的是以太网TCP/IP数据包结构。

/* 以太网的地址占6字节 */
#define ETHER_ADDR_LEN 6

/* 以太网头 */
struct sniff_ethernet {
u_char ether_dhost[ETHER_ADDR_LEN]; /* 目的地址 */
u_char ether_shost[ETHER_ADDR_LEN]; /* 源地址 */
u_short ether_type; /* IP? ARP? RARP? 等 */
};

/* IP 头 */
struct sniff_ip {
u_char ip_vhl; /* version << 4 | header length >> 2 */
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 */
};
#define IP_HL(ip) (((ip)->ip_vhl) & 0x0f)
#define IP_V(ip) (((ip)->ip_vhl) >> 4)

/* TCP 头 */
struct sniff_tcp {
u_short th_sport; /* source port */
u_short th_dport; /* destination port */
tcp_seq th_seq; /* sequence number */
tcp_seq th_ack; /* acknowledgement number */

u_char th_offx2; /* data offset, rsvd */
#define TH_OFF(th) (((th)->th_offx2 & 0xf0) >> 4)
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; /* window */
u_short th_sum; /* checksum */
u_short th_urp; /* urgent pointer */
};

注意:我发现在我的Slackware Linux 8(2.2.19内核)里不能编译这个结构。问题出现在include/features.h里,除非在包含它之前定义_BSD_SOURCE,否则将以 POSIX接口实现。所以建议在包含所有头文件之前先加入一行:

#define _BSD_SOURCE 1

这样能确保使用BSD风格的API。当然,如果你不想用预定义,你可以简单地改一下结构,就象我在这里做的那样。

那么,如何把我们神秘的u_char指针应用到pcap工作中来呢?嗯~~这些结构定义了包中的头部数据,那怎样提取这些部分呢?准备 见证指针的典型应用之一吧。

我们还是假设处理以太网的TCP/IP包。同样的方法可用于任何数据包,唯一的区别是你实际所使用的结构类型。让我们从用于解析数据包 的变量声明及预处理定义开始:

  1. /* 以太网头总是14字节 */
  2. #define SIZE_ETHERNET 14
  3.  
  4. const struct sniff_ethernet *ethernet; /* The ethernet header */
  5. const struct sniff_ip *ip; /* The IP header */
  6. const struct sniff_tcp *tcp; /* The TCP header */
  7. const char *payload; /* Packet payload */
  8.  
  9. u_int size_ip;
  10. u_int size_tcp;

现在开始神奇的类型转换:

  1. ethernet = (struct sniff_ethernet*)(packet);
  2. ip = (struct sniff_ip*)(packet + SIZE_ETHERNET);
  3. size_ip = IP_HL(ip)*4;
  4. if (size_ip < 20) {
  5.     printf(" * Invalid IP header length: %u bytes ", size_ip);
  6.     return;
  7. }
  8. tcp = (struct sniff_tcp*)(packet + SIZE_ETHERNET + size_ip);
  9. size_tcp = TH_OFF(tcp)*4;
  10. if (size_tcp < 20) {
  11.     printf(" * Invalid TCP header length: %u bytes ", size_tcp);
  12.     return;
  13. }
  14. payload = (u_char *)(packet + SIZE_ETHERNET + size_ip + size_tcp);

它怎样工作?考虑一下数据包在内存中的布局。u_char指针只是一个包含内存地址的变量,这就是指针的实质,指出内存所在位置。

为了简单起见,就说这个指针指向的地址为X吧。如果我们的这三个结构是线性存储的,那么第一个(sniff_ethernet)结构就 位于地址为X的内存上,接下来我们可以很简单地找到后面的结构:X地址加上14(或SIZE_ETHERNET)字节的以太网头长度。

简而言之,如果我们有头地址,那么后面的结构地址就是当前头地址加上头长度。IP头和以太网头不一样,这的长度是不固定的。它的长度由 它的成员指定,以字(4byte)为单位,所以得到字节长度还得剩上4。最小长度是20字节。

TCP头也是变长的,同样以4字节为一个单位,最小长度也是20字节。

我们来做个表格:

 

变量 位置 (bytes)
sniff_ethernet X
sniff_ip X + SIZE_ETHERNET
sniff_tcp X + SIZE_ETHERNET + {IP header length}
payload X + SIZE_ETHERNET + {IP header length} + {TCP header length}

第一行的sniff_ethernet结构,正好在X处。sniff_ip,紧跟在sniff_ethernet之后,为X加上以太网 头所占空间(14字节,或SIZE_ETHERNET)。sniff_tcp在sniff_ip后面,因此它的位置是X加上以太网头和IP头的大小。最 后,payload (它不是一个单一的结构,这与上层的协议有关)位于最后。

到这里,我们了解了如何调用回调函数,调用它以及找到嗅探到的包的属性。你所期待的时刻到了:写一个有用的嗅探器。由于代码长度的关 系,我不打算把它放到本文中。你可以到这里下载 sniffex.c并测试它。

Wrapping Up

现在你应该能够用pcap写一个嗅探器了。你已经学习了打开pcap会话、关于它的属性、嗅探数据包、应用过滤器和回调函数的基本概 念。是时候开始嗅探数据了。

加载中
0
块块
块块
靠,这么好的文章居然没人回复。我擦!
0
静风流云
静风流云
好文章是成年的老酒!
返回顶部
顶部