子域名接管排查:给每条悬空解析做体检

一、CNAME 是最容易被忽略的一类「域名接管」

域名接管(subdomain takeover)听起来像大厂才需要关心的问题,实际上个人站和中型站点是最容易中招的一类。原因在于:站点往往在几年里陆续接了各种第三方服务——早年的 CDN、已经停用的图床、Test 环境的 Heroku、GitHub Pages 的博客、Shopify 的活动页、Sentry 的错误上报、第三方统计。服务下线时,人只记得删 DNS 记录;可一旦忘了删,那条 CNAME 就指向一个已经没人持有的目标。

攻击者做的事很简单:在自己那个平台上注册同名资源(一个 GitHub Pages 仓库、一个 Heroku app 名、一个 S3 bucket 名),于是你的 activity.example.com 就开始返回他的页面。域名是你的,搜索结果里显示的是你的品牌,钓鱼邮件里放这个链接甚至有你的 HTTPS 证书,受害者几乎无法分辨。

二、先分清三种「悬空」状态

不是所有解析不到目标的记录都可以被接管,判断的关键是「返回的是什么」。用 dig 看三件事:CNAME 指向、目标是否还存在、返回的 HTTP 状态与响应体特征。

dig +short activity.example.com CNAME
dig +short activity.example.com A
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: activity.example.com' https://目标主机/

常见的三种状态:

  • 悬空且可被注册:目标平台返回 404 或「No such app」,且该名字仍可被新用户占用。这是真正的高危,必须立刻删记录。
  • 悬空但已被占用:目标已经 404,但名字被别人注册了。此时域名已经处于被接管状态,删记录是止血,还要检查有没有留下钓鱼页和证书签发记录。
  • 指向自有资源但已无服务:比如指向一台已经下线的自有服务器 IP。这种不会被第三方注册,但会留下一个「解析有效、连接失败」的入口,属于另一类问题。

很多人把 NXDOMAIN(目标不存在)和返回 404 混淆。前者说明目标 DNS 都不存在了,通常意味着平台已整体下线;后者才说明「域名还在、资源没了」——而后者才是可被抢占的典型形态。

三、用证书透明度日志补充发现手段

光翻 DNS 记录会漏东西,因为有些子域名是历史遗留、早就不在面板里显示了,但证书签发记录里留着痕迹。证书透明度(CT)日志是公开的,任何证书一签发就会被记录,含完整域名。查询方式有几种,最简单的是一条命令:

curl -s "https://crt.sh/?q=%25.example.com&output=json" \
  | python3 -c 'import sys,json; s=set(); [s.update(r["name_value"].split()) for r in json.load(sys.stdin)]; print(chr(10).join(sorted(s)))'

拿到清单后与现有 DNS 记录做差集,凡是在 CT 里出现过、现在却解析不到的,都值得查一遍。这个手段的价值在于它不受你的记忆限制:五年前临时开的测试域名,你自己忘了,CT 日志还记得。

四、把清单自动化:一份可跑的巡检脚本

手工查一次只能解决当下,做成脚本才能防止「下次又忘」。下面这个脚本读取一份子域名清单,逐个判断当前状态,输出可疑项:

#!/usr/bin/env bash
# 用法: ./takeover_check.sh subs.txt
while read -r host; do
  [ -z "$host" ] && continue
  cname=$(dig +short "$host" CNAME | head -1)
  a=$(dig +short "$host" A | head -1)
  code=$(curl -s -m 8 -o /tmp/tk.body -w '%{http_code}' "https://$host/" 2>/dev/null || echo 000)
  size=$(wc -c < /tmp/tk.body 2>/dev/null || echo 0)
  printf '%-40s code=%s size=%s cname=%s a=%s\n' "$host" "$code" "$size" "${cname:-none}" "${a:-none}"
done < "$1"

判定规则建议这样写死:HTTP 0 或 404 且响应体里出现 NoSuchBucket、no such app、There isn't a GitHub Pages site here、Repository not found 这类字样,就标红。只看状态码会漏判,因为不少平台对不存在的资源也返回 200 的「占位页」,但内容里带固定文案。

脚本跑通的标志是:对已知正常的子域名返回 200 且体积较大,对故意留的空壳子域名能命中关键字。没有这个对照,脚本本身的误报会让人很快不再看它。

五、Nginx 侧的另一半:Host 头与默认 server

悬空解析之外,还有一种相关但更本地的风险:解析没错,但 Nginx 对未知 Host 的处理方式把不该暴露的东西暴露了。默认情况下,一个请求的 Host 头如果匹配不到任何 server_name,Nginx 会用第一个 server 块(或显式 default_server)来响应——于是随便一个指向你 IP 的域名,都能访问到你「不打算公开」的那个站点。

server {
    listen 80 default_server;
    listen 443 ssl default_server;
    server_name _;
    ssl_certificate     /etc/nginx/ssl/default.crt;
    ssl_certificate_key /etc/nginx/ssl/default.key;
    return 444;
}

