从 AxonHub 镜像验真到 DNS 污染排查:一次 VPS 部署前检查记录

· 网安 · #服务器


从 AxonHub 镜像验真到 DNS 污染排查

这次排查最初不是为了研究 DNS,而是为了在一台已经运行 Aurora 的 VPS 上增加 AxonHub。

服务器资源并不宽裕,现有业务也不能因为新增服务受到影响。因此,在编写 Compose 文件和启动容器之前,先进行了三类部署前检查:确认机器是否还有余量、确认计划使用的端口没有冲突、确认准备拉取的 AxonHub 镜像确实来自预期的官方版本。

DNS 问题正是在第三项检查中被发现的。

本文中的服务器地址、网卡 MAC 等信息均已省略或泛化。网络配置与远程服务器强相关,请先确认自己的配置源,并保留控制台或其他恢复通道。

一、部署 AxonHub 的背景

目标是在现有 Aurora 服务旁边运行 AxonHub,而不是替换或修改 Aurora。服务器大致为 2 核、2 GB 内存,已经运行 8 个容器,因此不能把“Docker 能启动”直接等同于“适合继续增加服务”。

部署前首先查看了内存、Swap、磁盘、CPU 状态、现有容器和 Docker 磁盘占用。得到的关键信息是:

检查项 当时的情况 对部署的影响
可用内存 约 790 MiB 可以尝试,但必须限制 AxonHub 内存
Swap 已使用约 542 MiB 说明内存余量不算充足
磁盘 约 20 GB 可用 镜像和少量运行数据有空间
CPU 存在较明显的 steal time 共享宿主机可能存在资源竞争
现有服务 Aurora 相关 8 个容器运行中 新服务必须与现有业务隔离
计划端口 8090 未被占用 端口层面可以继续规划

因此,当时的结论不是简单的“可以部署”,而是:

可以继续准备 AxonHub,但需要设置 CPU 和内存限制,并在启动后复查 Aurora 的资源和健康状态。

到这一步为止,没有启动 AxonHub,也没有修改 Aurora。

二、为什么要先验证 AxonHub 镜像

计划使用的版本是:

docker.io/looplj/axonhub:v0.9.43

v0.9.43 可以在 AxonHub 官方 Releases 中核对。

部署时不能只依赖 v0.9.43 这个标签。标签可以移动,而镜像清单的 SHA-256 digest 才是更稳定的内容标识。因此,先从项目发布信息确认版本和官方镜像,再记录对应的 manifest digest:

sha256:6d14085c7729ff534168198d573cf02ba2899cb765e51db3c7540bc976aea9fd

接下来需要让服务器自己访问 Docker Hub,确认它看到的镜像与事先确认的内容一致:

docker buildx imagetools inspect \
  docker.io/looplj/axonhub:v0.9.43

如果这一步成功,后续就可以把部署引用固定到 digest,避免仅凭可变标签决定实际运行内容。

但服务器没有返回镜像清单,而是报出:

x509: certificate is valid for *.facebook.com, ...
not registry-1.docker.io

这意味着服务器访问 Docker Hub 时收到了与目标域名不匹配的证书。部署因此暂停;在原因没有查清之前,不继续执行 docker pull 或启动容器。

三、DaoCloud 镜像站解决了什么问题

服务器的 Docker 已经配置了 DaoCloud HTTPS 镜像站。它可以作为一条独立路径,用来回答两个问题:镜像站的 TLS 是否正常,以及镜像站中的 AxonHub 是否与官方镜像一致。

第一次访问 Registry V2 接口时返回:

HTTP 401 Unauthorized

这里的 401 不是服务故障。Docker Registry V2 的标准流程是先返回认证要求,客户端再到响应头指定的认证地址获取 Bearer Token,最后携带 Token 请求镜像清单。Docker 官方的 Registry authentication 文档记录了相同的 401 → WWW-Authenticate → Bearer Token → 重试请求 流程。

手动完成这段只读认证流程后,镜像站返回的 Docker-Content-Digest 是:

sha256:6d14085c7729ff534168198d573cf02ba2899cb765e51db3c7540bc976aea9fd

