2019独角兽企业重金招聘Python工程师标准>>>
简介
Suricata支持多种数据包源:pcap(实时/文件)、nfq、ipfw、mpipe、af-packet、pfring、dag(实时/文件)、napatech。
每种数据包源的支持都对应于一个线程模块(Thread Module),得益于这种其模块化的架构,增加一个新的数据源支持只需要添加一个新的线程模块即可。
这里,我将主要记录最常见的pcap实时数据源的实现细节,包括相关数据结构、运行流程,以及与主框架和其他模块的交互等。
模块注册
TmModuleReceivePcapRegister函数用于实现pcap实时数据源的线程模块的注册,该函数在系统初始化阶段由RegisterAllModules函数所调用。函数内部唯一的工作就是填充TmModule类型的结构体变量:tmm_modules[TMM_RECEIVEPCAP]。下面是各字段的填充内容:
字段 | 填充值 | 含义及用处 |
name | "ReceivePcap" | 线程名字:目前没有看到代码中有对这个变量的使用。 |
ThreadInit | ReceivePcapThreadInit | 初始化函数:在_TmSlotSetFuncAppend中被传递给其所嵌入的slot的SlotThreadInit函数,而该函数将在线程执行函数(如TmThreadsSlotVar)中被调用。 |
Func | NULL | 模块执行函数:对于数据源模块,其执行函数为下面的PktAcqLoop。 |
PktAcqLoop | ReceivePcapLoop | 数据包获取函数:在TmThreadsSlotPktAcqLoop中被调用。 |
ThreadExitPrintStats | ReceivePcapThreadExitStats | 退出打印函数:用于打印模块统计信息,同样被赋给slot对应函数,然后在线程执行函数的退出阶段被调用。 |
ThreadDeinit | NULL | 清理函数:这里设成NULL可能是个BUG,因为存在一个正好用于这个目的却没有被引用过的函数:ReceivePcapThreadDeinit,其中调用pcap_close进行了清理。 |
RegisterTests | NULL | 注册测试函数:用来注册模块内部所编写的单元测试,在单元测试模式下,运行所有测试前将调用TmModuleRegisterTests函数先注册所有线程模块的单元测试函数。 |
cap_flags | SC_CAP_NET_RAW | 能力标志(capability flags):标志这个线程模块所需要的能力,以确定能让整个系统正常运行所需要的最小权限。SC_CAP_NET_RAW应该是表示需要获取原始数据包的能力。 |
flags | TM_FLAG_RECEIVE_TM | 其他标志:TM_FLAG_RECEIVE_TM表示这个模块的用途是收包(其他还有解码、检测等)。 |
模块初始化
如上表所示,模块初始化由ReceivePcapThreadInit函数完成,其函数原型为:
TmEcode ReceivePcapThreadInit(ThreadVars *tv, void *initdata, void **data)
其中,tv参数对应该模块所嵌入的线程的ThreadVars,在使用某些与线程相关的函数时需要使用。
initdata参数是模块的初始化数据,在使用TmSlotSetFuncAppend向线程中嵌入模块时传入。对于这个模块,其初始数据是一个PcapIfaceConfig类型的结构体指针,而这个结构体是由运行模式(对应于这个模块的运行模式类型为RUNMODE_PCAP_DEV)初始化函数(例如如RunModeIdsPcapAutoFp)中的ParsePcapConfig通过查询配置节点树下的pcap节点信息所填充的。具体的一些重要配置信息包括:
字段 | 含义 |
iface | 设备接口名字,如linux下的"eth0"。 |
threads | 抓包线程数量,默认为1。 |
buffer_size | 接收缓冲大小,通过pcap_set_buffer_size设置。 |
snaplen | 抓取长度,通过pcap_set_snaplen设置。 |
promisc | 是否开启混杂模式,通过pcap_set_promisc设置。注意:如果要打开混杂模式,那么网卡必须也要打开(ifconfig eth0 promisc)。 |
bpf_filter | BPF过滤器表达式,通过pcap_compile编译成bpf_program后再通过pcap_setfilter设置。 |
checksum_mode | 校验和验证模式,设置为auto表示使用统计方式确定当前是否有checksum off-loading,若有,则关闭后续的校验和验证。 |
ref, DerefFunc | 实现对配置结构体的引用计数。ref初始为当前使用该接口的线程(threads),因为这些线程都会在ReceivePcapThreadInit中引用这个配置结构。当用完配置结构体后(或出错需要退出时),就调用DerefFunc(PcapDerefConfig)减少引用计数,并在引用计数值为0时调用SCFree回收内存。 |
最后一个data参数,是该初始化函数的结果输出。函数内部会新建一个PcapThreadVars结构体,作为本模块的内部上下文,其中部分字段是直接copy的PcapIfaceConfig,另一些重要字段包括:
字段 | 含义 |
livedev | 当前设备结构体(LiveDevice类型),记录设备统计信息(收包、丢包、校验和验证失败的包),通过iface调用LiveGetDevice查找得到。 |
pcap_handle | pcap句柄,通过pcap_open_live获得,后续使用pcap库函数时都会用到。 |
pcap_state | pcap状态,UP或DOWN,在使用PcapTryReopen重新打开pcap时用到。 |
在初始化函数返回时,填充好的PcapThreadVars结构体便传递给了包含该模块的slot的slot_data字段,该字段将做为参数传入后续的模块函数。
数据包获取
初始化完成后,TmThreadsSlotPktAcqLoop函数将进入一个while循环,调用slot的PktAcqLoop函数获取并处理数据包。对应的模块函数原型为:
TmEcode ReceivePcapLoop(ThreadVars *tv, void *data, void *slot)
其中,data即为初始化阶段生成的PcapThreadVars结构体,而slot就是包含该模块的那个slot,传入进来的目的主要是获取线程中后续的slot(这里对应DecodePcap),以调用后续的数据包处理流程。
函数内部的核心是一个while(1)循环,流程如下:
- 检查suricata_ctl_flags是否包含STOP或KILL标志,即Suricata是否需要退出,若是的话则立即返回。这里让子线程去检查全局控制标志的处理方式感觉的不是很优雅,最好是只检查自己的线程标志。
- 等待packet pool中有空闲数据包结构(通过cond或sleep实现)。
- 调用pcap_dispatch获取并处理数据包。其中,第2个参数表示最多处理的数据包个数,传入的值packet_q_len为packet pool的当前大小;第3个参数为包处理回调函数PcapCallbackLoop;最后一个参数为传给前面的回调函数的用户数据,这里传入的是PcapThreadVars结构体。
- 检查返回值,若出错则调用pcap_geterr获取错误信息,然后尝试不断调用PcapTryReopen重新开启抓包。
此后,pcap库内部会对每一个原始数据包,都调用PcapCallbackLoop函数进行进一步处理。
数据包封装
在Suricata中,用来封装数据包的结构体为Packet,核心字段如下:
字段 | 含义 |
src/dst、sp/dp、proto | 五元组信息:源/目的地址,源/目的端口号,传输层协议(TCP/UDP/…)。 |
flow | 数据包所属的流指针(类型为Flow_ *)。 |
ip4h、ip6h | 网络层数据指针。 |
tcph、udph、sctph、icmpv4/6h | 传输层数据指针。 |
payload、payload_len | 应用层负载指针及长度。 |
next、prev | 前一个/后一个数据包指针,用于组成双向链表。 |
PcapCallbackLoop函数中第一步就是完成对数据包的封装。其函数原型如下:
void PcapCallbackLoop(char *user, struct pcap_pkthdr *h, u_char *pkt)
其中,user为用户数据,即之前传入的PcapThreadVars,h为pcap包结构体头,pkt为包数据指针。函数流程如下:
- 调用PacketGetFromQueueOrAlloc获取一个Packet结构。该函数会首先尝试调用PacketPoolGetPacket从packet pool中直接获取,如果失败(已经用完了),那么就调用PacketGetFromAlloc新分配一个。
注:与pool中取出的数据包最终可以回收不同,这种使用malloc动态分配的数据包最后需要free,因此为了区分会在其flags中打上标记PKT_ALLOC。
- 填充Packet结构体的部分字段:数据包源(PKT_SRC_WIRE)、时间戳、所属数据链路/设备。
- 调用PacketCopyData复制数据包内容到pkt字段中,并设置pktlen为相应的长度。
注:为什么需要做复制这种开销大的操作呢?man pcap_dispatch给出了答案:The struct pcap_pkthdr… are not guaranteed to be valid after the callback routine returns; if the code needs them to be valid after the callback, it must make a copy of them. 而由于Suricata的多线程和异步特性,数据包在callback中会送入outq中等待后续线程继续处理,因此这里必须进行复制。
- 校验和相关的处理。若checksum_mode为DISABLE,将会给包的flags打上PKT_IGNORE_CHECKSUM标志,表示后续不再对其进行校验和验证。若checksum_mode为AUTO,则调用ChecksumAutoModeCheck进行统计分析,满足条件则后续该设备的数据包都会关闭校验和验证。目前是的关闭条件是:1000个包中若有超过10%的包校验和不正确,则认为网卡开启了checksum offloading,因而关闭检验和验证。
- 调用TmThreadsSlotProcessPkt让本线程中包含的其他slot中的模块对数据包进行后续处理。若处理返回失败,则调用pcap_breakloop中断抓包。
- 调用PcapDumpCounters打印抓包统计信息。这个打印保证每秒只触发一次,机制是:获取当前时间,只有当前秒数与上一次记录的秒数不同时才调用。
数据包处理
从上面可以看出,进一步的数据包处理是在TmThreadsSlotProcessPkt中完成,其原型为:
static inline TmEcode TmThreadsSlotProcessPkt(ThreadVars *tv, TmSlot *s, Packet *p)
其中,s就是前面一路传下来的slot,而p为当前要处理的Packet。处理流程如下:
- 调用TmThreadsSlotVarRun,将数据包依次传入后续的各个slot进行处理。
- 若返回失败,则调用TmqhOutputPacketpool将数据包(以及各个slot的slot_post_pq中的数据包)进行回收或释放。然后设置线程标志为THV_FAILED,等待主线程处理。
- 若返回成功,则调用tmqh_out(线程创建时设置为与该线程绑定的outqh的处理函数OutHandler,在这里默认为TmqhOutputFlowActivePackets),将数据包送到后续队列中去。
- 此外,由于各模块在处理过程中可能会新生成数据包(如隧道数据包、重组数据包),这些数据包存储在与每个slot绑定的slot_pre_pq或slot_post_pq队列中,因此还需要类似上述流程,对这些数据包进行处理。这里只集中处理了slot_post_pq,slot_pre_pq将在处理每个slot后立即处理。
对数据包进行进一步处理的TmThreadsSlotVarRun函数原型如下:
TmEcode TmThreadsSlotVarRun(ThreadVars *tv, Packet *p,TmSlot *slot)
按照函数头的注释说明,这个函数被从母函数中拉出来独立存在的原因是,为了能够对其进行递归调用。函数主流程是一个遍历所有slot的for循环,其执行过程如下:
- 调用slot的处理函数SlotFunc。
- 若返回失败,处理流程与上面类似。
- 若返回成功,则继续处理slot_pre_pq:对其中每个数据包,都递归调用TmThreadsSlotVarRun,将其送入下一个slot进行处理。
注: slot_pre_pq vs. slot_post_pq: 某个slot在处理某个母数据包时新产生的子数据包,若放入slot_pre_pq中,则这个数据包将在本个slot处理完母数据包后,在后续slot处理母数据包之前,先将这些子数据包放到后续的slot去处理;而如果是放如slot_post_pq,则需要等到母数据包被所有slot都处理完后,在下一个数据包处理之前,再去集中处理,如上面所述。
前一篇main()的笔记中已经记录了,抓包线程中只有两个slot,第一个slot在嵌入的即是前面所介绍的ReceivePcap模块,而下一个slot函数中嵌入的模块为DecodePcap,其对应的SlotFunc是DecodePcap,其任务是对从通过pcap抓取的数据包进行解码。
到次,这个pcap实时数据包源的任务已经完成了。其他数据包源模块的功能和流程也基本类似,最终都会递交给相应的解码模块。