跳到主要内容

蜜罐与诱饵端口:从「看不见」到「能检出」

2026-10-13安全运营 6 分钟阅读 2678 字多端口扫描的漏报,在本环境里不是「规则不够」,而是「看不见」——7 个端口的 SYN 在云安全组就被丢了,传感器看不到的包,任何规则都检测不到。这篇讲清可见性缺口怎么证明、误报抑制怎么做,以及哪些检测能力被刻意放弃了。

先纠正一个常见误解

看到"多端口扫描没告警",第一反应通常是:规则不够,加规则。

在本环境里这个反应是错的。实测结论写在这里:

多端口端口扫描的"漏报",不是规则缺口,而是可见性缺口。

证据(2026-10-13 实测):

从 srv1 对 srv2 扫 8 个端口(22/23/25/80/443/3306/8080/9200,间隔 600ms):
  · eve.json 里只出现 port 22 的 flow
  · 其余 7 个端口的 SYN 在【云安全组】就被丢弃
  · 传感器看不到的包,任何 Suricata 规则都检测不到

“看不见"和"没发生"在告警面板上长得一模一样 —— 这是安全运营里最贵的一类错觉。 所以这一篇的重点不是"怎么加规则”,而是怎么证明自己看不见。


怎么证伪"加规则就能解决"

这件事不能靠推理,要靠一次对照实验:

把规则目的地址放宽成 any 之后确实会触发。 但命中的是 10.0.0.10 -> 169.254.0.x 的出站流量 —— 也就是说, 触发规则的是误报,而不是真的检测到了那次扫描。

这个实验的价值在于:它把"我加个规则就好了"这个想法当场证伪了。 如果不做这一步,接下来的动作就是往规则集里塞一条错误的规则 —— 既没补上可见性,还增加一串误报。

结论:不再新增"低速多端口扫描"规则。 扩大检测面的唯一正确做法是让更多端口对传感器可见(云安全组放行), 而那属于维护窗口 + 站主决策,不是我能自己拍的事。

那么扫描真的完全检不出来吗?

不是。要分清楚"哪一层的扫描是可见的":

目标可见性检测
非 22 端口的多端口扫描不可见(安全组丢弃)任何规则都检不到
SSH 层面的扫描可见、可检测ET SCAN sid 2001219

所以对外的口径应该是"SSH 扫描可检出",而不是含糊的"我们做扫描检测"。 含糊的能力宣称最后都会在面试里被问倒。


误报抑制:让运维批量登录不再刷告警

可见性解决之后,紧接着是噪声问题。这里我踩了三个坑,全部固化进了脚本。

坑 1:suricata.yaml 是单文件 bind mount

sed -i ...
# cannot rename ... Device or resource busy

修法:必须就地覆盖写(read → 改 → write 同一个文件)。

坑 2:配置必须以两行开头

%YAML 1.1
---

把 threshold-file 插到最顶端会被 Suricata 拒绝:

The configuration file must begin with the following two lines

修法:插在 --- 之后。

坑 3:rule-files 追加新项要插到块末尾

不能紧跟 rule-files: 头行 —— 否则顺序变成"新项在前",语义就变了。

唯一的放行条件

改完必须以 suricata -T 的退出码作为唯一放行条件(exit=0 才加载), 失败立即回滚备份。脚本 scripts/apply-suricata-scan-tuning.sh 支持:

--check       # 只校验当前配置(suricata -T)+ 打印 rule-files
--rollback    # 回滚到最近一次备份并校验
(无参数)     # 备份 → 写阈值 → 行级插入 threshold-file
              # → suricata -T 放行 → 热加载/重启 → 失败自动回滚

“配置改完必须用真实引擎校验” 和上一篇部署链的逻辑一样: 声明不算数,退出码才算数。


一个必须自己承认的坏消息

写过的东西如果只是"写在那里",它就不算生效。这一条我自己撞上了:

