NIDS(Network Intrusion Detection System,网络入侵检测系统)在网络安全领域发挥着重要的作用,NIDS的开发主要涵盖两个关键部分:DPI深度包解析和规则引擎。其中,DPI深度包解析是NIDS运作的基础,它通过监听网络流量,捕捉数据包并进行协议识别、字段拆解。常见的数据包捕捉工具包括libpcap、PF_RING、DPDK等,而像PacketBeat、NDPI、Zeek、Suricata则是常用的DPI深度包解析工具。

在TCP/IP协议栈中,数据包的重组涉及IP分片重组和TCP段重组两个重要方面。这两种重组操作对于NIDS来说意义重大,接下来我们详细探讨一下。

一、IP分片重组和TCP段重组基础概念

(一)IP分片重组

当一个IP数据报过大,无法在当前物理网络上直接传输时,就会被分割成多个较小的片段,也就是分片。每个分片都会作为独立的IP数据报在网络中传输,最终在目的地重新组装成原始的数据报,这个过程就是IP分片重组。

IP头部的标识字段、标志字段和分片偏移字段在重组过程中发挥关键作用。标识字段用于区分不同的数据报,标志字段中的MF(More Fragments)位表示后面是否还有更多分片,分片偏移字段则指明该分片在原始数据报中的位置。

(二)TCP段重组

TCP协议是面向连接且提供可靠数据传输服务的协议。在数据传输时,发送方可能会将数据分割成多个TCP段进行发送。由于网络路由和延迟等因素,这些TCP段到达接收方的顺序可能与发送顺序不一致。TCP利用序列号和确认机制,确保所有发送的数据都能按正确顺序重组,接收方会根据序列号对收到的TCP段进行排序,并等待丢失段的重传。

(三)NIDS涉及重组的原因

大家可能会疑惑,IP分片重组和TCP段重组不是TCP/IP协议栈的工作吗,为什么NIDS也需要处理呢?这是因为NIDS使用如libpcap或DPDK等工具抓取的是一帧数据,一个IP分片通常被封装在一帧中传输。例如,在http post一个较大的json数据时,数据可能会经过TCP段重组(如果没有IP分片的情况下),NIDS才能看到完整的json内容。在TCP/IP协议栈中,每一层对上层是透明的,但NIDS需要自己重组每一层的数据,以便进行深入分析。

(四)IP分片和TCP分段的区别

IP分片和TCP分段虽然都涉及数据的切分,但它们发生在网络协议栈的不同层次。IP分片发生在网络层,由IP协议处理,主要是因为网络链路层的MTU(最大传输单元)限制,当IP数据报大小超过MTU时就需要分片。而TCP分段发生在传输层,由TCP协议处理,它是为了适应MSS(最大分段大小),避免IP层分片,TCP会根据MSS对数据进行分段。TCP分段在基于TCP的应用层协议(如HTTP、FTP)中非常常见,而IP分片通常是网络层处理特殊情况的手段,理想情况下应尽量避免。

二、IP分片重组与TCP段重组示例

(一)IP分片示例

假设主机A要通过以太网向主机B发送一个4000字节的IP数据报,而网络路径的MTU为1500字节,IP首部长度为20字节。由于数据报总长度超过MTU,所以需要进行IP分片。

  1. 计算IP分片数量:每个分片的最大有效载荷为1500 – 20(IP首部) = 1480字节。总数据量为4000字节,因此需要分成4000 ÷ 1480 ≈ 3片。
  2. IP分片详细信息

    分片编号数据范围数据长度偏移量(字节)偏移量(单位)MF标志位总长度
    第1片0 ~ 147914800011500
    第2片1480 ~ 29591480148018511500
    第3片2960 ~ 39991040296037001060
  3. 重组过程:主机B收到这些分片后,根据标识字段确定它们属于同一个原始数据报,再依据偏移量和MF标志位,按顺序将所有分片拼接起来,最终得到与原始数据报完全相同的4000字节数据。

(二)TCP分段示例

假设主机A要向主机B发送4000字节的应用层数据,网络路径的MTU为1500字节,TCP的MSS协商后为1460字节(即1500 – 20(IP首部) – 20(TCP首部)),IP首部长度为20字节。因为应用层数据大小超过MSS限制,所以需要进行TCP分段。

  1. 计算TCP分段数量:每个TCP数据段的最大有效载荷为1460字节。总数据量为4000字节,因此需要分成4000 ÷ 1460 ≈ 3段。
  2. TCP分段详细信息

    分段编号序列号范围数据长度TCP首部长度IP首部长度总长度
    第1段1 ~ 1460146020201500
    第2段1461 ~ 2920146020201500
    第3段2921 ~ 4000108020201120
  3. 重组过程:主机B收到这些分段后,根据TCP序列号将所有分段按顺序拼接起来,得到完整的4000字节数据,并交给应用层。在这个例子中,由于MTU和MSS的设置,无需进行IP分片。

