子域名接管(Subdomain Takeover)是指企业某条子域名的 CNAME 记录仍指向已失效的第三方服务,攻击者注册同名资源后即可完全控制该子域名。本文给出可批量执行的 CNAME 悬空检测脚本、第三方服务指纹识别方法、修复与验证流程,帮助运维人员在不依赖商业平台的前提下完成自有资产排查。
适用场景
- 企业历史遗留子域名较多,曾使用 GitHub Pages、对象存储、Heroku、Azure、Shopify、云函数等外部托管服务。
- 市场活动结束后临时域名未清理,或服务下线但 DNS 记录未同步删除。
- 需要为主域名下的所有子域名做一次资产风险体检。
- 站点已启用 HSTS 或对子域下发 Cookie,一旦子域名被接管会连带主域会话安全。
前置条件
- 需要一份待检测的子域名清单,来源可以是证书透明日志、企业内部 CMDB 或历史解析记录。
- Linux 环境,已安装
dig(bind-utils / dnsutils)与 Python 3.8 以上。 - Python 依赖:
dnspython、requests。 - 检测脚本仅做被动解析与 HTTP 探测,不发起任何写入操作,可在办公网直接运行。
# Debian / Ubuntu
apt-get install -y dnsutils python3-pip
pip3 install dnspython requests
# CentOS / RHEL
yum install -y bind-utils python3-pip
原理说明
子域名接管的成立需要同时满足两个条件。
第一,DNS 层面存在悬空 CNAME。例如营销活动页配置了 promo.example.com CNAME example.github.io,活动结束后删除 GitHub 仓库,但 DNS 记录未清理,此时解析链条的终点是一个不存在的资源。
第二,第三方服务不校验域名归属即允许占用。GitHub Pages、部分对象存储、PaaS 平台在用户未绑定自定义域名时,任何人在自己账号下创建同名资源并声明该域名,即可让解析流量落到自己服务器,平台不会要求域名所有权证明。
一旦接管成功,攻击者可获得的能力包括:
- 在合法子域名下托管钓鱼页面,借用主域可信度提升欺骗成功率。
- 若主域在
Domain=.example.com作用域下下发 Cookie,或子域通过document.domain与主域通信,则可能扩大会话泄露面。 - 绕过基于域名白名单的 CSP、CORS 与 SSO 回调校验。
- 申请该子域名的 TLS 证书,形成长期可信的服务入口。
检测的核心工作因此分为两步:找出「CNAME 指向的第三方服务、且该服务返回未认领特征」的记录;判定该第三方服务是否属于可被接管的脆弱类型。
操作步骤
步骤一:梳理待检测子域名清单
从证书透明日志获取公开子域名,作为清单基础:
curl -s "https://crt.sh/?q=%25.example.com&output=json" \
| python3 -c "
import json,sys
d=json.load(sys.stdin)
names=set()
for e in d:
for n in e.get('name_value','').split('\n'):
n=n.strip().lower().lstrip('*.')
if n.endswith('example.com'):
names.add(n)
print('\n'.join(sorted(names)))
" > subdomains.txt
wc -l subdomains.txt
步骤二:编写 CNAME 悬空检测脚本
脚本逻辑:解析 CNAME 记录,识别指向第三方托管服务的记录,再对目标发起 HTTP 请求,与已知「未认领」特征库比对。
# -*- coding: utf-8 -*-
import json, re, sys
import dns.resolver, requests
DOMAIN = "example.com"
# 常见可被接管的第三方托管服务指纹
FINGERPRINTS = [
# (CNAME 特征, 响应体/状态特征, 服务名)
(r"\.github\.io$", r"There isn't a GitHub Pages site here", "GitHub Pages"),
(r"\.s3[.-]", r"NoSuchBucket", "AWS S3"),
(r"\.herokuapp\.com$", r"No such app", "Heroku"),
(r"\.azurewebsites\.net$",r"Error 404 - Web app not found", "Azure Web App"),
(r"\.cloudfront\.net$", r"Bad request", "CloudFront"),
(r"\.myshopify\.com$", r"Sorry, this shop is currently unavailable","Shopify"),
(r"\.readthedocs\.io$", r"unknown to Read the Docs", "Read the Docs"),
(r"\.surge\.sh$", r"project not found", "Surge"),
(r"\.netlify\.app$", r"Not Found - Request ID", "Netlify"),
(r"\.trafficmanager\.net$", r"NXDOMAIN", "Azure Traffic Manager"),
]
resolver = dns.resolver.Resolver()
resolver.timeout, resolver.lifetime = 3.0, 5.0
def cname_chain(host):
chain = []
try:
ans = resolver.resolve(host, "CNAME")
for r in ans:
chain.append(str(r.target).rstrip("."))
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN,
dns.resolver.NoNameservers, dns.exception.Timeout):
pass
return chain
def probe(host):
for scheme in ("https", "http"):
try:
r = requests.get(f"{scheme}://{host}/", timeout=8,
allow_redirects=True,
headers={"User-Agent": "asset-audit/1.0"},
verify=False)
return r.status_code, r.text[:5000]
except requests.RequestException:
continue
return None, ""
def main():
hosts = [l.strip() for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
findings = []
for h in hosts:
chain = cname_chain(h)
if not chain:
continue
tail = chain[-1].lower()
for cname_re, body_re, svc in FINGERPRINTS:
if not re.search(cname_re, tail):
continue
code, body = probe(h)
dangling = bool(re.search(body_re, body, re.I)) or code in (404, 502, None)
findings.append(dict(host=h, cname=tail, service=svc,
http=code, dangling=dangling))
print(f"[{'DANGLING' if dangling else 'review '}] "
f"{h} -> {tail} ({svc}, HTTP {code})")
json.dump(findings, open("dangling_report.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
print(f"\n结论: 共 {sum(1 for f in findings if f['dangling'])} 条悬空记录 / "
f"{len(findings)} 条第三方 CNAME")
if __name__ == "__main__":
main()
执行并查看结果:
python3 cname_audit.py subdomains.txt
cat dangling_report.json | head -40
重要参数说明:verify=False 用于绕过证书错误以观察真实响应体,仅用于自有资产探测;timeout=8 避免在网络抖动时长时间阻塞;超时未被计入 dangling 判定的强证据,应二次确认。
步骤三:手工交叉验证可疑记录
# 1. 查看解析链完整路径
dig +short promo.example.com CNAME
dig +short promo.example.com A
# 2. 确认 CDN/托管服务的响应特征
curl -sI https://promo.example.com/ | head -5
curl -s https://promo.example.com/ | head -30
# 3. 若返回 NXDOMAIN 或第三方默认错误页,基本可确认悬空
dig +trace promo.example.com | tail -20
判定标准:CNAME 记录存在、目标资源不存在或未绑定该域名、且第三方平台允许无所有权校验地占用同名资源,三者同时成立即为有效风险。
步骤四:修复
- 不再使用的子域名:删除对应 CNAME 记录,保留必要的 A/AAAA 记录或直接置空。删除后等待 TTL 过期,通常 300 至 3600 秒。
- 仍需使用的子域名:在第三方平台重新认领资源并绑定域名,或迁移到自有服务器并改指 A 记录。
- 无法立即处置的记录:先在平台侧完成资源占用,或通过 CDN 回落策略返回 404,避免出现第三方默认错误页。
- 辅助控制:为域名配置 CAA 记录限制可签发证书的 CA,收敛被接管后的证书滥用风险。
; 域名配置 CAA,仅允许指定 CA 为本域签发证书
example.com. IN CAA 0 issue "letsencrypt.org"
example.com. IN CAA 0 issuewild ";"
步骤五:纳入持续监控
# 每日 03:00 执行一次并邮件告警(示例 crontab)
0 3 * * * cd /opt/asset-audit && python3 cname_audit.py subdomains.txt \
>> audit-$(date +\%F).log 2>&1 \
&& grep -q DANGLING audit-$(date +\%F).log \
&& mail -s "dangling CNAME found" ops@example.com < audit-$(date +\%F).log
配置验证
- 脚本层面:重新执行检测,输出中不再出现
DANGLING记录,dangling_report.json中dangling字段全为false。 - 解析层面:
dig +short <子域名> CNAME无输出,或输出的目标域名可正常解析到有效服务。 - 内容层面:
curl -s https://<子域名>/ | head -30不再出现第三方平台的「资源不存在」默认页。 - 证书层面:
echo | openssl s_client -connect <子域名>:443 -servername <子域名> 2>/dev/null | openssl x509 -noout -subject -dates确认证书主体为本域,而非第三方平台默认证书。
常见问题
FAQ1:CNAME 指向第三方服务但资源仍存在,还要处理吗?
属于「自持但高风险」状态,需要处理但优先级次之。风险点在于资源存在并不等于所有权可控:若托管资源由离职员工个人账号持有、或绑定的组织账号已注销,随时可能被回收。建议将此类记录纳入资产台账,明确责任人与续期时间,并在平台侧确认账号归属企业主体。若资源由企业内部账号持有且已开启 MFA,可保留但需加入季度复核清单。
FAQ2:主域配置了泛解析(* 记录),是否会放大风险?
泛解析会显著扩大影响面。泛解析让任意不存在的子域名都返回同一地址,如果该地址指向第三方托管服务,攻击者可以构造任意子域名前缀触发悬空判定,并利用搜索引擎与证书透明日志快速发现。此外泛解析场景下子域名枚举结果会全部命中,产生大量误报。检测时应把泛解析记录的返回特征单独记录,对泛解析之外的精确记录做重点核查。
FAQ3:检测脚本报告大量误报怎么办?
误报主要来自三个方面:目标站点自定义了 404 页面并包含第三方平台名称;HTTP 探测被 WAF 或 CDN 拦截返回统一错误页;第三方服务确实存在但配置了域名白名单导致对陌生 Host 返回 404。降低误报的做法是同时校验三项证据——CNAME 目标资源是否可访问、第三方平台是否要求域名所有权验证、响应体错误码是否与官方未认领文案一致,三项齐备再判定为悬空。也可用 dig 检查 CNAME 目标自身是否可解析,目标本身 NXDOMAIN 时可信度最高。
总结
子域名接管的本质是资产台账与 DNS 记录长期不同步。排查成本很低——一次全量子域名解析加一次 HTTP 探测即可覆盖绝大部分风险,而修复动作通常只是删除一条记录。建议运维团队把「下线服务同步清理 DNS」写入变更流程,并对所有指向第三方托管服务的 CNAME 建立季度复核机制,条件允许时把检测脚本接入每日定时任务,让悬空记录在产生当天就被发现。