章
目
录
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分片。
- 计算IP分片数量:每个分片的最大有效载荷为1500 – 20(IP首部) = 1480字节。总数据量为4000字节,因此需要分成4000 ÷ 1480 ≈ 3片。
- IP分片详细信息:
分片编号 数据范围 数据长度 偏移量(字节) 偏移量(单位) MF标志位 总长度 第1片 0 ~ 1479 1480 0 0 1 1500 第2片 1480 ~ 2959 1480 1480 185 1 1500 第3片 2960 ~ 3999 1040 2960 370 0 1060 - 重组过程:主机B收到这些分片后,根据标识字段确定它们属于同一个原始数据报,再依据偏移量和MF标志位,按顺序将所有分片拼接起来,最终得到与原始数据报完全相同的4000字节数据。
(二)TCP分段示例
假设主机A要向主机B发送4000字节的应用层数据,网络路径的MTU为1500字节,TCP的MSS协商后为1460字节(即1500 – 20(IP首部) – 20(TCP首部)),IP首部长度为20字节。因为应用层数据大小超过MSS限制,所以需要进行TCP分段。
- 计算TCP分段数量:每个TCP数据段的最大有效载荷为1460字节。总数据量为4000字节,因此需要分成4000 ÷ 1460 ≈ 3段。
- TCP分段详细信息:
分段编号 序列号范围 数据长度 TCP首部长度 IP首部长度 总长度 第1段 1 ~ 1460 1460 20 20 1500 第2段 1461 ~ 2920 1460 20 20 1500 第3段 2921 ~ 4000 1080 20 20 1120 - 重组过程:主机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_Reassembler
和TCPSessionAdapter
,相关数据结构定义如下:
// 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_Reassembler
的DataSent
函数处理数据:
// 数据到达处理:
// 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);
}
同时,TCPSessionAdapter
的Process
函数会获取序列号、处理标志位并进行状态分析和数据重组:
// 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开发中的这两个关键环节有更清晰的理解,想必聪明的你已经学会了吧。