三、IP分片重组的实际实现——以Suricata为例

(一)核心数据结构

Suricata处理IP分片重组时,定义了关键的数据结构:

// defrag.h - 分片重组的主要数据结构 typedef struct DefragTracker_ { SCMutex lock; // 互斥锁保护 uint32_t id; // IP包ID uint8_t proto; // IP协议 uint8_t policy; // 重组策略 uint8_t af; // 地址族(IPv4/IPv6) uint8_t seen_last; // 是否看到最后一个分片 Address src_addr; // 源地址 Address dst_addr; // 目的地址 struct IP_FRAGMENTS fragment_tree; // 分片树 } DefragTracker; // 单个分片的结构 typedef struct Frag_ { uint16_t offset; // 分片偏移 uint32_t len; // 分片长度 uint8_t more_frags; // 是否还有更多分片 uint8_t *pkt; // 实际分片数据 RB_ENTRY(Frag_) rb; // 红黑树节点 } Frag; 

DefragTracker用于跟踪同一数据包的所有分片,其中包含互斥锁、IP包ID、协议类型、地址族等信息,还通过分片树管理分片。Frag则定义了单个分片的偏移、长度、是否还有后续分片以及实际数据等内容。

(二)重组流程

decode - ipv4.c文件中,处理流程如下:

