首先要了解的是pcap嗅探器的总体布局。代码流程如下:
实际上这是一个很简单的过程。总共五步,其中一步是可选的(就是那个使你感到困惑的第三步)。下面我们开始研究每个步骤及如何实现它 们。
这一步极其简单。有两种方法来设置我们要嗅探的设备。
第一种是简单地让用户告诉我们,考虑下面的程序:
用户指定设备名作为程序的第一个参数。现在,字符串"dev"保存了我们要嗅探的,pcap可以认识的接口名(当然,假设用户给我们的 是真实的接口)。
另一种方法也同样简单,看这个程序:
在这里,由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)。函数返回会话句柄。
为了演示,考虑这个代码段:
这个代码打开"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)
很直白,第一个参数是会话句柄,第二个是编译后的表达式。
也许这个代码示例可以帮助你更好地理解:
这个程序在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()来嗅探数据包:
这个程序用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包。同样的方法可用于任何数据包,唯一的区别是你实际所使用的结构类型。让我们从用于解析数据包 的变量声明及预处理定义开始:
现在开始神奇的类型转换:
它怎样工作?考虑一下数据包在内存中的布局。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并测试它。
现在你应该能够用pcap写一个嗅探器了。你已经学习了打开pcap会话、关于它的属性、嗅探数据包、应用过滤器和回调函数的基本概 念。是时候开始嗅探数据了。