把一台机器的 SSH 端口放到公网上,过不了多久日志里就会开始有东西。半夜的登录失败、来自好几个国家的地址、每隔十几秒试一次。你不需要去惹谁,扫描器本来就在全网扫这个端口。

这些尝试绝大多数是无差别的,但里面有信息。它们猜哪些用户名、用什么口令、进去以后第一件事做什么。把这些记下来,比一条条翻 sshd 日志有意思得多。

这篇文章写一个能记这些东西的蜜罐。功能本身不复杂,值得花时间想清楚的是另外两件事:怎么保证它什么都不会真的执行,以及经过转发之后怎么把来源 IP 拿回来。

先定清楚要拿到什么

三样是基本盘:用户名、口令、来源 IP。再加两样进阶的:登录成功后执行的命令、客户端的版本串。版本串能认出对方用的是什么工具,有时候比口令本身更能说明问题。

整个数据流是这样:

图一 蜜罐的组成与数据流

图一 蜜罐的组成与数据流。攻击流量进到容器里,能走的最远一步就是被记录。

一个反直觉的决定:口令一律放行

正常的服务遇到错口令要拒绝,蜜罐反过来,全部接受。

原因在 SSH 的认证机制:一次连接只提交一组凭证。爆破工具的做法是连一次、试一组、断开、再连。所以两种策略拿到的数据完全不同。

一律拒绝的话,每次连接只能记下一组凭证,而且永远看不到认证成功之后发生了什么。一律接受的话,每次连接同样记一组凭证,工具试完整份字典,我们就拿到整份字典;同时还多了一条通道,能进到会话里看它接下来干什么。

放行的代价是得自己实现一个假 shell,不能让对方真的登进任何东西。这是下一节的事。

1
2
3
4
5
6
7
8
9
10
11
12
13
def check_auth_password(self, username, password):
self.meta["username"] = username
try:
self.log.write(type="auth", method="password",
password=password, **self.meta)
except Exception:
pass
if self.table:
try:
self.table.add(self.meta.get("src_ip", "?"), username, password)
except Exception:
pass
return paramiko.AUTH_SUCCESSFUL

这段里有个容易写漏的地方:日志和表格的写入都包了 try 除错。蜜罐的核心职责是永远接受并记录,磁盘写满、权限不对这类意外不该让它拒绝认证,那等于自己把自己关掉了。我第一版忘了包,结果一次参数重复就抛异常、认证直接失败,客户端看到的是连接被断开,而表面上蜜罐还在正常运行。

假 shell:难点在于看起来对

认证过了要给对方一个 shell。这里要盯住的是怎么保证它什么都不会真的执行。

原则很简单,所有回显都是写死的字符串。没有 os.system,没有 subprocess,没有 eval,不碰真实文件系统,不实现 SFTP 和 SCP 子系统,对方传文件会直接失败。整个程序不派生任何子进程,这条性质让「它只可能输出这些字符串」变成一个可以断言的事实,而不是靠代码审查去赌。

内置的命令按对方关心的顺序排,先是身份和配置,再是下载:

1
2
3
4
5
6
7
8
9
10
11
12
13
def answer(cmd):
low = cmd.strip().lower()
if low in ("id", "whoami") or low.startswith("id "):
return "uid=0(root) gid=0(root) groups=0(root)", False
if low.startswith("uname"):
return FAKE_KERNEL, False
if low.startswith("nproc"):
return "2", False
if low.startswith("wget") or low.startswith("curl"):
return FAKE_WGET_OUTPUT, False
if low in ("exit", "logout", "quit"):
return "", True
return "", False

最后一行是个刻意的选择。未知命令返回空,而不是 bash: xxx: command not found。后者会让对方意识到环境不对,直接断开走人;空输出更像一条正常的、只是没有输出的命令,会话能继续下去,我们也就多收几条命令。

wget 那条也有讲究:假回显写成下载成功,而不是失败。对方下载不成往往就撤了,回一个成功它才愿意继续走到 chmod +x 和执行那一步,那才是真正想看的东西。

用容器把风险关起来

蜜罐是一个主动邀请攻击者进来的服务,这决定了它比普通服务更需要隔离。Docker 的默认配置不够,得显式收紧:

1
2
3
4
5
6
7
8
9
10
11
read_only: true                     # 根文件系统只读
tmpfs:
- /tmp:size=16m,mode=1777
cap_drop:
- ALL # 一个 capability 都不给
security_opt:
- no-new-privileges:true # 禁止提权
user: "1000:1000" # 非 root
pids_limit: 256
mem_limit: 256m
cpus: 1.0

逐条说为什么。

根文件系统设成只读。攻击者即使在容器里找到办法写文件,也没地方写。确实需要写的两个目录单独挂出来,其余全部只读。

