Suricata 源码阅读(十):命中之后发生什么——从规则匹配到一行 JSON
这是系列第 10 篇,也是最后一篇。上一篇结尾,一条规则的所有条件都满足了,”产生一条告警”。可告警不是凭空出现在
eve.json里的:它要先经过一轮收尾,再由日志模块拼成 JSON、写到文件里。00 篇开头那行 http 事件,走的也是这条路。这一篇就把最后这一段走完。
先给答案,输出这一段做了三件事:
- 告警先攒起来,再统一收尾。 规则命中时,告警先放进一个队列;整个包检测完,再统一排序、做抑制和阈值检查、执行
drop这类动作,最后挂到p->alerts上。 - 日志按类型分发。 包处理的最后一步,
OutputLoggerLog()把包交给几类”根日志器”:包、事务、文件……每一类管着一批日志模块,各模块按自己的条件,或者按事务的进度,决定写不写。 - 拼成一行 JSON,写出去。 EVE 的每个日志模块用同一套工具拼 JSON:先拼所有事件共用的头,再加自己的字段,最后写到文件、syslog、unix socket 或 redis。
画出来是这样的:
1. 告警:先进队列,再统一收尾
规则检测命中时,检测引擎并不马上把告警写进包里,而是调用 AlertQueueAppend()(src/detect-engine-alert.c:393)把它放进本线程的告警队列。原因是:一个包可能命中好几条规则,而这些规则之间会互相影响。比如命中了一条 pass 规则,排在它后面的告警就不该再出现;drop 也要等所有规则都看完才能确定。
整个包检测完,PacketAlertFinalize()(src/detect-engine-alert.c:785)统一处理这个队列:
- 排序:按规则的顺序排好(上一篇的规则排序在这里又起作用了)。
- 逐条检查:
PacketAlertHandle()(src/detect-engine-alert.c:74)先看抑制(suppress),再看阈值(threshold)。比如规则写了”同一个源地址 60 秒内只告警一次”,第二次命中就在这里被拦下。 - 执行动作:
drop、pass这类动作在这里生效,drop会把包标记为丢弃(IPS 模式下,第 3 篇讲过,这个包就不会被转发出去)。 - **写进
p->alerts**:通过检查的告警,写进第 3 篇讲过的Packet里的alerts字段。每个包最多 15 条(packet-alert-max,src/decode.h:284);被抑制的只计入suppressed,超出上限的只计入discarded。
到这里,告警还只是 Packet 上的一段数据。真正写到日志里,是下一步的事。
2. 日志:按类型分发
第 5 篇讲 FlowWorker 时提到,它的最后一步是 OutputLoggerLog(),而且对每个包都会调用。它本身非常简单(src/output.c:785):
1 | TmEcode OutputLoggerLog(ThreadVars *tv, Packet *p, void *thread_data) |
它依次调用几个”根日志器”。这些根日志器在 OutputRegisterRootLoggers()(src/output.c:935)里注册,一共有包、事务、文件、文件数据、流式数据几类,每一类再管着一批具体的日志模块。
包日志器。 OutputPacketLog()(src/output-packet.c:84)遍历挂在它下面的模块,每个模块带一个条件函数,满足条件才写:
1 | if (logger->ConditionFunc(tv, store->thread_data, (const Packet *)p)) { |
EVE 的告警模块就挂在这里,它的条件函数很直白(src/output-json-alert.c:927):
1 | static bool JsonAlertLogCondition(ThreadVars *tv, void *thread_data, const Packet *p) |
包上有告警(或者有 tag 关键字标记的数据)才写。绝大多数包没有告警,这一步就直接跳过了。
事务日志器。 OutputTxLog()(src/output-tx.c:336)遍历这条流上的事务,对每个事务、每个还没写过的日志模块判断一次:事务进度够不够。够了就写,写完在事务的 logged 标志里记一笔,下次就不再写了。
“进度够不够”由日志模块注册时决定。HTTP 日志模块注册时没有指定进度,src/output-tx.c:98 起的这段逻辑会把它换成协议注册的”完成状态”:
1 | } else if (tc_log_progress < 0) { |
也就是说,HTTP 事件要等事务两个方向都完成才写。这就是 00 篇第 ⑧ 站说的:http 事件要等服务器的响应也回来、事务完成之后才写出,所以那行 JSON 里才有 status 和 length。第 7 篇讲事务释放的条件之一”所有日志模块都已经记录过”,看的也是这里打的 logged 标志。
不走这条路的。 有两类日志不经过 OutputLoggerLog():
flow日志:第 5 篇讲过,它在流被回收时,由 FlowRecycler 线程调用OutputFlowLog()写出,记录的是整条连接的汇总。stats日志:由专门的统计线程定时写出,前面几篇提到的那些计数器(capture.kernel_drops、tcp.reassembly_gap、app_layer.flow.http……)都在这里。
哪些模块被启用。 这些日志模块都是在启动时注册的(OutputRegisterLoggers(),src/output.c:1072),但只有配置文件 outputs 下打开的才会被启用:RunModeInitializeOutputs()(src/runmodes.c:772)读配置,把 eve-log 的 types 里列出的 alert、http、dns、flow 等模块挂到对应的根日志器上。默认配置里这些常用类型都是打开的,所以像 00 篇开头那样 curl 一次,除了 http 事件,通常还会看到 dns、fileinfo、flow 等事件。
3. EVE:拼成一行 JSON
告警模块的写函数最终调用 AlertJson()(src/output-json-alert.c:701),对包上的每一条告警拼一行 JSON:
1 | for (int i = 0; i < p->alerts.cnt; i++) { |
JsonBuilder。 拼 JSON 用的是 SCJsonBuilder,它是用 Rust 实现的(rust/src/jsonbuilder.rs:107),C 这边通过 SCJbSetString()、SCJbSetUint() 这类函数往里加字段。各日志模块用的都是这一套,所以输出的格式完全统一。
共用的头。 每一行 EVE 都以 CreateEveHeader()(src/output-json.c:832)开头:timestamp、flow_id、in_iface、event_type、五元组、pkt_src……00 篇开头那行 http 事件的前半部分,就是它拼出来的;当时说”字段顺序与 8.0.7 的实际输出一致”,依据就是这个函数里各字段的写入顺序。
告警自己的字段。 AlertJsonHeader()(src/output-json-alert.c:210)加上 alert 对象:action、gid、signature_id、rev、signature(就是规则的 msg)、category、severity。第 8 篇里规则的 sid、rev、msg,在这里原样出现在 JSON 里。如果告警来自应用层事务,默认还会把这个事务的字段一起带上,比如命中 HTTP 规则的告警里,会有一个类似 http 事件的 http 对象,方便直接看到是哪个请求触发的。
写出去。 OutputJsonBuilderBuffer()(src/output-json.c:991)把拼好的 JSON 放进本线程的缓冲区,交给 LogFileWrite()(src/util-logopenfile.c:1095)。写到哪里由 eve-log 的 filetype 决定:默认是普通文件 eve.json,也可以是 syslog、unix socket 或 redis。
小结
- 规则命中时,告警先进本线程的队列;包检测完,
PacketAlertFinalize()统一排序、做抑制和阈值检查、执行动作,写进p->alerts,每个包最多 15 条。 OutputLoggerLog()把包交给几类根日志器:包日志器按条件函数决定写不写(告警模块要求包上有告警),事务日志器按事务进度决定写不写(HTTP 要等事务完成),写完打上logged标志。flow日志在流回收时写,stats日志由统计线程写;启用哪些模块,由配置文件的outputs决定。- EVE 用 Rust 实现的 JsonBuilder 拼 JSON:
CreateEveHeader()拼共用的头,各模块加自己的字段,OutputJsonBuilderBuffer()写到文件、syslog、unix socket 或 redis。
写在最后
到这里,00 篇开头那个问题,可以从头到尾回答一遍了:执行一次 curl http://example.com/,eve.json 里那行 JSON 是怎么来的?
- 第 1、2 篇:Suricata 启动时搭好了流水线:worker 线程上串好了
ReceiveAFP → DecodeAFP → FlowWorker → RespondReject,放行之后开始工作。 - 第 3 篇:包到达网卡,内核把它放进 AF_PACKET 的环形缓冲区;worker 线程从包池里拿一个
Packet,零拷贝地指向这一帧。 - 第 4 篇:解码层把以太网、IP、TCP 一层层剥开,记下各层头部的位置,算好流的哈希值。
- 第 5 篇:
FlowWorker在流表里找到这条连接的Flow。 - 第 6 篇:TCP 重组把 GET 请求的数据按序号拼好,等服务器 ACK 之后交给应用层。
- 第 7 篇:应用层识别出 HTTP,libhtp 解析出方法、Host、URI,组成一个事务。
- 第 8、9 篇:规则早在启动时就被解析、分组、编进了 MPM;这个包找到自己的规则组,预过滤、细查,没有规则命中。
- 第 10 篇:服务器的响应回来,事务完成,事务日志器调用 HTTP 日志模块,
CreateEveHeader()拼出头部、HTTP 模块加上http对象,一行 JSON 写进了eve.json。等这条连接结束、流被回收,又一行flow事件跟着写了出来。
这就是一个数据包穿过 Suricata 的全过程。回头看,有几个设计贯穿了整个系列:
- 用函数指针表解耦:
TmModule(第 2 篇)、应用层解析器表(第 7 篇)、关键字表(第 8 篇)、日志模块(第 10 篇),框架只管查表调用,具体实现各管各的,新增一个协议、一个关键字、一种日志,只要多注册一项,不用改框架的核心逻辑。 - 能复用就不分配:包池(第 3 篇)、流的备用池(第 5 篇),热路径上尽量不做 malloc。
- 能不拷贝就不拷贝:环形缓冲区的零拷贝(第 3 篇),解码只记指针(第 4 篇)。
- 能少做就少做:校验和要用时才算(第 4、6 篇),规则先分组再预过滤(第 9 篇),日志先看条件再拼 JSON(第 10 篇)。
- 跟着进度走:IDS 模式下 TCP 数据要等对方 ACK(第 6 篇),应用层的事务进度决定了检测什么时候能做(第 9 篇)、日志什么时候能写(第 10 篇)、事务什么时候能释放(第 7 篇)。
当然,Suricata 远不止这些:IPS 模式下的裁决、文件提取、Lua 脚本、多租户、规则热加载……本系列沿着一个包的主线走,很多支线都只是路过。如果你读到这里,手里已经有了一张地图,剩下的路口,可以自己顺着源码走进去了。