返回 444 是 Nginx 特有的「直接关闭连接」,比 404 更干净,不会返回任何页面内容。这条默认 server 要放在 conf.d 里确保最先加载(用文件名前缀控制顺序,如 00-default.conf),并且必须为它配一张自签证书,否则 HTTPS 握手阶段就会因为无证书而报错,攻击面反而变成「无从判断」。配完用 curl -H 'Host: random.example.com' -k https://你的IP/ 验证,应该是空响应而不是 200。

六、处理顺序:先止血,再清理,最后防复发

发现可被接管的记录后,动作的顺序很关键,顺序错了会放大风险。

  • 第一步,删记录或改指向。不要「先申请那个资源保住名字」——那等于用自己的账号去接管一个本该消失的入口,还会让这个悬空依赖继续存在。删 DNS 记录才是根除,CDN 侧若有缓存也一并清。
  • 第二步,查证书。到 CT 日志里查这个子域名近期有没有被签过证书(尤其是 Let's Encrypt,任何人都能申请)。如果有,说明攻击者已经准备好用你的域名跑 HTTPS,要记录时间点,评估是否有用户在那个窗口访问过。
  • 第三步,查日志与搜索。在站点日志里搜这个子域名的 Referer,在搜索引擎里 site:子域名 看是否已被收录成钓鱼页,必要时提交移除。
  • 第四步,防复发。给 DNS 变更加一条纪律:删除任何第三方服务时,先把 DNS 记录列出来,一条条确认;每季度跑一次上面的巡检脚本;CT 日志订阅(多数平台提供 certwatch 类功能),有新的子域名证书签发时能收到通知。

七、和证书申请流程一起考虑

自动申请证书的工具(certbot、acme.sh)在申请前会验证域名控制权,这个过程本身就能暴露悬空解析:如果某个子域名的解析指向了别处,验证会失败,报错信息里往往写着「返回了非预期内容」。别把这类报错当成「网络问题」忽略掉,它经常是第一条线索。

反过来,如果你开启了泛域名证书(*.example.com),要意识到它让「任何子域名都能用你的证书跑 HTTPS」这件事变得更容易:攻击者只要拿到解析权,就能直接对上证书。所以通配符证书站点更应该把子域名清单管严,尤其是那些已经不用但记录还在的老服务。

八、CDN 与对象存储:最容易被遗忘的一整类悬空

上面讲的都是 DNS 直接指向第三方平台的情况,还有一种更隐蔽:记录指向的是自家 CDN 的加速域名,而 CDN 那边的源站配置早就删了。这时候 DNS 看起来「正常」——CNAME 到 CDN 的调度域名,解析能成功,但 CDN 拿到请求后找不到回源配置,返回的是平台自己的错误页,而这个错误页恰好可能带品牌名。

这类记录在接管检查里必须单独处理,判据是「CNAME 链条的第二跳是不是一个你控制得住的域名」。检查方式是逐跳展开:

dig +short static.example.com CNAME
dig +short 上面输出的那个域名 CNAME
curl -s -I "https://static.example.com/__takeover_probe__" | head -5

如果第二跳落到 *.cloudfront.net、*.oss-cn-*.aliyuncs.com、*.b0.upaiyun.com、*.github.io、*.netlify.app 这类共享域,那它就是一个可以被别人抢占的名字。对象存储的桶名尤其危险:桶名全局唯一,你删了桶之后,别人可以立刻注册同名桶,然后你的域名就会指向他的静态站点。

处理这类记录的动作要和 DNS 分开做:先在 CDN 面板确认加速域名是否已删除,再决定 DNS 记录是改指自有源站还是直接删除。顺序反过来会得到一个「DNS 已删、CDN 还在计费」的尴尬状态,账单每月照扣,而你已经完全忘了它的存在。

九、为什么这类问题在小站更常见

大公司有资产清单和权限审批,一个小团队或一个人的站,历史遗留基本靠记忆。站点运营三年以上,大概率会经历这些阶段:初期用免费图床,后来换自建;博客先挂 GitHub Pages,后来搬到自己服务器;活动页用第三方建站工具做,活动结束就忘了下线。每一次迁移都会留下几条记录,而删记录这件事从来不紧急,于是就一直没做。

还有个更实际的原因:CDN 和对象存储往往用主账号登录,而域名解析在另一家服务商,两边不在一起看,删除服务时根本想不起来还有 DNS 这一环。解决办法是在变更流程里加一句硬性检查——凡是「下线某个对外服务」,第一步就是列出所有指向它的解析记录,拍照存档,再做删除。这个动作五秒钟,能避免前面讲的所有风险。

十、小结

子域名接管的技术门槛极低,防御成本也不高,真正难的是「想起来要查」。把 CT 日志当发现手段、把巡检脚本当日常动作、把 Nginx 默认 server 当兜底,再配一条「删服务必删解析」的变更纪律,四件事做完,这类问题基本就关在门外了。下次清理 CDN、停用图床、迁移博客平台时,顺手跑一遍脚本,比事后补救便宜得多。

Last modification:November 5th, 2026 at 12:26 pm

Leave a Comment