丢弃全部 capability。这一条能成立,前提是蜜罐监听一个大于 1024 的端口,用不上 CAP_NET_BIND_SERVICE。换成一个必须绑 22 端口的实现,就得把这个能力加回来,隔离性直接降一档。这也是我一开始就选高端口的原因。

进程以非 root 身份跑,并且把容器内的 uid 对齐宿主机用户。这样挂出来的日志和表格在宿主机上属主就是登录用户本人,不用 sudo 就能直接打开看。

资源和进程数都设上限。蜜罐会吸引大量连接,这两个限制是防止别人拿它把你的宿主机拖垮。程序自身再加一层并发上限,超过就直接断开,不让队列堆起来。

只挂两个目录,产出的目录和主机密钥。其余一律不碰宿主机。

还有一件事不在配置里,在代码里:主机密钥由容器入口用 ssh-keygen 生成,而不是让 Python 程序自己生成。这样程序仍然保持着不派生任何进程的性质。

两份产出

一份全量事件日志,一行一个 JSON,连接、认证、命令、断开都记,留给分析脚本。另一份是实时表格,四列,可以直接用表格软件打开:

序号 IP 用户名 密码
1 203.0.113.10 root 123456
2 198.51.100.22 admin admin888
3 192.0.2.77 oracle oracle123

每来一次认证尝试就追加一行并立即刷盘,表格软件里刷新一下就能看到新记录。序号在程序重启之后接着往下数,不会重头来。文件开头写 UTF-8 BOM,中文列名在 Excel 和 WPS 里打开不会乱码。

一个容易漏掉的坑:转发之后 IP 就不是攻击者的了

这是我实测里踩到的。

蜜罐如果放在端口转发或者反向代理后面,它看到的对端地址是转发器自己,而不是攻击者。在容器里还会再差一层,变成 Docker 网关地址。结果是成百上千个攻击者全被记成同一个 IP,而记录来源 IP 正是蜜罐的核心产出,这一列错了整份数据就没有意义。

解法是 PROXY protocol。转发侧打开之后,真实来源地址会在 SSH 握手之前,以一个小包头先发过来,蜜罐在开始解析 SSH 之前把它读掉再往下走。逻辑大概二十行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PROXY_V2_SIG = b"\r\n\r\n\x00\r\nQUIT\n"

def maybe_proxy_v2(sock):
head = sock.recv(16, socket.MSG_PEEK)
if len(head) < 16 or not head.startswith(PROXY_V2_SIG):
return None # 不是 PROXY 头,正常连接,什么都不做
header = sock.recv(16)
alen = (header[14] << 8) | header[15]
body = b""
while len(body) < alen:
body += sock.recv(alen - len(body))
if header[13] == 0x11: # TCP over IPv4
return socket.inet_ntoa(body[0:4]), (body[8] << 8) | body[9]
return None

用它的时候有个顺序问题必须注意:这段解析要在记录 connect 事件之前做完。我第一版是先记 connect 再剥头,结果汇总时「按 IP 排行」用的是转发器地址,而凭证明细里用的是真实地址,两份数据自己打自己。改完之后才对上。

实测

跑一轮模拟爆破,23 条连接、20 次认证尝试、6 个来源地址、9 次进入会话、78 条命令。

用户名猜得最多的是 root,口令是 123456:

图二 被尝试的用户名与口令分布

图二 被尝试的用户名与口令分布。这是无差别扫描的典型形状,不是针对谁,就是字典前几行。

进到会话之后的命令集中在几类:

图三 进入会话后执行的命令分布

图三 进入会话后执行的命令分布。

先看 cpuinfo、nproc、free,确认机器配置;然后 wget 拉一个脚本、chmod 加执行位、跑起来。这是挖矿木马的标准动作序列,先判断这台机器值不值得占,再投载荷。我这版蜜罐的假回显里有 2 核和不到 2G 内存,按这个配置去挑目标的脚本,大概会继续往下走。

边界

几件事得说清楚。

蜜罐是观测手段,不是防护手段。装了这个不等于安全了,该关的口令登录、该上的密钥认证、该限制的访问来源,一个都不能省。它的价值在于让你知道外面在发生什么,而不是让外面进不来。

抓到的用户名口令不要去别的机器上试。拿别人撞你的字典去撞第三方,性质就从防守变成撞库了。这一步跨过去,整件事的立场就反了。

日志里会有别人的凭证明文。虽然都是攻击者自己发过来的,它终究是一份明文口令集合,别随手丢在公网可读的目录里。

最后,别指望它替你挡掉什么。它只是一个很听话的记录器。

小结

一个能用的 SSH 蜜罐大概三百行:全部接受凭证、给一个写死回显的假 shell、把事件落成 JSONL 和实时表格。功能部分写起来很快,真正要反复确认的是两头,一头是它绝对不执行任何东西,另一头是来源 IP 确实拿到了攻击者的。这两条哪条没做到,剩下的都白记。