简介:Nemea网络流量分析与异常检测系统源码包面向网络安全、流量测量与运维分析方向的学习者和研究者,围绕基于流的模块化检测场景,提供可运行的检测框架与配套脚本。压缩包共46个文件,约200KB,以Shell脚本、Markdown文档、Automake构建文件及Vagrant配置为主,辅以Dockerfile、JSON配置和示例数据,覆盖环境准备、编译构建与实验部署等环节。已有515人学习,说明其在流量异常检测入门与复现中具有一定参考价值。内容包含NEMEA框架、检测器与通用处理模块、TRAP流量分析平台相关材料,以及用例和虚拟化环境配置,便于读者理解模块间消息传递、流记录处理、告警生成与数据导出流程,也可据此搭建小型实验环境,学习DNS隧道、DoS、扫描等异常流量的检测思路,并参考脚本完成编译、部署与调试。
1. 全量抓包不是答案:流级检测凭什么能长期跑在生产网里
Nemea 这套网络流量分析和异常检测系统,第一眼会让人有点失望:它默认不保存原始报文。做过应急响应的人都知道,出事时最想要的就是 pcap,能一帧一帧地看。可真正挂在骨干链路上连跑几个月的东西,恰恰不能依赖全量抓包。40G 口跑满一小时,包级数据就是几十 TB,写入磁盘和逐包解析都是灾难,而且真正有价值的信息其实只占很小一部分。
它的取舍是把“看得见”和“看得起”拆开:采集侧只保留流的概要信息(五元组、字节数、包数、起止时间),检测侧在这份概要上做统计和建模。这套结构决定了系统的吞吐上限、故障边界和扩展方式,也几乎决定了所有基于流的检测系统长什么样。接下来要拆的是三个关键点:进程之间怎么传数据、报文怎么被折叠成流、检测模块凭什么判定异常。
2. 模块链与 TRAP:一条流记录怎么从一个进程走到另一个进程
把 Nemea 拆开看,最先撞上的不是算法,而是进程边界。采集是一个进程,过滤是一个进程,每个检测器又是各自的进程,它们之间没有函数调用、没有共享内存,只有一条单向的数据流。理解这条流的约定和代价,比背检测规则有用得多,因为线上绝大多数“跑不起来”都出在接口这一层。
2.1 接口是约定:模块之间只认字段,不认对方是谁
每个模块都是一个能独立启动的可执行文件,有自己的输入接口和输出接口,接口在命令行上用描述符声明。模块之间不链接、不 import,一个用 C 写的采集器可以把数据交给一个用 Python 写的检测器,只要两边谈妥字段。这套设计换来三件事:单个模块可以独立重启和替换、单个模块崩溃不会影响整条链、算法迭代时不需要重新编译整个系统。代价同样明确:每次跨进程都要序列化一次,字段格式必须在两端严格一致。
承载这条流的是 libtrap 这一层库,它把“一个进程往另一个进程发消息”收敛成少数几个调用。消息的基本单位是一条 UniRec 记录,记录由模板定义,模板就是一串字段名,比如SRC_IP,DST_IP,SRC_PORT,DST_PORT,PROTOCOL,BYTES,PACKETS,TIME_FIRST,TIME_LAST。接收方在启动时可以声明自己“必须拿到哪些字段”,libtrap 在建立连接时做比对:字段类型不兼容就直接拒绝启动,绝不会让你跑到一半才发现读出来的 IP 是乱的。这套机制本质上是一种轻量级的 schema 协商,也是整条链能跨语言拼装的前提。
有一点容易被忽略:模板不是“消息里带一段 JSON”,而是紧凑的二进制布局,字段按模板顺序排列、按类型对齐。解析方拿到的其实是一个字节块加上一份模板,ur_get/ur_set负责按偏移存取。这是性能的关键,也是所有“字段读出来全是零”事故的根源,后面第 5 章会专门讲。
2.2 接口描述符:u:和t:差的不只是传输介质
接口描述符决定了数据走哪条路。最常见的两种前缀是u:(Unix 域套接字,只能本机)和t:(TCP,可以跨主机)。前者省掉整个网络栈,延迟低、配置简单,单机部署几乎都用它;后者用于把采集和检测拆到不同机器上,代价是要处理网络抖动、连接断开和带宽限制。
# 采集侧:从网卡抓流,把记录写进一个 Unix 域套接字 flow_meter -i "eth0" -o "u:/tmp/flow.sock" # 检测侧:从同一个套接字读记录,输出告警 vportscan_detector -i "u:/tmp/flow.sock" -o "u:/tmp/alert.sock"这两行命令的先后顺序不是随便写的。Unix 域套接字的语义是接收端先bind并listen,发送端再connect。所以整条链必须从下游往上游启动:先起告警收集器,再起检测器,最后起采集器。如果按“数据流方向”从上游往下游起,采集器会因为连不上套接字而直接退出。手工用 Shell 脚本部署时,这个顺序问题会反复出现,也是后来引入中心化编排组件的直接原因之一:把接口关系声明出来,由编排器去计算启动依赖,比把顺序硬编码在脚本里更可靠。
2.3 一个最小模块的三段式:recv、处理、send
不管用什么语言写,模块的骨架都一样:初始化接口,循环收记录,处理完再发出去。下面是 C 版本的结构,省略了错误处理,只保留主干:
#include <libtrap/trap.h> #include <unirec/unirec.h> int main(int argc, char **argv) { trap_ctx_t *ctx; // 解析 -i / -o 描述符并建立接口连接 trap_ctx_init2(&ctx, "example_detector", &ifc_spec, 1, 1); // 从接口约定推导输入模板;输出模板单独定义 ur_template_t *tmpl_in = ur_template_from_ifc_spec(ctx, 0); ur_template_t *tmpl_out = ur_create_template("SRC_IP,EVENT_ID,COUNT", NULL); const void *data; uint16_t size; while (true) { // 阻塞等待一条记录;返回终止信号时退出循环 if (trap_recv(ctx, 0, &data, &size) == TRAP_E_TERMINATED) break; ip_addr_t src = ur_get_ip(tmpl_in, data, F_SRC_IP); ur_set_ip(tmpl_out, out, F_SRC_IP, src); ur_set(tmpl_out, out, F_EVENT_ID, 1); ur_set(tmpl_out, out, F_COUNT, 1); trap_send(ctx, 0, out, ur_rec_size(tmpl_out, out)); } trap_ctx_finalize(&ctx); return 0; }三个地方值得说清楚。trap_ctx_init2里的两个数字分别是输入接口数和输出接口数,它们必须和命令行的-i/-o数量对上,对不上就直接报错退出。ur_template_from_ifc_spec是从连接时协商出的模板生成访问器,意味着你不需要手写字段偏移,这也是 UniRec 相对裸结构体的价值所在;ur_set/ur_get内部做的是偏移加类型转换,字段不存在时返回默认值而不是越界读取。trap_recv默认是阻塞的,想让模块响应信号或者做定时任务,就得在描述符里追加超时选项,让它周期性返回超时而不是永久挂住。
Python 版本的调用方式略有不同,但三段式完全一致:
import pytrap trap = pytrap.TrapCtx() # 一个输入接口,描述符必须和上游输出的那一端匹配 trap.init(["example_detector"], 1, ["u:/tmp/flow.sock"]) trap.set_required_fmt(0, pytrap.FMT_UNIREC, "SRC_IP,DST_IP,SRC_PORT,DST_PORT,PROTOCOL,BYTES") rec = pytrap.UnirecTemplate("SRC_IP,DST_IP,SRC_PORT,DST_PORT,PROTOCOL,BYTES") while True: data = trap.recv(0) if len(data) == 0: # 长度为 0 表示上游关闭或终止 break rec.setData(data) # 这里做统计,命中条件时再 trap.send() print(rec.SRC_IP, rec.DST_PORT, rec.BYTES)set_required_fmt是 Python 侧最关键的一行,它把“我至少要哪些字段”提前告诉对端,省掉运行期反复判断字段是否存在。用 Python 写模块的代价很直接:单条记录的处理成本比 C 高一个数量级,适合做低频、复杂判断的检测器,不适合做逐条过滤这种高频操作。这一点在使用中很容易记不住,直到某天流量上来,报警不是因为攻击,而是因为模块自己处理不过来。
2.4 编排:从 Shell 脚本到中心化配置
模块少的时候,一个 Shell 脚本就够了:起进程、记日志、挂了再拉起。但模块数量一多,脚本里就会出现几十行sleep 1,用这些延迟来保证启动顺序;而且日志分散在各个文件里,某个模块退出后不会有人通知你。常见的做法是引入一个中心化的编排组件,用一份配置文件声明“有哪些模块、各自的接口描述符、依赖谁”,由它计算启动顺序、监控退出码并自动重启。
这个演进本身就是一次设计取舍:Shell 脚本胜在透明,出问题的时候执行ps就能看明白;编排组件胜在可管理,但多了一层间接,查问题时先得看编排器自己的日志。调试期我一般还是直接手敲命令逐段验证,确认接口通了再交给编排器。这种“先手工把连接建立起来,再自动化”的习惯,能省掉大量对启动顺序的猜测。
3. flow_meter:一个报文凭什么被折叠成一条流记录
采集侧要回答的问题只有一个:同一个连接上的几千个包,凭什么被认定是同一股流量。这个判断错了,后面所有统计都会偏。flow_meter 做的就是这件事,它从网卡收包、构键、聚合、老化,最后按 IPFIX 格式导出。它不强求精确,但必须在有限内存里稳定运行几个月,这就把很多设计逼成了折中。
3.1 流键:五元组只是起点
最朴素的流键是五元组:源 IP、目的 IP、源端口、目的端口、协议号。但真实链路上还需要更多维度来区分“不同的流”:VLAN 标签、隧道封装的内外层、ICMP 的类型和代码位(端口字段在这里被复用为 type/code)。如果网关后面有大量 NAT,同一个五元组在不同时间可能属于不同主机,这时还会把采集设备的输入接口号、甚至时间片拼进键里。
键越长,哈希表的内存占用越大,冲突处理也越慢。实际部署里这是一个明确的调参点:在纯三层转发的骨干出口,五元组足够;在接入层、有大量短连接和 NAT 的环境里,键里不加接口号就会把两台主机的流量合成一条。加不加这件事没有通用答案,只能看链路的拓扑。
3.2 两种超时:active 与 inactive 的分工
一条 TCP 连接可以活几小时,如果一直等它关闭才导出,检测就失去了时效性;反过来,每来一个包就更新记录,导出频率又会高得没有意义。所以流表普遍用两个超时来分流处理:
| 超时类型 | 典型取值 | 触发条件 | 作用 |
|---|---|---|---|
| active timeout | 数百秒 | 流已存在且持续有包 | 强制切分长连接,保证检测器按时拿到数据 |
| inactive timeout | 数十秒 | 一段时间内没有新包 | 回收资源,判定流已结束 |
| TCP FIN/RST | 即时 | 抓到连接终止标志 | 提前结束,减少表项驻留 |
active timeout 决定了检测的最小时间粒度:如果它被设成 5 分钟,那么再快的扫描攻击,检测器也要等到分片导出之后才能看到。这也解释了一个常见困惑:为什么有的扫描在流级系统里看起来“延迟很高”——不是算法慢,而是采集侧的窗口把它推迟了。把它调小能提高时效,代价是导出的记录数成倍增长,后面的模块要处理更多消息。
inactive timeout 则是内存保护。流动表本身是个哈希表,容量有限,长期不清理就会被慢速连接慢慢占满:攻击者只要发一批只发一个包的探测,就能在表里残留大量表项。清理逻辑要在“清得太早导致一条长流被拆成多条、统计失真”和“清得太晚导致表满、新流无法入表”之间找平衡,通常会再配一层 LRU 或者按比例淘汰作为兜底。
3.3 双向流的合并:以谁为发起方
同一条 TCP 连接有两个方向。如果把两个方向当成两条流统计,那么“某个 IP 对外发起了多少连接”就没法算。所以采集侧要做双向合并:两个方向共享一个表项,但要记录哪边是发起方(先发出第一个包的一侧),这样导出时SRC_IP就固定表示客户端,DST_IP表示服务端。
合并的难点在于判定发起方。TCP 有 SYN 可以看,UDP 和 ICMP 没有,只能退而求其次:先到先得,或者按字节数和包数多少来判断“谁更像客户端”。后者在短连接上会出错——服务端返回的报文往往更大,于是一个只发了一次请求的客户端会被认成服务端。这种反向错误在检测侧的表现很隐蔽:端口扫描检测器会把受害主机当成扫描源。我一般会在采集侧把方向字段一并导出,让检测模块自己做二次判断,而不是盲目相信合并结果。
3.4 导出格式:模板、批量与丢包策略
导出走 IPFIX,也就是带模板的流记录格式。模板先发一次,后续数据记录只按模板顺序排列字段,接收端按模板解析。这和 TRAP 里的 UniRec 模板是同一个思路:用一次协商换来长期的低开销。
真正要留意的是压力下的行为。采集侧是整条链的入口,它如果为了等下游处理而阻塞,丢的就是原始报文,而且不可恢复。所以这里的策略通常是“宁可丢记录、不可丢采集”:设置发送缓冲区上限,缓冲区满了就从流表里淘汰最旧的表项,把资源留给新流。这个取舍很冷酷:在攻击最猛烈、最该被看见的时候,系统恰恰可能漏掉一部分数据。知道这个边界存在,比相信“系统会全部记下来”要安全得多。
4. 检测模块:滑动窗口和基线预测怎么把流量变成告警
到了检测侧,输入已经是概要化之后的流记录,问题变成:怎么从“每秒几十万条记录”里找出那几条不对劲的。这里没有复杂的模型,核心就两件事:把记录按时间归拢成可比较的统计量,再判定这个统计量是否偏离了它本该有的样子。
4.1 时间桶:滑窗不是真的滑动
最直接的做法是给每个被监控的实体(一个源 IP、一个目的网段、一对主机)维护一个固定长度的时间桶数组,每个桶累积一个时间片内的指标,比如新建连接数、不同目的端口数、不同目的 IP 数。窗口向前推进时,不需要真的移动数据,只需要推进桶的索引,旧桶被覆盖或者清零。
这种环形缓冲的好处是内存完全固定:每个实体占用的空间等于桶数乘以单个桶的大小,与流量规模无关。代价是精度受桶宽限制,窗口边界上的事件可能被切到两个桶里。对于扫描检测这类“五分钟内访问了多少个不同端口”的问题,桶宽取 1 秒到 10 秒就够用,误差完全可接受。真正麻烦的是“不同端口数”这种去重指标,每个桶里存一个集合显然不现实,常见的实现思路是用位图来近似:把端口号映射成固定长度的位图,求并集时做按位或,最后统计置位的数量。用少量的误判换来可控的内存。
4.2 阈值不能写死:基线为什么要跟着时间走
写死阈值的检测器在实验室里表现很好,上线之后就变成噪声发生器。原因很简单:一个两千人的办公网和一个 IDC 出口,正常的“每分钟新建连接数”差好几个数量级;同一个网络,凌晨三点的基线和下午三点也完全不同。所以成熟的检测模块会根据历史数据算出“此刻的期望值”,再用相对偏差判定。
常见的实现思路是对每个实体的指标维护一段历史,用滑动均值加标准差做简单判定,比如当前值超过均值若干倍标准差就触发;考虑周期性的话,会按“星期几 + 时段”分桶建立独立的基线,让工作日的早高峰和周末的低谷各自有参照系。更复杂一点会用时间序列 prediction 的方法,用过去若干个时间片预测下一个时间片的期望区间。
这带来一个新的工程问题:冷启动。刚启动时没有历史,基线是空的,任何流量都可能被判为异常。处理办法通常是设一个预热期,前若干个时间片只学习不判定,或者用同类实体的汇总值作为初始基线。预热期设得太短会误报一片,太长则这段时间内完全盲视,两种都会在值班时造成麻烦。
4.3 告警记录长什么样:为什么要带上下文
检测模块输出的不是一句“发现扫描”,而是一条结构化记录,字段大致包括:实体标识(源 IP 或子网)、事件类型、置信度或强度、时间窗的起止、触发时的统计量(访问了多少端口、多少目的 IP)、以及上游流记录里的部分原始字段。带上这些上下文,是为了让下游的关联模块和值班人员不必回头再查一遍原始数据,这本身就能修掉不少“告警看不出所以然”的毛病。
这一步还承担了抑制的职责。同一个源 IP 持续扫描十分钟,会产生几十条触发,如果全部输出就会刷爆下游。常见的做法是对同一个实体加事件类型设置冷却时间,窗口内只报一次,同时把强度累加,让“持续多久、规模多大”体现在一条记录里。冷却时间设长了会漏掉二次攻击,设短了就是噪声,这个参数基本只能靠线上反馈调。
4.4 状态表的内存上限:检测器自己也会被打挂
所有检测模块都有自己的状态表,本质上和采集侧的流表一样,容量有限、需要淘汰。攻击者完全可以利用这一点:用大量随机源 IP 制造海量实体,把检测器的状态表挤爆,让真正需要跟踪的实体被淘汰出去,检测效果自然下降。
对抗方式有几层。第一层是聚合:不要给每个源 IP 都建基线,把长尾的地址归到子网级别,只给活跃度高的实体单独建表。第二层是加入容量上限和淘汰策略:按最近活跃时间淘汰,或者给每个实体一个权重,长期没有触发的实体优先清除。第三层是在上游做预过滤,用无状态规则先去掉明显无害的流量,减少进入状态表的数据量。这三层通常会放到模块链的不同位置,这也正是把检测拆成多个小模块的实际好处:过滤和统计可以各自独立调参,互不改动代码。
5. 避坑与排查:接口、模板、阈值这三处最容易翻车
这套系统的设计很干净,但干净不等于好调。以下几条是按“现象 → 原因 → 解决”整理的踩坑记录,共同点是都发生在模块边界上而不是算法内部。
5.1 现象:模块一启动就退出,日志只有一行初始化失败
原因几乎总是接口两端对不上。可能是上游写u:/tmp/flow.sock、下游写t:127.0.0.1:1234,传输方式不同根本连不上;也可能是上一轮运行留下的套接字文件还在,新进程bind时直接失败;还有一种是启动顺序反了,发送端比接收端先起,connect时文件不存在。这三种现象在日志里长得很像,都是一行失败加退出。
解决方式:先ls看一眼套接字文件在不在、是不是残留的(进程已退出但文件还在),再核对两端的描述符字符串是否逐字符一致。排查顺序上,我一般从下游往上游逐段手工启动,每起一个就用ls确认套接字建出来了,再起下一个。不要依赖脚本里的sleep,那只是在赌时间够不够,赌输了就得从头查。
5.2 现象:数据能收到,但读出来的 IP 和端口全是零或者明显不对
原因是模板没对齐。UniRec 记录是二进制布局,接收端按自己声明的模板去算偏移,如果上游实际发的字段顺序或类型不同,读到的就是错位的数据。更隐蔽的情况是接收端没声明 required format,libtrap 没有做启动时的比对,于是错误被推迟到运行期,表现成“偶尔几条记录不对”,很容易被误判成上游解析有 bug。
解决方式:让每个接收模块都显式声明所需字段,把错误暴露在启动阶段而不是数据阶段;联调时先用一个只打印字段的脚本模块挂在中间,把上下游都接上,肉眼确认字段值与预期一致,再接入真正的检测逻辑。定位“到底哪一段开始错”的时候,这个打印模块比任何日志都有用。
5.3 现象:检测器每天产生大量误报,值班收到了无数条无效告警
原因通常是阈值按默认值上线,没有按网络规模调整。同一个“五分钟内访问 50 个不同端口”的阈值,在小型网络里是明确的扫描特征,在出口有大量代理和端口复用的大网里就是正常业务。另一个常见原因是时间窗太短,把正常的突发流量切成了异常片段。
解决方式:先做一轮只记录不告警的观察期,把指标的实际分布打出来,按分位数定初始阈值而不是拍脑袋;再把明显无害的源(内部扫描器、监控探针、代理出口)加入白名单,这类排除规则应该独立配置,不要写进代码。调阈值这件事没有一次到位的办法,通常要跟着业务节奏迭代几轮。
5.4 现象:流量高峰时下游收到的记录明显变少,但采集侧没有报错
原因在于模块链的瓶颈位置。采集侧为了不阻塞,缓冲区满了就会丢弃记录;中间的某个处理模块如果单线程处理不过来,就会把这种压力向上传导。表现出来就是“什么错都没有,但数据少了”,这是最难受的一类故障,因为没有异常日志可查。
解决方式:在每个模块的出口加上计数和速率输出,定期打点,看数据量是从哪一段开始掉的。定位到瓶颈模块之后,常用的手段是拆链:用分流模块把同一份数据广播给多个下游,把重活拆成几个并行的实例;或者把逐条过滤这种高频操作改用 C 实现,Python 只保留低频复杂判断。另一个方向是在采集侧启用加速收包路径,把瓶颈前移出系统本身。
5.5 现象:模块重启后告警数量突然变多,过一会儿又恢复正常
原因是状态全在内存里。滑动窗口、基线历史、冷却计时器,重启之后全部清零,检测器进入冷启动状态,把正常流量当成异常输出。这件事在灰度发布或者机器重启时几乎必然遇到,但第一次见的时候会以为是攻击。
解决方式:接受冷启动的存在,但把它变得可控。常见做法是给检测模块加一个可配置的预热时长,这段时间内只累积状态不输出告警;对特别关键的实体,把基线周期性地写入磁盘,重启后加载回来。还有一种是让编排组件在重启前先摘流量、等窗口自然过渡再接入,代价是有一小段时间的盲区,需要权衡。
6. 设计取舍:为什么把检测拆成一串互不认识的进程
回过头看,整套系统的设计选择都指向同一个方向:用清晰的进程边界,换取各部分的独立演进能力。这个选择当然有代价,把两种方案放在一起对比会更清楚:
| 维度 | 单体式检测程序 | 模块链 |
|---|---|---|
| 算法迭代 | 改一处要重新编译部署整体 | 单独替换一个模块,其余不动 |
| 故障影响 | 一个逻辑崩溃影响全部功能 | 崩溃被限制在单个进程内 |
| 语言选择 | 全系统统一 | 采集用 C、检测用 Python 混搭 |
| 数据传递 | 函数调用,零序列化 | 每次跨进程都要序列化 |
| 性能上限 | 更高,无进程间开销 | 受接口吞吐和调度影响 |
| 排查难度 | 栈内调试,路径清晰 | 需要逐段确认接口与模板 |
可以看出来,代价主要集中在性能和可观测性上:序列化开销、接口一致性、启动顺序,全都是新增的负担。而收益在于变更成本——在一套要持续运行数月、算法需要不断调整的系统里,变更成本往往比峰值性能更重要。这也是为什么采集侧被单独拆出来、还允许接入硬件加速的收包方式:把最耗性能的一段隔离掉,让它能独立升级,其余部分就可以用更灵活的方式迭代。
验证这套结构是否真的工作,方法也比较朴素。一是回放:把抓好的 pcap 用采集侧重放进去,看检测输出是否与人工分析一致,这能验证解析和统计逻辑,但验证不了线上时序。二是合成注入:构造特定模式的流记录直接打进检测模块,专测边界条件,比如窗口切换的瞬间、状态表刚好满了的时候。三是灰度对比:新版本和老版本并行跑同一份流量,比较告警集合的差异,差异部分逐条解释。三种方式各有盲区,通常要组合使用。
从那以后,我每次调整模块链,哪怕只改一个接口描述符,也会先把整条链从下游到上游手工起一遍,看到每段的数据真的流动起来,才敢交给编排组件。这个习惯帮我省过好几次“改了半行配置、排了一下午故障”的麻烦。希望帮到你。
本文还有配套的精品资源,点击获取