1. 程式人生 > >基於 linux 平臺的 libpcap 原始碼分析

基於 linux 平臺的 libpcap 原始碼分析

http://blog.chinaunix.net/uid-21768364-id-3244490.html

關於作者: 施聰,成都人,高階程式設計師、網路設計師。從事基於 UNIX/LINUX 下的 c/c++ 程式設計和資料庫建模工作已 10 年。

libpcap 是 unix/linux 平臺下的網路資料包捕獲函式包,大多數網路監控軟體都以它為基礎。Libpcap 可以在絕大多數類 unix 平臺下工作,本文分析了 libpcap 在 linux 下的原始碼實現,其中重點是linux 的底層包捕獲機制和過濾器設定方式,同時也簡要的討論了 libpcap 使用的包過濾機制 BPF。

網路監控
絕大多數的現代作業系統都提供了對底層網路資料包捕獲的機制,在捕獲機制之上可以建立網路監控(Network Monitoring)應用軟體。網路監控也常簡稱為sniffer,其最初的目的在於對網路通訊情況進行監控,以對網路的一些異常情況進行除錯處理。但隨著互連網的快速普及和網路攻擊行為的頻繁出現,保護網路的執行安全也成為監控軟體的另一個重要目的。例如,網路監控在路由器,防火牆、入侵檢查等方面使用也很廣泛。除此而外,它也是一種比較有效的黑客手段,例如,美國政府安全部門的"肉食動物"計劃。

包捕獲機制
從廣義的角度上看,一個包捕獲機制包含三個主要部分:最底層是針對特定作業系統的包捕獲機制,最高層是針對使用者程式的介面,第三部分是包過濾機制。

不同的作業系統實現的底層包捕獲機制可能是不一樣的,但從形式上看大同小異。資料包常規的傳輸路徑依次為網絡卡、裝置驅動層、資料鏈路層、IP 層、傳輸層、最後到達應用程式。而包捕獲機制是在資料鏈路層增加一個旁路處理,對傳送和接收到的資料包做過濾/緩衝等相關處理,最後直接傳遞到應用程式。值得注意的是,包捕獲機制並不影響作業系統對資料包的網路棧處理。對使用者程式而言,包捕獲機制提供了一個統一的介面,使使用者程式只需要簡單的呼叫若干函式就能獲得所期望的資料包。這樣一來,針對特定作業系統的捕獲機制對使用者透明,使使用者程式有比較好的可移植性。包過濾機制是對所捕獲到的資料包根據使用者的要求進行篩選,最終只把滿足過濾條件的資料包傳遞給使用者程式。

Libpcap 應用程式框架
Libpcap 提供了系統獨立的使用者級別網路資料包捕獲介面,並充分考慮到應用程式的可移植性。Libpcap 可以在絕大多數類 unix 平臺下工作,參考資料 A 中是對基於 libpcap 的網路應用程式的一個詳細列表。在 windows 平臺下,一個與libpcap 很類似的函式包 winpcap 提供捕獲功能,其官方網站是http://winpcap.polito.it/

Libpcap 軟體包可從 http://www.tcpdump.org/ 下載,然後依此執行下列三條命令即可安裝,但如果希望 libpcap 能在 linux 上正常工作,則必須使核心支援"packet"協議,也即在編譯核心時開啟配置選項 CONFIG_PACKET(選項預設為開啟)。


./configure
./make
./make install

libpcap 原始碼由 20 多個 C 檔案構成,但在 Linux 系統下並不是所有檔案都用到。可以通過檢視命令 make 的輸出瞭解實際所用的檔案。本文所針對的libpcap 版本號為 0.8.3,網路型別為常規乙太網。Libpcap 應用程式從形式上看很簡單,下面是一個簡單的程式框架:


char * device; /* 用來捕獲資料包的網路介面的名稱 */
pcap_t * p; /* 捕獲資料包控制代碼,最重要的資料結構 */
struct bpf_program fcode; /* BPF 過濾程式碼結構 */

/* 第一步:查詢可以捕獲資料包的裝置 */
device = pcap_lookupdev(errbuf);

/* 第二步:建立捕獲控制代碼,準備進行捕獲 */
p = pcap_open_live(device, 8000, 1, 500, errbuf);

/* 第三步:如果使用者設定了過濾條件,則編譯和安裝過濾程式碼 */
pcap_compile(p, &fcode, filter_string, 0, netmask);
pcap_setfilter(p, &fcode);

/* 第四步:進入(死)迴圈,反覆捕獲資料包 */
for( ; ; )
{
while((ptr = (char *)(pcap_next(p, &hdr))) == NULL);
                    
/* 第五步:對捕獲的資料進行型別轉換,轉化成以太資料包型別 */
eth = (struct libnet_ethernet_hdr *)ptr;

/* 第六步:對以太頭部進行分析,判斷所包含的資料包型別,做進一步的處理 */
if(eth->ether_type == ntohs(ETHERTYPE_IP)) 
…………
if(eth->ether_type == ntohs(ETHERTYPE_ARP)) 
…………
}
          
/* 最後一步:關閉捕獲控制代碼,一個簡單技巧是在程式初始化時增加訊號處理函式,
以便在程式退出前執行本條程式碼 */
pcap_close(p);