⚠️〔2026-10-13 核实:该抑制从未真正生效 —— threshold.conf 未被 compose 挂载且为 0 字节〕

也就是说:脚本写了、--check 跑了、文档也记了,但那份阈值文件从来没被容器读到过。

这件事的意义比它本身大:它说明"我们做了误报抑制"这句话, 在核实挂载与文件大小之前,是一句没有证据的话。

修法与纪律:任何"已生效"的宣称,必须落到一个容器内可读的事实上 (文件存在 + 非空 + 被挂载 + 引擎校验通过)。这四件事缺一不可。


蜜罐侧:我们能给出什么、不能给出什么

蜜罐(Cowrie)的数据来自 Wazuh 的规则描述,实测规则形状(2026-10-12,共 9352 条):

100100 蜜罐: 新的 SSH 连接来自 <ip>                          3127
100104 蜜罐: SSH 客户端版本 <ver> 来源=<ip>                  3122
100102 蜜罐: 攻击者"登录成功" 用户=<u> 口令=<p> 来源=<ip>    3113
100101 登录失败                                                5

关键点:值直接写在 rule.description 里, 所以在 OpenSearch 里没法直接做 terms 聚合,只能在 Java 侧用正则抽取再聚合。

哪些真的拿到了

维度状态
来源 IP(掩码到网段)✅ 真实计数
尝试账号 / 口令✅ 真实(但公开面只给结构,见下)
客户端指纹(如 SSH-2.0-Go)✅ 真实
连接 / 登录 / 登录失败计数✅ 真实
命令时间线❌ commandsAvailable=false

最后一行要单独说:蜜罐里敲过的命令拿不到,因为 Wazuh 侧没有命令执行规则 —— 数据源里根本没有这一维度。

所以页面上的处理是:这一格保持「数据源未接入」,不编造命令内容。 一个"看起来像命令时间线"的东西,比空着更糟 —— 它会让人以为这条链路是通的。


公开面为什么只给口令结构,不给完整口令

这条是本篇最实用的一段。蜜罐真实捕获到的口令是敏感数据: 它可能对应攻击者正在使用的弱口令字典, 也可能(更糟)是误填进来的真实凭据。

所以公开面不下发完整口令,只给聚合结构与掩码示例:

给出不给
长度分桶(≤6 / 7–9 / ≥10)完整口令串
是否与用户名相同
字符构成(纯数字 / 字母+数字)
掩码示例,如 q****t
客户端指纹、来源网段完整来源 IP

实现上这是"从真实榜单现场归纳",不是预置的假分布: 前端拿到的是服务端给的 topCredentials(真实计数), 长度分桶与"是否与用户名相同"是从这份真实榜单现算的。 这样既不泄露原文,也没有编一个好看的分布图。


一个检测能力的取舍(写在明面上)

规则裁剪那件事我单独写在 另一篇里。这里只说和"可见性"直接相关的部分:

  • 不做:为不可见端口新增规则(会产生误报,见上文对照实验);
  • 不做:为"运维批量登录"放宽 SSH 扫描规则(那会真的丢掉扫描告警);
  • 做:阈值抑制(按 by_src 计数),并且用引擎退出码放行;
  • 做:把"哪些端口可见"写进文档,让下一个人不用重新做一遍对照实验。

小结:把"看不见"和"没发生"分开

这一篇的所有内容可以压成三条纪律:

  1. 告警为空时,先问"是没发生,还是看不见" —— 证明方式是对照实验(放宽规则看触发的是什么流量),不是推理。
  2. 配置生效与否,用引擎/容器的事实判断(suricata -T 退出码、文件非空、 被挂载),不用"我改过了"判断。
  3. 拿不到的东西就写"未接入" —— 命令时间线如此,完整口令如此。 一个假的占位比一个诚实空位危险得多。

本文的端口列表、规则 sid、报错原文、规则计数与字段名均来自本项目的 RUNBOOK.md 与实测输出;本文写作日期为 2026-10-13。