它与事先确认的官方 digest 完全一致。Registry V2 API 使用 digest 作为内容寻址标识,并通过 Docker-Content-Digest 响应头返回目标实体的摘要:CNCF Distribution HTTP API V2。由此可以确认:

但这一步只是完成了备用路径的镜像验真,并没有解释 Docker Hub 直连为什么会收到 Facebook 证书,也没有实际启动 AxonHub。为了避免以后其他镜像和系统组件继续遇到同类问题,仍需要修复服务器的 DNS 链路。

四、为什么证书错误最终指向 DNS

访问 registry-1.docker.io 时,连接会依次经过:

1. DNS 解析 registry-1.docker.io
2. 连接解析结果的 443 端口
3. 完成 TLS 握手并获取证书
4. 验证证书是否属于 registry-1.docker.io

如果第一步返回了错误 IP,客户端就会连接到错误服务器。对方返回自己的合法证书,客户端在第四步发现证书名称不匹配并中止连接。

因此,*.facebook.com 证书并不表示 Facebook 伪造了 Docker 证书。它说明客户端实际连接到了 Meta/Facebook 的服务器,而 TLS 主机名校验正确地阻止了后续通信。接下来需要确认的是:这个错误地址是否来自 DNS。

五、建立 DNS 对照组

1. 查看系统解析结果

先通过系统当前使用的解析链查询:

resolvectl query registry-1.docker.io

异常结果中出现了 31.13.x.x 和 Meta 使用的 IPv6 地址。无论具体地址如何变化,Docker Registry 域名解析到 Meta/Facebook 地址本身就明显不合理。

2. 对比明文 DNS 与 DoH

同一台服务器通过 Cloudflare DNS over HTTPS 查询时,得到的却是 AWS/CDN 地址。于是形成了第一个关键对照:

同一台服务器、同一个域名

明文 DNS(53 端口) ──→ Meta/Facebook 地址  ❌
DoH(HTTPS 443)     ──→ AWS/CDN 地址        ✅

这一步把问题范围从“Docker 整体异常”收缩到了“传统明文 DNS 查询路径异常”。DoH 将查询放在 HTTPS 连接中,路径中的普通网络设备无法像处理明文 UDP DNS 那样直接读取或修改查询内容。IETF 的 DNS 隐私分析指出,传统 DNS 通常不加密,即使后续访问使用 HTTPS,前置的 DNS 查询仍可能成为整条链路中较薄弱的一环:RFC 7626。

3. 比较同一解析器的 UDP 与 TCP

接着直接查询服务器原先使用的 DNS,并分别指定 UDP 与 TCP:

dig @223.5.5.5 registry-1.docker.io A +time=3 +tries=1
dig @223.5.5.5 registry-1.docker.io A +tcp +time=3 +tries=1

dig @223.6.6.6 registry-1.docker.io A +time=3 +tries=1
dig @223.6.6.6 registry-1.docker.io A +tcp +time=3 +tries=1

其中最有诊断价值的一组结果是:

查询路径 结果 判断
223.6.6.6 UDP 53 31.13.70.9 错误,指向 Meta/Facebook
223.6.6.6 TCP 53 一组 AWS 地址 合理
223.5.5.5 UDP 53 Meta/Facebook 地址 错误
223.5.5.5 TCP 53 连接被重置 异常

同一个域名、同一个 DNS 服务器,只改变传输协议,答案却完全不同:

VPS ── UDP 53 ──→ 错误的 Facebook 地址
 │
 └── TCP 53 ────→ 正常的 AWS 地址

如果真是权威数据或递归解析器缓存本身错误,UDP 与 TCP 通常应返回语义一致的记录。现在 UDP 返回错误地址、TCP 返回合理地址,更像是 UDP 53 路径中出现了伪造响应、透明代理或其他网络干预。

DNS 客户端通常会接受首先到达且与查询 ID、端口和问题字段相匹配的响应。如果路径中的设备比真实解析器更早返回伪造答案,后到的真实答案便可能失去作用。DNS 的抗伪造规范专门讨论了这些匹配条件;TCP 还具有连接状态和序列号,因此通常比无连接的 UDP 更难被第三方伪造:RFC 5452。