檢查網路裝置
libpcap 程式的第一步通常是在系統中找到合適的網路介面裝置。網路介面在Linux 網路體系中是一個很重要的概念,它是對具體網路硬體裝置的一個抽象,在它的下面是具體的網絡卡驅動程式,而其上則是網路協議層。Linux 中最常見的介面裝置名 eth0 和 lo。Lo 稱為迴路裝置,是一種邏輯意義上的裝置,其主要目的是為了除錯網路程式之間的通訊功能。eth0 對應了實際的物理網絡卡,在真實網路環境下,資料包的傳送和接收都要通過 eht0。如果計算機有多個網絡卡,則還可以有更多的網路介面,如 eth1,eth2 等等。呼叫命令 ifconfig 可以列出當前所有活躍的介面及相關資訊,注意對 eth0的描述中既有物理網絡卡的 MAC 地址,也有網路協議的 IP 地址。檢視檔案 /proc/net/dev 也可獲得介面資訊。

Libpcap 中檢查網路裝置中主要使用到的函式關係如下圖:

libpcap 呼叫 pcap_lookupdev() 函式獲得可用網路介面的裝置名。首先利用函式 getifaddrs() 獲得所有網路介面的地址,以及對應的網路掩碼、廣播地址、目標地址等相關資訊,再利用add_addr_to_iflist()、add_or_find_if()、get_instance() 把網路介面的資訊增加到結構連結串列 pcap_if 中,最後從連結串列中提取第一個介面作為捕獲裝置。其中 get_instanced() 的功能是從裝置名開始,找第一個是數字的字元,做為介面的例項號。網路介面的裝置號越小,則排在連結串列的越前面,因此,通常函式最後返回的裝置名為 eth0。雖然 libpcap 可以工作在迴路介面上,但顯然 libpcap開發者認為捕獲本機程序之間的資料包沒有多大意義。在檢查網路裝置操作中,主要用到的資料結構和程式碼如下:


          /* libpcap 自定義的介面資訊連結串列 [pcap.h] */
struct pcap_if 
{
struct pcap_if *next; 
char *name; /* 介面裝置名 */
char *description; /* 介面描述 */
                    
/*介面的 IP 地址, 地址掩碼, 廣播地址,目的地址 */
struct pcap_addr addresses; 
bpf_u_int32 flags;  /* 介面的引數 */
};

char * pcap_lookupdev(register char * errbuf)
{
          pcap_if_t *alldevs;
          ……
                    pcap_findalldevs(&alldevs, errbuf);
                    ……
                    strlcpy(device, alldevs->name, sizeof(device));
          }
         

開啟網路裝置
當裝置找到後,下一步工作就是開啟裝置以準備捕獲資料包。Libpcap 的包捕獲是建立在具體的作業系統所提供的捕獲機制上,而 Linux 系統隨著版本的不同,所支援的捕獲機制也有所不同。

2.0 及以前的核心版本使用一個特殊的 socket 型別 SOCK_PACKET,呼叫形式是 socket(PF_INET, SOCK_PACKET, int protocol),但 Linux 核心開發者明確指出這種方式已過時。Linux 在2.2 及以後的版本中提供了一種新的協議簇 PF_PACKET 來實現捕獲機制。PF_PACKET 的呼叫形式為 socket(PF_PACKET, int socket_type, int protocol),其中 socket 型別可以是SOCK_RAW 和 SOCK_DGRAM。SOCK_RAW 型別使得資料包從資料鏈路層取得後,不做任何修改直接傳遞給使用者程式,而 SOCK_DRRAM 則要對資料包進行加工(cooked),把資料包的資料鏈路層頭部去掉,而使用一個通用結構 sockaddr_ll 來儲存鏈路資訊。

使用 2.0 版本核心捕獲資料包存在多個問題:首先,SOCK_PACKET 方式使用結構 sockaddr_pkt 來儲存資料鏈路層資訊,但該結構缺乏包型別資訊;其次,如果引數 MSG_TRUNC 傳遞給讀包函式 recvmsg()、recv()、recvfrom() 等,則函式返回的資料包長度是實際讀到的包資料長度,而不是資料包真正的長度。Libpcap 的開發者在原始碼中明確建議不使用 2.0 版本進行捕獲。

相對 2.0 版本 SOCK_PACKET 方式,2.2 版本的 PF_PACKET 方式則不存在上述兩個問題。在實際應用中,使用者程式顯然希望直接得到"原始"的資料包,因此使用 SOCK_RAW 型別最好。但在下面兩種情況下,libpcap 不得不使用 SOCK_DGRAM 型別,從而也必須為資料包合成一個"偽"鏈路層頭部(sockaddr_ll)。

某些型別的裝置資料鏈路層頭部不可用:例如 Linux 核心的 PPP 協議實現程式碼對 PPP 資料包頭部的支援不可靠。

在捕獲裝置為"any"時:所有裝置意味著 libpcap 對所有介面進行捕獲,為了使包過濾機制能在所有型別的資料包上正常工作,要求所有的資料包有相同的資料鏈路頭部。

開啟網路裝置的主函式是 pcap_open_live()[pcap-linux.c],其任務就是通過給定的介面裝置名,獲得一個捕獲控制代碼:結構 pcap_t。pcap_t 是大多數 libpcap 函式都要用到的引數,其中最重要的屬性則是上面討論到的三種 socket 方式中的某一種。首先我們看看 pcap_t 的具體構成。


