用 Python 从零实现一个 SSH 蜜罐
把一台机器的 SSH 端口放到公网上,过不了多久日志里就会开始有东西。半夜的登录失败、来自好几个国家的地址、每隔十几秒试一次。你不需要去惹谁,扫描器本来就在全网扫这个端口。
这些尝试绝大多数是无差别的,但里面有信息。它们猜哪些用户名、用什么口令、进去以后第一件事做什么。把这些记下来,比一条条翻 sshd 日志有意思得多。
这篇文章写一个能记这些东西的蜜罐。功能本身不复杂,值得花时间想清楚的是另外两件事:怎么保证它什么都不会真的执行,以及经过转发之后怎么把来源 IP 拿回来。
先定清楚要拿到什么
三样是基本盘:用户名、口令、来源 IP。再加两样进阶的:登录成功后执行的命令、客户端的版本串。版本串能认出对方用的是什么工具,有时候比口令本身更能说明问题。
整个数据流是这样:
图一 蜜罐的组成与数据流。攻击流量进到容器里,能走的最远一步就是被记录。
一个反直觉的决定:口令一律放行
正常的服务遇到错口令要拒绝,蜜罐反过来,全部接受。
原因在 SSH 的认证机制:一次连接只提交一组凭证。爆破工具的做法是连一次、试一组、断开、再连。所以两种策略拿到的数据完全不同。
一律拒绝的话,每次连接只能记下一组凭证,而且永远看不到认证成功之后发生了什么。一律接受的话,每次连接同样记一组凭证,工具试完整份字典,我们就拿到整份字典;同时还多了一条通道,能进到会话里看它接下来干什么。
放行的代价是得自己实现一个假 shell,不能让对方真的登进任何东西。这是下一节的事。
1 | def check_auth_password(self, username, password): |
这段里有个容易写漏的地方:日志和表格的写入都包了 try 除错。蜜罐的核心职责是永远接受并记录,磁盘写满、权限不对这类意外不该让它拒绝认证,那等于自己把自己关掉了。我第一版忘了包,结果一次参数重复就抛异常、认证直接失败,客户端看到的是连接被断开,而表面上蜜罐还在正常运行。
假 shell:难点在于看起来对
认证过了要给对方一个 shell。这里要盯住的是怎么保证它什么都不会真的执行。
原则很简单,所有回显都是写死的字符串。没有 os.system,没有 subprocess,没有 eval,不碰真实文件系统,不实现 SFTP 和 SCP 子系统,对方传文件会直接失败。整个程序不派生任何子进程,这条性质让「它只可能输出这些字符串」变成一个可以断言的事实,而不是靠代码审查去赌。
内置的命令按对方关心的顺序排,先是身份和配置,再是下载:
1 | def answer(cmd): |
最后一行是个刻意的选择。未知命令返回空,而不是 bash: xxx: command not found。后者会让对方意识到环境不对,直接断开走人;空输出更像一条正常的、只是没有输出的命令,会话能继续下去,我们也就多收几条命令。
wget 那条也有讲究:假回显写成下载成功,而不是失败。对方下载不成往往就撤了,回一个成功它才愿意继续走到 chmod +x 和执行那一步,那才是真正想看的东西。
用容器把风险关起来
蜜罐是一个主动邀请攻击者进来的服务,这决定了它比普通服务更需要隔离。Docker 的默认配置不够,得显式收紧:
1 | read_only: true # 根文件系统只读 |
逐条说为什么。
根文件系统设成只读。攻击者即使在容器里找到办法写文件,也没地方写。确实需要写的两个目录单独挂出来,其余全部只读。
丢弃全部 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 | PROXY_V2_SIG = b"\r\n\r\n\x00\r\nQUIT\n" |
用它的时候有个顺序问题必须注意:这段解析要在记录 connect 事件之前做完。我第一版是先记 connect 再剥头,结果汇总时「按 IP 排行」用的是转发器地址,而凭证明细里用的是真实地址,两份数据自己打自己。改完之后才对上。
实测
跑一轮模拟爆破,23 条连接、20 次认证尝试、6 个来源地址、9 次进入会话、78 条命令。
用户名猜得最多的是 root,口令是 123456:
图二 被尝试的用户名与口令分布。这是无差别扫描的典型形状,不是针对谁,就是字典前几行。
进到会话之后的命令集中在几类:
图三 进入会话后执行的命令分布。
先看 cpuinfo、nproc、free,确认机器配置;然后 wget 拉一个脚本、chmod 加执行位、跑起来。这是挖矿木马的标准动作序列,先判断这台机器值不值得占,再投载荷。我这版蜜罐的假回显里有 2 核和不到 2G 内存,按这个配置去挑目标的脚本,大概会继续往下走。
边界
几件事得说清楚。
蜜罐是观测手段,不是防护手段。装了这个不等于安全了,该关的口令登录、该上的密钥认证、该限制的访问来源,一个都不能省。它的价值在于让你知道外面在发生什么,而不是让外面进不来。
抓到的用户名口令不要去别的机器上试。拿别人撞你的字典去撞第三方,性质就从防守变成撞库了。这一步跨过去,整件事的立场就反了。
日志里会有别人的凭证明文。虽然都是攻击者自己发过来的,它终究是一份明文口令集合,别随手丢在公网可读的目录里。
最后,别指望它替你挡掉什么。它只是一个很听话的记录器。
小结
一个能用的 SSH 蜜罐大概三百行:全部接受凭证、给一个写死回显的假 shell、把事件落成 JSONL 和实时表格。功能部分写起来很快,真正要反复确认的是两头,一头是它绝对不执行任何东西,另一头是来源 IP 确实拿到了攻击者的。这两条哪条没做到,剩下的都白记。