仅凭这些客户端侧实验,无法精确证明是哪一台中间设备进行了注入,也不能简单归咎于某个公共 DNS 服务商。但已经足够确认:

问题不在 Docker 镜像本身,也不像 TLS 层故障,而是服务器到传统 DNS 解析器之间的明文查询路径异常。

六、公开研究数据提供的补充证据

到这里,UDP 与 TCP 的对照已经强烈指向 DNS 注入。将异常结果与公开研究数据比对后,又得到了一组补充证据。

本次排查先后观察到了这些地址:

31.13.70.9
31.13.69.169
103.252.114.101

其中前两个地址属于 Meta/Facebook。在 NDSS 2025 论文 Wallbleed 附录公布的伪造 DNS 地址池中:

论文研究的设备会观察特定 DNS 查询并伪造响应,错误地址来自一组会循环使用的池。研究者还发现,有些通信虽然起点和终点都不在中国,却因为实际网络路由经过相关边界而收到了注入响应:NDSS 2025 Wallbleed 论文。此前 USENIX Security 的 GFWatch 研究也通过长期、大规模测量确认了此类 DNS 过滤系统会持续伪造响应:GFWatch 项目介绍。

需要注意,该附录记录的是论文实验查询所对应的地址池,论文也提醒不同注入器和不同查询名使用的池可能不同。因此,这三个 IP 的重合是很强的特征匹配,却不能单独完成对具体设备或运营商的归因。

这也解释了为什么会收到 Facebook 证书:注入系统可能只是从预设地址池中选择了一个假答案,而该地址恰好属于 Meta。Docker 连接过去后,对方正常返回自己的证书,TLS 主机名校验才暴露出 DNS 结果有误。

不过,这里必须守住证据边界:

结论 证据强度 原因
明文 UDP DNS 路径返回了错误答案 已确认 同一域名、同一解析器的 UDP 与 TCP 结果明显冲突
错误答案具有已知 DNS 注入地址池特征 强证据 三个观测地址都出现在公开研究的伪造地址池中
异常可能发生在 VPS 出口或其上游路由 高概率推断 DoH 正常、UDP 53 异常,符合路径中间设备干预特征
VPS 商家主动进行了污染 未证实 客户端实验无法定位具体是哪台设备或哪个运营主体
AliDNS 自己把 Docker Hub 配成 Facebook 很不可能 同一 AliDNS 地址通过 TCP 能返回合理答案

因此,可以把它判断为具有明显 DNS 注入特征的案例,但现有证据不足以归因到某一家 VPS 商家或某一个具体网络设备。

七、小型 VPS 环境中可能出现问题的原因

严格地说,“小厂 VPS”不是一种网络协议,也不是污染的充分条件。大厂同样可能遭遇线路问题,小厂也可以拥有干净稳定的网络。真正增加风险的通常是下面几层因素叠加。

1. 多层转售与上游路由

一些小型商家并不拥有完整的机房和骨干网络,而是转售上游 VPS、租用机柜、购买廉价国际带宽或混合多家线路。数据包从你的虚拟机出发后,可能依次经过宿主机、虚拟交换机、机房出口、上游运营商和跨境网络。

链路越长、控制权越分散,商家自己也未必能准确说明 UDP 53 在哪一跳被处理。论文记录的“通信双方都在境外,但路由经过相关边界仍触发注入”,正说明地理位置标签并不能代表数据包真实走过的路径。

2. 系统模板、DHCP 与 IPv6 RA

VPS 开机后看到的 DNS 不一定是用户亲手设置的。它可能来自预制系统镜像、Cloud-init、Netplan 文件、DHCP 租约或 IPv6 Router Advertisement。Netplan 官方文档明确说明,在 networkd 后端中,DHCP 下发的 DNS 默认会被采用,甚至优先于静态 DNS;IPv6 RA 也能提供 DNS 信息:Netplan DHCP 与 RA 配置。

这不表示 DHCP 本身有问题,但会造成一种常见现象:用户没有手动配置某个解析器,它却在每次重启后重新出现。如果不找到真正的配置来源,只修改 /etc/resolv.conf 通常无法持久生效。