struct pcap [pcap-int.h]

          int fd; /* 檔案描述字,實際就是 socket */
          
                    /* 在 socket 上,可以使用 select() 和 poll() 等 I/O 複用型別函式 */
          int selectable_fd; 

          int snapshot; /* 使用者期望的捕獲資料包最大長度 */
          int linktype; /* 裝置型別 */
          int tzoff;                     /* 時區位置,實際上沒有被使用 */
          int offset;         /* 邊界對齊偏移量 */

          int break_loop; /* 強制從讀資料包迴圈中跳出的標誌 */

          struct pcap_sf sf; /* 資料包儲存到檔案的相關配置資料結構 */
          struct pcap_md md; /* 具體描述如下 */
          
          int bufsize; /* 讀緩衝區的長度 */
          u_char buffer; /* 讀緩衝區指標 */
          u_char *bp;
          int cc;
          u_char *pkt;

          /* 相關抽象操作的函式指標,最終指向特定作業系統的處理函式 */
          int       (*read_op)(pcap_t *, int cnt, pcap_handler, u_char *);
          int       (*setfilter_op)(pcap_t *, struct bpf_program *);
          int       (*set_datalink_op)(pcap_t *, int);
          int       (*getnonblock_op)(pcap_t *, char *);
          int       (*setnonblock_op)(pcap_t *, int, char *);
          int       (*stats_op)(pcap_t *, struct pcap_stat *);
          void (*close_op)(pcap_t *);

          /*如果 BPF 過濾程式碼不能在核心中執行,則將其儲存並在使用者空間執行 */
          struct bpf_program fcode; 

          /* 函式調用出錯資訊緩衝區 */
          char errbuf[PCAP_ERRBUF_SIZE + 1]; 
          
          /* 當前裝置支援的、可更改的資料鏈路型別的個數 */
          int dlt_count;
          /* 可更改的資料鏈路型別號連結串列,在 linux 下沒有使用 */
          int *dlt_list;

          /* 資料包自定義頭部,對資料包捕獲時間、捕獲長度、真實長度進行描述 [pcap.h] */
          struct pcap_pkthdr pcap_header;          
};

/* 包含了捕獲控制代碼的介面、狀態、過濾資訊  [pcap-int.h] */
struct pcap_md {
/* 捕獲狀態結構  [pcap.h] */
struct pcap_stat stat;  

          int use_bpf; /* 如果為1,則代表使用核心過濾*/ 
          u_long    TotPkts; 
          u_long    TotAccepted; /* 被接收資料包數目 */ 
          u_long    TotDrops; /* 被丟棄資料包數目 */ 
          long      TotMissed;          /* 在過濾進行時被介面丟棄的資料包數目 */
          long      OrigMissed; /*在過濾進行前被介面丟棄的資料包數目*/
#ifdef linux
          int       sock_packet; /* 如果為 1,則代表使用 2.0 核心的 SOCK_PACKET 模式 */
          int       timeout;  /* pcap_open_live() 函式超時返回時間*/ 
          int       clear_promisc; /* 關閉時設定介面為非混雜模式 */ 
          int       cooked;             /* 使用 SOCK_DGRAM 型別 */
          int       lo_ifindex;         /* 迴路裝置索引號 */
          char *device;       /* 介面裝置名稱 */ 
          
/* 以混雜模式開啟 SOCK_PACKET 型別 socket 的 pcap_t 連結串列*/
struct pcap *next;  
#endif
};

函式 pcap_open_live() 的呼叫形式是 pcap_t * pcap_open_live(const char *device, int snaplen, int promisc, int to_ms, char *ebuf),其中如果 device 為 NULL 或"any",則對所有介面捕獲,snaplen 代表使用者期望的捕獲資料包最大長度,promisc 代表設定介面為混雜模式(捕獲所有到達介面的資料包,但只有在裝置給定的情況下有意義),to_ms 代表函式超時返回的時間。本函式的程式碼比較簡單,其執行步驟如下:

為結構 pcap_t 分配空間並根據函式入參對其部分屬性進行初試化。

分別利用函式 live_open_new() 或 live_open_old() 嘗試建立 PF_PACKET 方式或 SOCK_PACKET 方式的 socket,注意函式名中一個為"new",另一個為"old"。

根據 socket 的方式,設定捕獲控制代碼的讀緩衝區長度,並分配空間。

為捕獲控制代碼 pcap_t 設定 linux 系統下的特定函式,其中最重要的是讀資料包函式和設定過濾器函式。(注意到這種從抽象模式到具體模式的設計思想在 linux 原始碼中也多次出現,如 VFS 檔案系統)
handle->read_op = pcap_read_linux; handle->setfilter_op = pcap_setfilter_linux;

下面我們依次分析 2.2 和 2.0 核心版本下的 socket 建立函式。


static int
live_open_new(pcap_t *handle, const char *device, int promisc,
   int to_ms, char *ebuf)
{
/* 如果裝置給定,則開啟一個 RAW 型別的套接字,否則,開啟 DGRAM 型別的套接字 */
sock_fd = device ?
                               socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL))
                          : socket(PF_PACKET, SOCK_DGRAM, htons(ETH_P_ALL));

/* 取得迴路裝置介面的索引 */
handle->md.lo_ifindex = iface_get_id(sock_fd, "lo", ebuf);