int DecodeIPV4(ThreadVars *tv, DecodeThreadVars *dtv, Packet *p, const uint8_t *pkt, uint16_t len) { // 1. 检测到分片标记 if (IPV4_GET_RAW_FRAGOFFSET(ip4h) > 0 || IPV4_GET_RAW_FLAG_MF(ip4h)) { // 2. 调用Defrag进行重组 Packet *rp = Defrag(tv, dtv, p); if (rp != NULL) { // 3. 重组成功,将重组后的包加入队列 PacketEnqueueNoLock(&tv->decode_pq, rp); } p->flags |= PKT_IS_FRAGMENT; return TM_ECODE_OK; } } 

IPv4通过检查分片偏移和MF标志位识别分片,IPv6则通过分片扩展头识别。Suricata使用红黑树存储各个分片,并按偏移量排序。在Defrag函数中,会创建或查找分片追踪器,将分片加入分片树,检查是否可以重组,重组后生成新包并返回。此外,还有处理IPv6分片头的函数DecodeIPV6FragHeader,用于解析分片头、设置标记和进行合法性检查。

总的来说,Suricata的IP分片重组实现具备以下特点:利用分片跟踪器和分片树管理分片,支持IPv4和IPv6协议,拥有完善的错误检测机制,通过队列管理重组后的包,并且采用互斥锁保证线程安全,全面覆盖了IP分片重组的各个环节。

四、TCP段重组的实际实现——以Zeek为例

(一)核心数据结构

Zeek处理TCP段重组的主要组件是TCP_ReassemblerTCPSessionAdapter,相关数据结构定义如下:

// src/analyzer/protocol/tcp/TCP_Reassembler.h // TCP_Reassembler类 class TCP_Reassembler final : public Reassembler { enum Type { Direct, // 直接传递到目标分析器 Forward // 转发到目标分析器的子节点 }; TCP_Endpoint* endp; // TCP端点 bool had_gap; // 是否有数据空洞 bool deliver_tcp_contents; // 是否传递TCP内容 uint64_t seq_to_skip; // 要跳过的序列号 FilePtr record_contents_file; // 记录内容的文件 }; // src/packet_analysis/protocol/tcp/TCPSessionAdapter.h // TCPSessionAdapter类 class TCPSessionAdapter { TCP_Endpoint* orig; // 发起方端点 TCP_Endpoint* resp; // 响应方端点 bool reassembling; // 是否正在重组 bool is_partial; // 是否部分连接 uint64_t rel_data_seq; // 相对数据序列号 }; 

TCP_Reassembler负责具体的重组工作,TCPSessionAdapter则管理整个TCP会话。

(二)重组流程

当启用重组时,TCPSessionAdapter会创建TCP_Reassembler实例:

// 启用重组: void TCPSessionAdapter::EnableReassembly() { SetReassembler( new TCP_Reassembler(this, TCP_Reassembler::Forward, orig), new TCP_Reassembler(this, TCP_Reassembler::Forward, resp) ); } 

当有TCP包到达时,会调用TCP_ReassemblerDataSent函数处理数据:

// 数据到达处理: // src/analyzer/protocol/tcp/TCP_Reassembler.cc - TCP重组器实现 void TCP_Reassembler::DataSent(double t, uint64_t seq, int len, const u_char* data) { // 检查是否需要跳过 if (IsSkippedContents(seq, len)) return; // 处理数据空洞 if (seq > last_reassem_seq) { Gap(last_reassem_seq, seq - last_reassem_seq); had_gap = true; } // 交付数据 DeliverBlock(seq, len, data); } 

同时,TCPSessionAdapterProcess函数会获取序列号、处理标志位并进行状态分析和数据重组:

// src/packet_analysis/protocol/tcp/TCPSessionAdapter.cc void TCPSessionAdapter::Process(bool is_orig, const struct tcphdr* tp, int len, const IP_Hdr* ip) { // 获取序列号 uint32_t base_seq = ntohl(tp->th_seq); // 处理标志位 TCP_Flags flags(tp); // 分析状态 UpdateStateMachine(t, endpoint, peer, base_seq, flags); // 数据重组 if (len > 0 && !flags.RST()) endpoint->DataSent(t, base_seq, len, data); } 

在处理过程中,如果发现序列号不连续,会进行空洞处理:

// 空洞处理: void TCP_Reassembler::Gap(uint64_t seq, uint64_t len) { // 报告空洞 if (report_gap(endp, endp->peer)) dst_analyzer->EnqueueConnEvent(content_gap, ...); // 更新状态 had_gap = true; } 

此外,TCPSessionAdapter还会跟踪TCP状态:

// 跟踪TCP状态: void TCPSessionAdapter::UpdateStateMachine(TCP_Endpoint* endpoint, TCP_Endpoint* peer, uint32_t seq, TCP_Flags flags) { // SYN处理 if (flags.SYN()) { if (is_orig) { endpoint->SetState(TCP_ENDPOINT_SYN_SENT); } else { endpoint->SetState(TCP_ENDPOINT_ESTABLISHED); } } // FIN处理 if (flags.FIN()) { endpoint->SetState(TCP_ENDPOINT_CLOSED); } // RST处理 if (flags.RST()) { endpoint->SetState(TCP_ENDPOINT_RESET); } } 

Zeek通过序列号跟踪、缓存管理和状态跟踪等机制,确保数据的正确重组。它就像在整理一系列明信片,每张明信片是一个TCP段,通过序列号按顺序排列,遇到乱序或丢失的明信片(数据空洞)时,会等待并处理,最终完成整个“拼图”(数据重组)。

五、简单的TCP流重组Demo示例

// TCP流结构 typedef struct { uint32_t src_ip; uint32_t dst_ip; uint16_t src_port; uint16_t dst_port; uint32_t next_seq; // 关键字段:下一个期望的序列号 uint32_t init_seq; // 初始序列号 time_t last_seen; unsigned char *stream; // 存储重组数据的缓冲区 int stream_size; // 当前已重组的数据大小 flow_state state; char src_ip_str[INET_ADDRSTRLEN]; char dst_ip_str[INET_ADDRSTRLEN]; } tcp_flow_t; // 全局变量 tcp_flow_t flows[MAX_FLOWS]; int flow_count = 0; 
void handle_tcp_packet(const struct ip *ip_header, const struct tcphdr *tcp_header, const unsigned char *payload, int payload_len) { tcp_flow_t *flow = find_or_create_flow(ip_header, tcp_header); if (!flow) return; if (tcp_header->th_flags & TH_SYN) { if (flow->state == FLOW_NEW) { flow->state = FLOW_ESTABLISHED; flow->next_seq = ntohl(tcp_header->th_seq) + 1; // SYN包,序列号+1 print_flow_info(flow, "SYN"); } } else if (tcp_header->th_flags & TH_FIN || tcp_header->th_flags & TH_RST) { flow->state = FLOW_CLOSED; print_flow_info(flow, tcp_header->th_flags & TH_FIN ? "FIN" : "RST"); process_stream_data(flow); } else if (payload_len > 0) { uint32_t seq = ntohl(tcp_header->th_seq); if (seq == flow->next_seq) { // 关键重组逻辑:检查序列号是否符合预期 // 确保不会超出缓冲区 int copy_len = payload_len; if (flow->stream_size + copy_len > MAX_STREAM_SIZE) { copy_len = MAX_STREAM_SIZE - flow->stream_size; } if (copy_len > 0) { // 按序复制数据到重组缓冲区 memcpy(flow->stream + flow->stream_size, payload, copy_len); flow->stream_size += copy_len; flow->next_seq = seq + copy_len; // 更新下一个期望的序列号 flow->state = FLOW_DATA; print_flow_info(flow, "DATA"); } } } } 

这个Demo实现了基本的TCP流跟踪功能,包含序列号检查、按序重组数据以及状态跟踪(如SYN、FIN、RST等),完整代码可在https://github.com/njcx/pcap_tcp_reassemble获取。

通过对IP分片重组和TCP段重组的深入分析,以及实际示例和代码实现的介绍,希望大家对NIDS开发中的这两个关键环节有更清晰的理解,想必聪明的你已经学会了吧。