3. 上游可能部署透明 DNS 代理或流量治理设备

机房或运营商可能为了缓存、审计、合规、反滥用或故障处理,对 53 端口流量进行代理或重定向。也可能什么策略都没打算做,只是中间设备配置错误、规则陈旧或多租户网络互相影响。

从 VPS 内部看,这几种情况的表象很相似:你以为请求发给了 8.8.8.8 或 223.6.6.6,真正回答的却未必是那个地址背后的解析器。没有双端抓包、路由侧日志或运营商配合,仅凭客户端不能断言具体机制。

4. 明文 UDP 53 本来就是最容易被看见的一环

传统 DNS 查询通常以明文发送。IETF 在制定 DoT 时直接指出,未加密 DNS 会暴露给能观察网络信道的设备;DoT 使用专用的 TCP 853 端口建立 TLS 通道,以减少窃听和路径中篡改机会:RFC 7858。Cloudflare 对 DoT 与 DoH 的说明也分别给出了 853 和 443 端口,并明确把防止监视和篡改作为加密 DNS 的目标:Cloudflare 加密 DNS 文档。

这也是本案例中 DoH 正常而 UDP 53 异常的意义:不是“Cloudflare 的答案天生更真”,而是查询换了一条中间设备难以直接读写内容的通道。

5. 普通的系统与运维配置错误

不是每个错误解析都是大型网络审查系统造成的。过期镜像、错误的静态 DNS、失效的本地缓存、被遗忘的 Docker daemon 配置、多个网络管理器争夺 /etc/resolv.conf,都可能产生类似症状。

因此,应先完成 UDP/TCP、不同解析器以及 DoH/DoT 的对照,再讨论问题归因。

八、为什么系统和 Docker 都会一起出错

继续检查实际网络配置:

ls -l /etc/netplan
grep -RniE 'nameservers|dhcp4|dhcp6' /etc/netplan 2>/dev/null
networkctl status ens17

结果确认 DNS 并非由 Docker 单独配置,也不是神秘地来自某个临时 DHCP 租约,而是写在 Netplan 配置文件中:

nameservers:
  addresses:
    - 223.5.5.5
    - 223.6.6.6

实际配置链是:

/etc/netplan/50-network.yaml
        ↓
Netplan
        ↓
systemd-networkd
        ↓
systemd-resolved
        ↓
系统应用与 Docker

systemd-resolved 会综合全局配置、每条网络链路的静态配置以及 DHCP 等来源选择 DNS 服务器。因此,只改 /etc/resolv.conf 往往不是持久修复;下一次网络重载或重启后,它仍可能被真正的配置源覆盖。Ubuntu 的 systemd-resolved 文档也说明了这些 DNS 来源及 /etc/resolv.conf 的多种管理模式:systemd-resolved 手册。

九、先验证 DoT,再改配置

修复方向是让系统使用支持 DNS over TLS 的解析器,避免继续通过异常的明文 UDP 53 路径查询。但在远程服务器上,绝不能先假定 853 端口可用。

先做只读连通性与证书验证:

timeout 10s openssl s_client \
  -connect 1.1.1.1:853 \
  -servername cloudflare-dns.com \
  -brief \
  -verify_return_error </dev/null

timeout 10s openssl s_client \
  -connect 8.8.8.8:853 \
  -servername dns.google \
  -brief \
  -verify_return_error </dev/null

本次环境中两个端点都返回了 Verification: OK,说明:

只有得到这组结果后,才进入修改阶段。如果你的服务器连接超时或被重置,应停下来选择可达的方案,例如经过验证的 DoH 本地代理;不要直接启用严格 DoT,否则所有 DNS 请求都可能失败。

十、最小化、可回滚的修复方案

NodeSeek 的 DNS 净化方案提供了“清理冲突 DNS 来源、统一交给 systemd-resolved、启用加密 DNS”的思路。这个方向值得参考,但一键脚本通常需要兼容 Debian、旧式 ifupdown、dhclient、resolvconf 等多种环境,会同时修改很多位置。

本次服务器已经确认使用的是:

Ubuntu + Netplan + systemd-networkd + systemd-resolved

