postio邮局泄露密码?防火墙规则失效?一次 Docker + 防火墙的排查记录

zsanjin 发布于 12 小时前 12 次阅读


先说说,正常情况下大家会怎么处理这种事

如果你的邮件账号密码被盗、被用来发垃圾邮件,"标准做法"大致是这几步,按优先级排序:

立刻改密码,这是第一反应,也是最该做的一步——只要密码没换,封IP毫无意义,攻击者随时能换个IP继续登录。
看邮件服务自带的防护,比如登录失败次数限制、单IP发信速率限制(很多邮件服务比如 Haraka、Postfix 都有现成插件,不需要自己造轮子)。
如果还要更进一步收紧,才考虑利用动态IP白名单库——只允许特定IP连接25/465/587这几个端口,等于把"谁能用这个邮箱发信"的范围锁死到几个固定地点。

大多数教程到这一步,会直接告诉你"用 ufw allow from <IP> 就行了",配合一个定时拉取IP列表的脚本,看起来非常合理,很多人(包括我们这次)都是这么开始的。但如果你的邮件服务是跑在 Docker 容器里的,这套标准做法很可能完全失效,而且不会有任何报错提示你"这样不行"——规则看起来加上了,日志也显示成功了,结果就是不拦人。 这篇记录就是讲这个坑是怎么一步步被发现、又是怎么解决的。

 

先补一点基础知识,方便看懂后面发生了什么

防火墙规则不是只有一条路。在 Linux 上,网络流量进来之后,内核会按"这个包要去哪"分成两种情况来处理:

· 如果这个包是发给本机上某个进程的(比如你直接在服务器上装了个网站,监听80端口),这条路叫 INPUT 链
· 如果这个包是要被转发到别的地方的(比如你的服务器只是个中转站,包真正要去的是另一台机器,或者——这次的关键——另一个 Docker 容器),这条路叫 FORWARD 链

UFW,就是市面上最常用的"防火墙管理工具",默认管的是 INPUT 链。 你可以把它想象成小区门口的保安,只负责"谁能进小区大门"。

Docker 的端口映射,本质上是个转发。你在 docker run 里写的 -p 587:587,意思是"外面有人连服务器的587端口,请转发给容器里的587端口"——这个过程对内核来说,根本不算"发给本机",而是货真价实的"转发",走的是 FORWARD 链,跟保安(UFW/INPUT)完全不是一个管辖范围。

打个比方:UFW 是小区门口的保安,只检查进小区大门的人。但你的邮件服务是小区里的一个"快递代收点",快递员直接从小区侧门(Docker 自己开的转发通道)进出,根本不经过正门保安。 你在正门贴再多的"禁止XX人员入内"的告示(UFW规则),对走侧门的快递员(Docker转发流量)毫无作用——这正是这次踩的坑。

 

排查过程:从"看起来对"到"真的对"

第一步:怀疑规则顺序,结果是干扰项

最早的UFW脚本逻辑很完整:拉取白名单、清理旧规则、加白名单allow、最后deny掉587/465。跑起来没报错,ufw status 里也确实能看到规则。

但实测非白名单IP照样能连上发信。第一反应是查规则顺序、查默认策略:

ufw status verbose
iptables -L INPUT -n -v --line-numbers

确实翻出了一批历史遗留规则,但核对后发现跟当前问题无关,是过去测试留下的死规则,纯属误导,排查方向错了。

 

第二步:找到真正的路——Docker 走的是 FORWARD,不是 INPUT

转折点是查这两条命令:

iptables -t nat -L -n -v --line-numbers
iptables -L FORWARD -n -v --line-numbers

nat 表里能看到这样的规则:

DNAT tcp -- !br-xxxx * 0.0.0.0/0 0.0.0.0/0 tcp dpt:587 to:172.19.0.2:587

这条规则的意思是:"所有打到587端口的流量,把目标地址改写成容器内网IP 172.19.0.2,然后继续转发"——这就是前面说的"侧门"。改写地址之后,真正决定"放不放行"的判断,落在了 FORWARD 链上,而不是UFW管的INPUT链。

于是把白名单规则直接搬到FORWARD链,目标地址换成容器IP:

iptables -I FORWARD 1 -p tcp -s <白名单IP> -d 172.19.0.2 --dport 587 -j ACCEPT
iptables -I FORWARD 1 -p tcp -d 172.19.0.2 --dport 587 -j DROP

跑完之后,DROP那条规则的计数器(pkts那一列)开始随着非白名单连接尝试而增长——终于钓上鱼了。

 

第三步:计数器涨了,但客户端工具还说"连上了"——别被这行字骗了

规则生效后,又冒出一个让人怀疑自己的现象:服务器这边DROP计数器明明在涨,Windows端用 ncat 测试却还是显示:

Ncat: connection succeeded.

后来对比"真连上"和"被拦截"两种情况下的完整输出,才看出区别。真连上时是这样:

Ncat: connection succeeded.
220 mail.example.com ESMTP Haraka ready (xxxxxx)

被拦截时是这样:

Ncat: connection succeeded.
Ncat: 0 bytes sent, 0 bytes received in 17.27 seconds.

"connection succeeded" 这行字,只代表客户端和本地代理之间握手成功,不代表代理真的连到了目标服务器——这是这次最容易踩的认知误区。被DROP的包,服务器是直接"装死"不回应的,客户端只能干等到超时,但有些工具/代理软件会在这个过程里提前打印"succeeded",看起来像通了,其实啥也没传过去。真正的判断标准是有没有拿到服务器的应答内容(banner),不是看连接提示语。

465端口还有个额外的坑:它是"加密直连"端口(隐式TLS),连上之后服务器根本不会用明文说话,必须客户端先发加密握手包,服务器才在加密通道里回应。所以即使465完全正常,纯文本工具 ncat 也看不到任何输出——这跟"被拦截"长得一样,但原因完全不同,得换用支持TLS的工具才能验证:

openssl s_client -connect <服务器IP>:465 -quiet

 

第四步:白名单测试时,居然一个"非白名单"IP也连上了

加完白名单规则后,有一次测试,一个明确不在名单里的IP(用 curl ipinfo.io/ip 查到的)居然也连上了,还拿到了完整banner。差点以为白名单又出漏洞了。

后来用服务器端最权威的依据查了一下:

conntrack -L | grep "<怀疑的IP>"

结果——这个IP在服务器侧根本没有任何连接记录。说明它从来没有真正连接过服务器。真相是:客户端所在的网络是多线路的,查IP用的连接和实际测试连接,走的不是同一个出口,对外显示的IP完全不同。从这之后,所有验证都改成"以服务器端 conntrack 记录的真实IP为准",不再信任客户端自己查到的那个IP。

 

最终方案

把整个机制重新梳理成一个脚本,核心设计是:

# 自动检测容器当前IP,不写死(容器重建IP可能变)
TARGET_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mailserver)

# 拉取白名单失败就直接退出,不碰现有规则
IPS_RAW=$(curl -fsSL --max-time 10 "$WHITELIST_API" || true)

# 精确删除上一次的规则(记录上次IP列表,删除时一一对应,不用模糊匹配)
# 插入新的白名单ACCEPT,再插入DROP兜底,顺序:ACCEPT必须在DROP之前

加上几个保障措施:

· 每次执行前自动存一份规则快照,方便人工回滚。
· 不依赖 iptables-persistent 做重启持久化——因为白名单本身一天更新好几次,单纯"保存某一刻快照"意义不大,改成每次定时任务都重新拉取、重新建立规则,天然适配高频更新的场景。
· 原来管邮件端口的UFW脚本完全弃用——不是脚本写得不好,是它从一开始就管错了链,逻辑再完善也不会生效。但如果是非Docker、直接跑在宿主机上的服务(不经过端口映射转发),UFW依然是有效的,这次的坑只针对"容器+端口映射"这种场景。

 

一句话总结

"规则添加成功" 和 "规则真的在拦人",中间隔着好几层可能的误导:选错了链(INPUT vs FORWARD)、看错了提示("succeeded"不等于真通)、用错了工具(明文工具测加密端口)、信错了IP(客户端自查的IP不一定是实际连接用的IP)。每一层都得拿服务器端最原始的证据——iptables计数器、conntrack连接记录、tcpdump抓包——去交叉验证,不能只看某个工具吐出的一行提示就下结论。

 

附:排查时用到的核心命令

# 抓包看流量到底有没有进来,进来之后走的什么路径
tcpdump -i eth0 -n 'tcp port 25 or tcp port 465 or tcp port 587' -vv

# 查当前真实建立的连接,确认某个IP是否真的连过服务器(比客户端自查更可信)
conntrack -L | grep "<怀疑的IP>"

# 查FORWARD链实际生效的规则和计数器
iptables -L FORWARD -n -v --line-numbers

# 查DNAT规则,确认端口被转发到了哪个容器IP
iptables -t nat -L -n -v --line-numbers

# 查容器当前的内网IP(容器重建后会变,别写死)
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <容器名>

# 验证隐式TLS端口(465),纯文本工具看不到banner是正常现象
openssl s_client -connect <服务器IP>:465 -quiet

# 验证STARTTLS端口(25/587)的明文/加密两个阶段
ncat -v <服务器IP> 587
openssl s_client -starttls smtp -connect <服务器IP>:587 -quiet
感谢请我吃辣条
感谢请我吃泡面
感谢请我喝奶茶