/* 如果裝置給定,但介面型別未知或是某些必須工作在加工模式下的特定型別,則使用加工模式 */
if (device) {
/* 取得介面的硬體型別 */
arptype = iface_get_arptype(sock_fd, device, ebuf); 

/* linux 使用 ARPHRD_xxx 標識介面的硬體型別,而 libpcap 使用DLT_xxx
來標識。本函式是對上述二者的做對映變換,設定控制代碼的鏈路層型別為
DLT_xxx,並設定控制代碼的偏移量為合適的值,使其與鏈路層頭部之和為 4 的倍數,目的是邊界對齊 */
map_arphrd_to_dlt(handle, arptype, 1);

/* 如果介面是前面談到的不支援鏈路層頭部的型別,則退而求其次,使用 SOCK_DGRAM 模式 */
if (handle->linktype == xxx) 
{
close(sock_fd);
sock_fd = socket(PF_PACKET, SOCK_DGRAM, htons(ETH_P_ALL));
}

/* 獲得給定的裝置名的索引 */
device_id = iface_get_id(sock_fd, device, ebuf);
                               
/* 把套接字和給定的裝置繫結,意味著只從給定的裝置上捕獲資料包 */
iface_bind(sock_fd, device_id, ebuf);

} else { /* 現在是加工模式 */
handle->md.cooked = 1;
/* 資料包鏈路層頭部為結構 sockaddr_ll, SLL 大概是結構名稱的簡寫形式 */
handle->linktype = DLT_LINUX_SLL;
                               device_id = -1;
                    }
                    
/* 設定給定裝置為混雜模式 */
if (device && promisc) 
{
memset(&mr, 0, sizeof(mr));
mr.mr_ifindex = device_id;
mr.mr_type = PACKET_MR_PROMISC;
setsockopt(sock_fd, SOL_PACKET, PACKET_ADD_MEMBERSHIP, 
&mr, sizeof(mr));
}

/* 最後把建立的 socket 儲存在控制代碼 pcap_t 中 */
handle->fd = sock_fd;
          }

/* 2.0 核心下函式要簡單的多,因為只有唯一的一種 socket 方式 */
static int
live_open_old(pcap_t *handle, const char *device, int promisc,
                int to_ms, char *ebuf)
{
/* 首先建立一個SOCK_PACKET型別的 socket */
handle->fd = socket(PF_INET, SOCK_PACKET, htons(ETH_P_ALL));
                    
/* 2.0 核心下,不支援捕獲所有介面,裝置必須給定 */
if (!device) {
strncpy(ebuf, "pcap_open_live: The \"any\" device isn't supported on 2.0[.x]-kernel systems", PCAP_ERRBUF_SIZE);
break;
}
                    
/* 把 socket 和給定的裝置繫結 */
iface_bind_old(handle->fd, device, ebuf);
                    
/*以下的處理和 2.2 版本下的相似,有所區別的是如果介面鏈路層型別未知,則 libpcap 直接退出 */
                     
arptype = iface_get_arptype(handle->fd, device, ebuf);
map_arphrd_to_dlt(handle, arptype, 0);
if (handle->linktype == -1) {
snprintf(ebuf, PCAP_ERRBUF_SIZE, "unknown arptype %d", arptype);
break;
}

/* 設定給定裝置為混雜模式 */
if (promisc) {
memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, device, sizeof(ifr.ifr_name));
ioctl(handle->fd, SIOCGIFFLAGS, &ifr);
ifr.ifr_flags |= IFF_PROMISC;
ioctl(handle->fd, SIOCSIFFLAGS, &ifr);
}
}

比較上面兩個函式的程式碼,還有兩個細節上的區別。首先是 socket 與介面繫結所使用的結構:老式的繫結使用了結構 sockaddr,而新式的則使用了 2.2 核心中定義的通用鏈路頭部層結構sockaddr_ll。


iface_bind_old(int fd, const char *device, char *ebuf)
{
struct sockaddr     saddr;
memset(&saddr, 0, sizeof(saddr));
strncpy(saddr.sa_data, device, sizeof(saddr.sa_data));
bind(fd, &saddr, sizeof(saddr));
}