因此无需修改 /etc/dhcp/dhclient.conf、/etc/network/interfaces 或 /etc/network/if-up.d/,也不需要卸载 resolvconf、重启整个 networking.service。最稳妥的方法是只处理已确认的两个配置层。

1. 备份配置

sudo cp -a /etc/netplan/50-network.yaml \
  /etc/netplan/50-network.yaml.bak-$(date +%Y%m%d-%H%M%S)

sudo cp -a /etc/systemd/resolved.conf \
  /etc/systemd/resolved.conf.bak-$(date +%Y%m%d-%H%M%S)

如果 VPS 提供商有网页控制台或救援模式,修改远程网络前也应确认它可用。

2. 修改 Netplan 中的 DNS 地址

保留原有 IP、路由、网卡匹配等配置,只替换 nameservers:

network:
  version: 2
  ethernets:
    ens17:
      # 原有 addresses、routes、match、set-name 等配置保持不变
      nameservers:
        addresses:
          - 1.1.1.1
          - 8.8.8.8

不要直接复制本文的完整网卡示例覆盖自己的文件。静态地址、默认网关、接口名称和缩进都必须以当前服务器为准。

3. 为 systemd-resolved 开启严格 DoT

编辑 /etc/systemd/resolved.conf:

[Resolve]
DNS=1.1.1.1#cloudflare-dns.com 8.8.8.8#dns.google
DNSOverTLS=yes
LLMNR=no
MulticastDNS=no

这里使用 地址#服务器名称,让 systemd-resolved 在建立 TLS 连接时使用对应的 SNI 并验证证书。DNSOverTLS=yes 是严格模式:如果上游不支持 DoT,DNS 查询会失败,而不会静默退回明文。相关行为可见 Ubuntu 的 resolved.conf 手册。

本次没有加入:

DNSSEC=allow-downgrade

因为 DNSSEC 与 DoT 解决的是不同问题。DoT 保护查询到解析器之间的传输;DNSSEC 验证支持签名的 DNS 数据。当前目标是消除明文传输路径这一变量,先保持改动最小更容易验证。

4. 先检查,再试运行

sudo netplan generate
sudo netplan try

netplan try 会临时应用新配置;如果在时限内没有确认,会尝试自动回滚。官方文档特别指出,它适合远程系统,可降低网络配置错误导致管理员永久失联的风险:Netplan Try 文档。

不过自动回滚并不是备份和带外控制台的替代品。Netplan 文档也提醒,超时或取消后仍应检查配置是否确实恢复。

确认 SSH 与网络都正常后,再重启解析服务:

sudo systemctl restart systemd-resolved

十一、如何验证修复真正闭环

不能以“命令没有报错”作为完成标准。DNS 修复本身至少需要验证四层,之后还要单独完成 AxonHub 的部署验收。

1. 配置层

resolvectl status ens17

检查 DNS 服务器是否已变为 1.1.1.1、8.8.8.8,并确认协议状态中 DoT 已启用。

2. 解析层

resolvectl query registry-1.docker.io

返回地址会随 Docker Hub 的调度变化,不能把某一组 IP 写死为永久标准。判断规则是:不应再出现与 Docker Hub 无关的 Meta/Facebook 地址,并应与可信的加密 DNS 查询结果语义一致。

必要时可以清理本地缓存后复查:

sudo resolvectl flush-caches
resolvectl query registry-1.docker.io

3. TLS 层

openssl s_client \
  -connect registry-1.docker.io:443 \
  -servername registry-1.docker.io \
  -verify_return_error </dev/null

确认不再收到 Facebook 证书,证书验证正常。

4. Docker 层

最后重新执行最初失败的命令:

docker buildx imagetools inspect \
  docker.io/looplj/axonhub:v0.9.43

只有这一步能正常读取镜像清单,并且 digest 与事先确认的官方值一致,才算完成从 DNS、TLS 到 Docker Registry 的验证闭环。

原排查记录中已经验证了 DoT 可达并形成了上述修复步骤,但没有保留修改后再次执行镜像检查的最终输出。因此本文不把最后一步描述为“已经验证成功”;实际操作时应以你自己的复查结果为准。

5. AxonHub 部署层

Docker Registry 验证通过后,才能回到最初的 AxonHub 部署目标。后续应当:

  1. 在 Compose 配置中引用已经确认的镜像 digest,而不是只写版本标签;
  2. 根据前面的资源评估设置 CPU 和内存限制;
  3. 只启动 AxonHub 相关服务,不修改现有 Aurora 容器;
  4. 验证 8090 端口上的服务是否可访问,并检查启动日志;
  5. 观察 AxonHub 的实际内存、CPU 和重启状态;
  6. 再次检查 Aurora 各容器的健康状态和服务器可用内存。

这一步属于部署验收,不能由“DNS 已经修复”自动替代。

十二、本次工作的实际完成边界

为了避免把排查过程写成已经完成的部署,下面按原始记录区分已确认事项与待完成事项:

状态 项目
已确认 服务器可以继续准备 AxonHub,但资源较紧,需要限制 CPU 和内存
已确认 8090 当时未被占用
已确认 AxonHub v0.9.43 的官方镜像与目标 digest
已确认 DaoCloud 镜像站中的 manifest digest 与官方值一致
已确认 Docker Hub 直连异常来自明文 DNS 路径,而不是 AxonHub 镜像
已确认 Cloudflare 与 Google 的 DoT 端点在该 VPS 上可达且证书验证通过
已形成方案 Netplan 与 systemd-resolved 的最小化、可回滚修改步骤
原记录未验证 修改 DNS 后重新访问 Docker Hub 是否成功
原记录未执行 创建并启动 AxonHub 容器
原记录未执行 AxonHub 启动后的功能、资源和 Aurora 回归验证

因此,这次工作的直接结果是完成了部署前评估、镜像验真和 DNS 根因定位;AxonHub 的正式启动仍应作为后续独立步骤完成。

十三、如果修改后仍然失败

可以按照下面的分支继续判断:

现象 优先检查
resolvectl 仍显示旧 DNS Netplan 是否真正应用、是否还有 DHCP 或其他每链路 DNS 来源
DoT 开启后所有解析失败 853 端口可达性、上游 DoT 支持、SNI 与证书验证
系统解析正常,Docker 仍异常 Docker daemon 的代理、镜像加速器、容器内独立 DNS 配置
DNS 正常,仍收到错误证书 实际连接 IP、HTTP/HTTPS 代理、SNI、出口透明代理
重启后旧 DNS 恢复 是否只临时执行了 resolvectl dns,而未修改持久配置源

还可以用以下命令确认 /etc/resolv.conf 的管理方式:

ls -l /etc/resolv.conf
cat /etc/resolv.conf

不要看到它指向 127.0.0.53 就认为配置错误。对于 systemd-resolved 的 stub 模式,这通常是预期行为;真正的上游 DNS 应通过 resolvectl status 查看。

十四、这次排查最值得复用的方法

1. 从错误发生的层级往前追

证书错误发生在 TLS 层,但根因可以在 DNS 层。报错位置不等于故障起点。

2. 每次只改变一个变量

这次最关键的实验不是换了多少公共 DNS,而是:

同一域名 + 同一 DNS + UDP/TCP 对照
同一服务器 + 明文 DNS/DoH 对照

对照实验比盲目更换配置更能说明问题。

3. 先找到真正的配置源

现代 Ubuntu 服务器上,DNS 可能来自 Netplan、systemd-networkd、DHCP、systemd-resolved 或其他网络管理器。只改 /etc/resolv.conf,或者运行一个同时修改所有历史配置位置的脚本,都可能掩盖真正的所有权关系。

4. “净化”不是改得越多越好

通用脚本的价值在于覆盖更多发行版和历史环境,但具体到一台服务器,最安全的修复通常是:识别当前网络栈,只修改拥有这项配置的最少文件。

5. TLS 报错是保护,不是麻烦

正是因为客户端严格校验证书,错误的 DNS 结果才没有悄悄变成一次成功连接。遇到域名不匹配时,不应关闭证书验证,而应继续追查为什么连接到了错误地址。

参考资料

← 全部文章