iface_bind(int fd, int ifindex, char *ebuf)
{
struct sockaddr_ll  sll;
memset(&sll, 0, sizeof(sll));
sll.sll_family = AF_PACKET;
sll.sll_ifindex = ifindex;
sll.sll_protocol    = htons(ETH_P_ALL);
bind(fd, (struct sockaddr *) &sll, sizeof(sll);
}

第二個是在 2.2 版本中設定裝置為混雜模式時,使用了函式 setsockopt(),以及新的標誌 PACKET_ADD_MEMBERSHIP 和結構 packet_mreq。我估計這種方式主要是希望提供一個統一的呼叫介面,以代替傳統的(混亂的)ioctl 呼叫。


struct packet_mreq
{
int             mr_ifindex;    /* 介面索引號 */
unsigned short  mr_type;       /* 要執行的操作(號) */
unsigned short  mr_alen;       /* 地址長度 */
unsigned char   mr_address[8]; /* 物理層地址 */ 
};

使用者應用程式介面
Libpcap 提供的使用者程式介面比較簡單,通過反覆呼叫函式pcap_next()[pcap.c] 則可獲得捕獲到的資料包。下面是一些使用到的資料結構:


/* 單個數據包結構,包含資料包元資訊和資料資訊 */
struct singleton [pcap.c]
{
struct pcap_pkthdr hdr; /* libpcap 自定義資料包頭部 */
const u_char * pkt; /* 指向捕獲到的網路資料 */
};

/* 自定義頭部在把資料包儲存到檔案中也被使用 */
struct pcap_pkthdr 
{
                    struct timeval ts; /* 捕獲時間戳 */ 
                    bpf_u_int32 caplen; /* 捕獲到資料包的長度 */
                    bpf_u_int32 len; /* 資料包的真正長度 */
}

/* 函式 pcap_next() 實際上是對函式 pcap_dispatch()[pcap.c] 的一個包裝 */
const u_char * pcap_next(pcap_t *p, struct pcap_pkthdr *h)
{
struct singleton s;
s.hdr = h;

/*入參"1"代表收到1個數據包就返回;回撥函式 pcap_oneshot() 是對結構 singleton 的屬性賦值 */
if (pcap_dispatch(p, 1, pcap_oneshot, (u_char*)&s) <= 0)
return (0);
return (s.pkt); /* 返回資料包緩衝區的指標 */
}

pcap_dispatch() 簡單的呼叫捕獲控制代碼 pcap_t 中定義的特定作業系統的讀資料函式:return p->read_op(p, cnt, callback, user)。在 linux 系統下,對應的讀函式為 pcap_read_linux()(在建立捕獲控制代碼時已定義 [pcap-linux.c]),而pcap_read_linux() 則是直接呼叫 pcap_read_packet()([pcap-linux.c])。

pcap_read_packet() 的中心任務是利用了 recvfrom() 從已建立的 socket 上讀資料包資料,但是考慮到 socket 可能為前面討論到的三種方式中的某一種,因此對資料緩衝區的結構有相應的處理,主要表現在加工模式下對偽鏈路層頭部的合成。具體程式碼分析如下:


static int
pcap_read_packet(pcap_t *handle, pcap_handler callback, u_char *userdata)
{
/* 資料包緩衝區指標 */
u_char * bp;

/* bp 與捕獲控制代碼 pcap_t 中 handle->buffer
之間的偏移量,其目的是為在加工模式捕獲情況下,為合成的偽資料鏈路層頭部留出空間 */
int offset;

/* PACKET_SOCKET 方式下,recvfrom() 返回 scokaddr_ll 型別,而在SOCK_PACKET 方式下,
返回 sockaddr 型別 */
#ifdef HAVE_PF_PACKET_SOCKETS 
                               struct sockaddr_ll  from;
                               struct sll_header   * hdrp;
#else
                               struct sockaddr                from;
#endif

socklen_t           fromlen;
int                            packet_len, caplen;

/* libpcap 自定義的頭部 */
struct pcap_pkthdr  pcap_header;

#ifdef HAVE_PF_PACKET_SOCKETS
/* 如果是加工模式,則為合成的鏈路層頭部留出空間 */
if (handle->md.cooked)
offset = SLL_HDR_LEN;

/* 其它兩中方式下,鏈路層頭部不做修改的被返回,不需要留空間 */
else
offset = 0;
#else
offset = 0;
#endif

bp = handle->buffer + handle->offset;
          
/* 從核心中接收一個數據包,注意函式入參中對 bp 的位置進行修正 */
packet_len = recvfrom( handle->fd, bp + offset,
handle->bufsize - offset, MSG_TRUNC,
(struct sockaddr *) &from, &fromlen);
          
#ifdef HAVE_PF_PACKET_SOCKETS
          
/* 如果是迴路裝置,則只捕獲接收的資料包,而拒絕傳送的資料包。顯然,我們只能在 PF_PACKET
方式下這樣做,因為 SOCK_PACKET 方式下返回的鏈路層地址型別為
sockaddr_pkt,缺少了判斷資料包型別的資訊。*/
if (!handle->md.sock_packet &&
from.sll_ifindex == handle->md.lo_ifindex &&
from.sll_pkttype == PACKET_OUTGOING)
return 0;
#endif

#ifdef HAVE_PF_PACKET_SOCKETS
/* 如果是加工模式,則合成偽鏈路層頭部 */
if (handle->md.cooked) {
/* 首先修正捕包資料的長度,加上鍊路層頭部的長度 */
packet_len += SLL_HDR_LEN;
                    hdrp = (struct sll_header *)bp;
                    
/* 以下的程式碼分別對偽鏈路層頭部的資料賦值 */
hdrp->sll_pkttype = xxx;
hdrp->sll_hatype = htons(from.sll_hatype);
hdrp->sll_halen = htons(from.sll_halen);
memcpy(hdrp->sll_addr, from.sll_addr, 
(from.sll_halen > SLL_ADDRLEN) ? 
SLL_ADDRLEN : from.sll_halen);
hdrp->sll_protocol = from.sll_protocol;
}
#endif
          
/* 修正捕獲的資料包的長度,根據前面的討論,SOCK_PACKET 方式下長度可能是不準確的 */
caplen = packet_len;
if (caplen > handle->snapshot)
caplen = handle->snapshot;

/* 如果沒有使用核心級的包過濾,則在使用者空間進行過濾*/
if (!handle->md.use_bpf && handle->fcode.bf_insns) {
if (bpf_filter(handle->fcode.bf_insns, bp,
packet_len, caplen) == 0)
{
/* 沒有通過過濾,資料包被丟棄 */
return 0;
}
}

/* 填充 libpcap 自定義資料包頭部資料:捕獲時間,捕獲的長度,真實的長度 */
ioctl(handle->fd, SIOCGSTAMP, &pcap_header.ts);
pcap_header.caplen  = caplen;
pcap_header.len                = packet_len;
          
/* 累加捕獲資料包數目,注意到在不同核心/捕獲方式情況下數目可能不準確 */
handle->md.stat.ps_recv++;

/* 呼叫使用者定義的回撥函式 */
callback(userdata, &pcap_header, bp);
}

資料包過濾機制
大量的網路監控程式目的不同,期望的資料包型別也不同,但絕大多數情況都都只需要所有資料包的一(小)部分。例如:對郵件系統進行監控可能只需要埠號為 25(smtp)和 110(pop3) 的TCP 資料包,對 DNS 系統進行監控就只需要埠號為 53 的 UDP 資料包。包過濾機制的引入就是為了解決上述問題,使用者程式只需簡單的設定一系列過濾條件,最終便能獲得滿足條件的資料包。包過濾操作可以在使用者空間執行,也可以在核心空間執行,但必須注意到資料包從核心空間拷貝到使用者空間的開銷很大,所以如果能在核心空間進行過濾,會極大的提高捕獲的效率。核心過濾的優勢在低速網路下表現不明顯,但在高速網路下是非常突出的。在理論研究和實際應用中,包捕獲和包過濾從語意上並沒有嚴格的區分,關鍵在於認識到捕獲資料包必然有過濾操作。基本上可以認為,包過濾機制在包捕獲機制中佔中心地位。

包過濾機制實際上是針對資料包的布林值操作函式,如果函式最終返回 true,則通過過濾,反之則被丟棄。形式上包過濾由一個或多個謂詞判斷的並操作(AND)和或操作(OR)構成,每一個謂詞判斷基本上對應了資料包的協議型別或某個特定值,例如:只需要 TCP 型別且埠為 110 的資料包或 ARP 型別的資料包。包過濾機制在具體的實現上與資料包的協議型別並無多少關係,它只是把資料包簡單的看成一個位元組陣列,而謂詞判斷會根據具體的協議對映到陣列特定位置的值。如判斷ARP型別資料包,只需要判斷陣列中第 13、14 個位元組(以太頭中的資料包型別)是否為 0X0806。從理論研究的意思上看,包過濾機制是一個數學問題,或者說是一個演算法問題,其中心任務是如何使用最少的判斷操作、最少的時間完成過濾處理,提高過濾效率。

BPF
Libpcap 重點使用 BPF(BSD Packet Filter)包過濾機制,BPF 於 1992 年被設計出來,其設計目的主要是解決當時已存在的過濾機制效率低下的問題。BPF的工作步驟如下:當一個數據包到達網路介面時,資料鏈路層的驅動會把它向系統的協議棧傳送。但如果 BPF 監聽介面,驅動首先呼叫 BPF。BPF 首先進行過濾操作,然後把資料包存放在過濾器相關的緩衝區中,最後裝置驅動再次獲得控制。注意到BPF是先對資料包過濾再緩衝,避免了類似 sun 的 NIT 過濾機制先緩衝每個資料包直到使用者讀資料時再過濾所造成的效率問題。參考資料D是關於 BPF 設計思想最重要的文獻。

BPF 的設計思想和當時的計算機硬體的發展有很大聯絡,相對老式的過濾方式CSPF(CMU/Stanford Packet Filter)它有兩大特點。1:基於暫存器的過濾機制,而不是早期記憶體堆疊過濾機制,2:直接使用獨立的、非共享的記憶體緩衝區。同時,BPF 在過濾演算法是也有很大進步,它使用無環控制流圖(CFG control flow graph),而不是老式的布林表示式樹(boolean expression tree)。布林表示式樹理解上比較直觀,它的每一個葉子節點即是一個謂詞判斷,而非葉子節點則為 AND 操作或 OR操作。CSPF 有三個主要的缺點。1:過濾操作使用的棧在記憶體中被模擬,維護棧指標需要使用若干的加/減等操作,而記憶體操作是現代計算機架構的主要瓶頸。2:布林表示式樹造成了不需要的重複計算。3:不能分析資料包的變長頭部。BPF 使用的CFG 演算法實際上是一種特殊的狀態機,每一節點代表了一個謂詞判斷,而左右邊分別對應了判斷失敗和成功後的跳轉,跳轉後又是謂詞判斷,這樣反覆操作,直到到達成功或失敗的終點。CFG 演算法的優點在於把對資料包的分析資訊直接建立在圖中,從而不需要重複計算。直觀的看,CFG 是一種"快速的、一直向前"的演算法。

過濾程式碼的編譯
BPF 對 CFG 演算法的程式碼實現非常複雜,它使用偽機器方式。BPF 偽機器是一個輕量級的,高效的狀態機,對 BPF 過濾程式碼進行解釋處理。BPF 過濾程式碼形式為"opcode jt jf k",分別代表了操作碼和定址方式、判斷正確的跳轉、判斷失敗的跳轉、操作使用的通用資料域。BPF 過濾程式碼從邏輯上看很類似於組合語言,但它實際上是機器語言,注意到上述 4 個域的資料型別都是 int 和 char 型。顯然,由使用者來寫過濾程式碼太過複雜,因此 libpcap 允許使用者書寫高層的、容易理解的過濾字串,然後將其編譯為BPF程式碼。

Libpcap 使用了 4 個源程式 gencode.c、optimize.c、grammar.c、scanner.c完成編譯操作,其中前兩個實現了對過濾字串的編譯和優化,後兩個主要是為編譯提供從協議相關過濾條件到協議無關(的字元陣列)位置資訊的對映,並且它們由詞彙分析器生成器 flex 和 bison 生成。參考資料 C 有對此兩個工具的講解。


flex -Ppcap_ -t scanner.l > $.scanner.c; mv $.scanner.c scanner.c
bison -y -p pcap_ -d grammar.y
mv y.tab.c grammar.c
mv y.tab.h tokdefs.h

編譯過濾字串呼叫了函式 pcap_compile()[getcode.c],形式為:


int pcap_compile(pcap_t *p, struct bpf_program *program,
               char *buf, int optimize, bpf_u_int32 mask)

其中 buf 指向使用者過濾字串,編譯後的 BPF 程式碼存在在結構 bpf_program中,標誌 optimize 指示是否對 BPF 程式碼進行優化。


/* [pcap-bpf.h] */
struct bpf_program {
u_int bf_len; /* BPF 程式碼中謂詞判斷指令的數目 */
struct bpf_insn *bf_insns; /* 第一個謂詞判斷指令 */
};
          
/* 謂詞判斷指令結構,含意在前面已描述 [pcap-bpf.h] */
struct bpf_insn {
u_short   code;
u_char    jt;
u_char    jf;
bpf_int32 k;
};

過濾程式碼的安裝
前面我們曾經提到,在核心空間過濾資料包對整個捕獲機制的效率是至關重要的。早期使用 SOCK_PACKET 方式的 Linux 不支援核心過濾,因此過濾操作只能在使用者空間執行(請參閱函式pcap_read_packet() 程式碼),在《UNIX 網路程式設計(第一卷)》(參考資料 B)的第 26 章中對此有明確的描述。不過現在看起來情況已經發生改變,linux 在 PF_PACKET 型別的 socket 上支援核心過濾。Linux 核心允許我們把一個名為 LPF(Linux Packet Filter) 的過濾器直接放到 PF_PACKET 型別 socket 的處理過程中,過濾器在網絡卡接收中斷執行後立即執行。LSF 基於 BPF 機制,但兩者在實現上有略微的不同。實際程式碼如下:


/* 在包捕獲裝置上附加 BPF 程式碼 [pcap-linux.c]*/
static int
pcap_setfilter_linux(pcap_t *handle, struct bpf_program *filter)
{
#ifdef SO_ATTACH_FILTER
struct sock_fprog   fcode;
int can_filter_in_kernel;
int err = 0;
#endif

/* 檢查控制代碼和過濾器結構的正確性 */
if (!handle)
return -1;
if (!filter) {
strncpy(handle->errbuf, "setfilter: No filter specified",
sizeof(handle->errbuf));
return -1;
}

/* 具體描述如下 */ 
if (install_bpf_program(handle, filter) < 0)
return -1;

/* 預設情況下在使用者空間執行過濾器,但如果在核心安裝成功,則值為 1 */
handle->md.use_bpf = 0;

          
/* 嘗試在核心安裝過濾器 */
#ifdef SO_ATTACH_FILTER
#ifdef USHRT_MAX
if (handle->fcode.bf_len > USHRT_MAX) {
/*過濾器程式碼太長,核心不支援 */
fprintf(stderr, "Warning: Filter too complex for kernel\n");
fcode.filter = NULL;
can_filter_in_kernel = 0;
} else
#endif /* USHRT_MAX */
{
/* linux 核心設定過濾器時使用的資料結構是 sock_fprog,而不是 BPF 的結構 bpf_program ,因此應做結構之間的轉換 */
switch (fix_program(handle, &fcode)) {
                                                   
/* 嚴重錯誤,直接退出 */
case -1:
default: 
return -1;
                                                   
/* 通過檢查,但不能工作在核心中 */
case 0: 
can_filter_in_kernel = 0;
break;

/* BPF 可以在核心中工作 */
case 1: 
can_filter_in_kernel = 1;
break;
}
}

/* 如果可以在核心中過濾,則安裝過濾器到核心中 */
if (can_filter_in_kernel) {
if ((err = set_kernel_filter(handle, &fcode)) == 0)
{
/* 安裝成功 !!! */
handle->md.use_bpf = 1;
}
else if (err == -1) /* 出現非致命性錯誤 */
{
if (errno != ENOPROTOOPT && errno != EOPNOTSUPP) {
fprintf(stderr, "Warning: Kernel filter failed:
 %s\n",pcap_strerror(errno));
}
}
}

/* 如果不能在核心中使用過濾器,則去掉曾經可能在此 socket
上安裝的核心過濾器。主要目的是為了避免存在的過濾器對資料包過濾的干擾 */
if (!handle->md.use_bpf)
reset_kernel_filter(handle);[pcap-linux.c]
#endif 
}


/* 把 BPF 程式碼拷貝到 pcap_t 資料結構的 fcode 上 */
int install_bpf_program(pcap_t *p, struct bpf_program *fp)
{
size_t prog_size;

/* 首先釋放可能已存在的 BPF 程式碼 */ 
pcap_freecode(&p->fcode);

/* 計算過濾程式碼的長度,分配記憶體空間 */
prog_size = sizeof(*fp->bf_insns) * fp->bf_len;
p->fcode.bf_len = fp->bf_len;
p->fcode.bf_insns = (struct bpf_insn *)malloc(prog_size);
if (p->fcode.bf_insns == NULL) {
snprintf(p->errbuf, sizeof(p->errbuf),
"malloc: %s", pcap_strerror(errno));
return (-1);
}

/* 把過濾程式碼儲存在捕獲控制代碼中 */
memcpy(p->fcode.bf_insns, fp->bf_insns, prog_size);
                               
return (0);
}

/* 在核心中安裝過濾器 */
static int set_kernel_filter(pcap_t *handle, struct sock_fprog *fcode)
{
int total_filter_on = 0;
int save_mode;
int ret;
int save_errno;

/*在設定過濾器前,socket 的資料包接收佇列中可能已存在若干資料包。當設定過濾器後,
這些資料包極有可能不滿足過濾條件,但它們不被過濾器丟棄。
這意味著,傳遞到使用者空間的頭幾個資料包不滿足過濾條件。
注意到在使用者空間過濾這不是問題,因為使用者空間的過濾器是在包進入佇列後執行的。
Libpcap 解決這個問題的方法是在設定過濾器之前,
首先讀完接收佇列中所有的資料包。具體步驟如下。*/
           
/*為了避免無限迴圈的情況發生(反覆的讀資料包並丟棄,但新的資料包不停的到達),首先設定一個過濾器,阻止所有的包進入 */
           
setsockopt(handle->fd, SOL_SOCKET, SO_ATTACH_FILTER,
&total_fcode, sizeof(total_fcode);

/* 儲存 socket 當前的屬性 */
save_mode = fcntl(handle->fd, F_GETFL, 0);

/* 設定 socket 它為非阻塞模式 */
fcntl(handle->fd, F_SETFL, save_mode | O_NONBLOCK);

/* 反覆讀佇列中的資料包,直到沒有資料包可讀。這意味著接收佇列已被清空 */
while (recv(handle->fd, &drain, sizeof drain, MSG_TRUNC) >= 0);
                                         
/* 恢復曾儲存的 socket 屬性 */
fcntl(handle->fd, F_SETFL, save_mode);
                               
/* 現在安裝新的過濾器 */
setsockopt(handle->fd, SOL_SOCKET, SO_ATTACH_FILTER,
fcode, sizeof(*fcode));
}

/* 釋放 socket 上可能有的核心過濾器 */
static int reset_kernel_filter(pcap_t *handle)
{
int dummy;
return setsockopt(handle->fd, SOL_SOCKET, SO_DETACH_FILTER,
&dummy, sizeof(dummy));
}

linux 在安裝和解除安裝過濾器時都使用了函式 setsockopt(),其中標誌SOL_SOCKET 代表了對 socket 進行設定,而 SO_ATTACH_FILTER 和 SO_DETACH_FILTER 則分別對應了安裝和解除安裝。下面是 linux 2.4.29 版本中的相關程式碼:


[net/core/sock.c]
#ifdef CONFIG_FILTER
case SO_ATTACH_FILTER:
……
/* 把過濾條件結構從使用者空間拷貝到核心空間 */
if (copy_from_user(&fprog, optval, sizeof(fprog)))
break;
/* 在 socket 上安裝過濾器 */
ret = sk_attach_filter(&fprog, sk);
                               ……

case SO_DETACH_FILTER:
/* 使用自旋鎖鎖住 socket */
spin_lock_bh(&sk->lock.slock);

filter = sk->filter;
/* 如果在 socket 上有過濾器,則簡單設定為空,並釋放過濾器記憶體 */
if (filter) {
sk->filter = NULL;
spin_unlock_bh(&sk->lock.slock);
sk_filter_release(sk, filter);
break;
}
spin_unlock_bh(&sk->lock.slock);
ret = -ENONET;
break;
#endif

上面出現的 sk_attach_filter() 定義在 net/core/filter.c,它把結構sock_fprog 轉換為結構 sk_filter, 最後把此結構設定為 socket 的過濾器:sk->filter = fp。

其他程式碼
libpcap 還提供了其它若干函式,但基本上是提供輔助或擴充套件功能,重要性相對弱一點。我個人認為,函式 pcap_dump_open() 和 pcap_open_offline() 可能比較有用,使用它們能把線上的資料包寫入檔案並事後進行分析處理。

總結
1994 年 libpcap 的第一個版本被髮布,到現在已有 11 年的歷史,如今libpcap 被廣泛的應用在各種網路監控軟體中。Libpcap 最主要的優點在於平臺無關性,使用者程式幾乎不需做任何改動就可移植到其它 unix 平臺上;其次,libpcap也能適應各種過濾機制,特別對BPF的支援最好。分析它的原始碼,可以學習開發者優秀的設計思想和實現技巧,也能瞭解到(linux)作業系統的網路核心實現,對個人能力的提高有很大幫助。

B:《UNIX網路程式設計(第一卷)》 W.Richard Stevens

C:《使用 lex 和 yacc 編譯程式碼》 Peter Seebach

E:linux 聯機幫助手冊:socket(2)、socket(7)、packet等