DNS 协议与 DNSSEC 安全扩展
DNS 是互联网最古老也最被低估的基础设施:每次打开网页、每次 API 调用之前,都先有一次(或一串)域名解析。它设计于 1983 年,核心假设是『网络里都是好人』——报文明文、无源验证、无完整性校验。这个假设在 2008 年 Kaminsky 缓存投毒攻击后彻底破产,直接催生了 DNSSEC。本文拆解 DNS 的迭代解析与缓存体系、记录类型语义,再用一段零依赖 Python 模拟演示缓存收益与 DNSSEC 风格的签名验证如何抵御投毒。
一、解析体系:迭代、递归与缓存
一次 www.example.com 的完整解析涉及四类角色:
- 存根解析器(stub resolver):操作系统内置,只向递归解析器发一个查询,然后等完整答案(递归查询 RD=1);
- 递归解析器:运营商或公共 DNS(114.114.114.114、8.8.8.8)提供,替客户端完成全部迭代工作;
- 根服务器:13 组逻辑根(A~M,全球上千个任播节点),只回答『.com 归谁管』这类指引;
- 顶级域(TLD)与权威服务器:.com 的 TLD 服务器指引到 example.com 的权威服务器,权威服务器给出最终 A 记录。
递归解析器内部走的是迭代查询:问根得到 TLD 指引、问 TLD 得到权威指引、问权威得到答案。每一级结果都按 TTL 缓存——这是整个体系能撑住全球流量的根本原因:根服务器实际只承担全球极小比例的直接查询,绝大多数答案在各级缓存中命中。下文实验会把『首次 3 次外部查询、之后 0 次』这个缓存收益直接量化。
二、核心记录类型与协议细节
- A / AAAA:域名到 IPv4 / IPv6 地址,最基础的记录;
- CNAME:别名,指向另一个域名(CDN 接入的标准方式);注意 CNAME 不能与其他记录共存于同一节点,这是 zone apex(裸域)不能直接 CNAME 的原因;
- MX / NS / SOA:邮件交换、域名服务器委托、区域起始授权(含序列号与刷新生期);
- TXT:文本记录,承载了 SPF/DKIM 邮件防伪、域名所有权验证等大量协议扩展;
- SRV / CAA / HTTPS:服务定位、证书颁发授权、新型 HTTPS 服务绑定记录。
传输层默认 UDP/53,报文超过 512 字节(EDNS0 后约 1232 字节)或被截断(TC 位)时回退 TCP/53。查询与响应共享同一 16 位事务 ID 与端口对——这个脆弱的匹配机制正是投毒攻击的入口。
三、DNSSEC:给答案签名,而不是给信道加密
DNSSEC(RFC 4033~4035)解决的核心问题是应答的真实性:客户端如何确认『93.184.216.34』这个答案确实来自 example.com 的权威管理员,而不是某个在链路上伪造响应的攻击者?
设计要点:
- RRSIG:区域用私钥对每组记录(RRset)签名,签名随记录一起下发。注意它签名的是数据而非传输——投毒者即使伪造了 UDP 响应,也造不出合法签名;
- DNSKEY:区域公钥以 DNSKEY 记录发布,本身也被签名;
- DS 与信任链:子区域的 DNSKEY 哈希(DS 记录)存放在父区域并由父区域签名。验证从根的信任锚(root trust anchor,全球公开发布)开始:根签 .com 的 DS → .com 签 example.com 的 DS → example.com 签自己的记录,形成一条从根到叶子的密码学信任链;
- NSEC/NSEC3:对『不存在』这个否定回答也签名——按字典序签名相邻域名之间的空隙,防止攻击者伪造『域名不存在』的应答。
四、动手实验:缓存收益与投毒检测
下面代码用三类模拟服务器(根、TLD、权威)实现迭代解析并统计外部查询次数,再用 HMAC 模拟 DNSSEC 风格的记录签名(教学模型——真实 DNSSEC 用 RSA/ECDSA/EdDSA 非对称签名):验证正常记录、检测被投毒篡改的记录,并演示『根签 TLD、TLD 签权威』的信任链。零依赖,Python 3 直接运行:
import hashlib, hmac, os
# ---- 迭代解析: 根 -> TLD -> 权威 ----
class DnsServer:
def __init__(self, name, zone):
self.name, self.zone, self.queries = name, zone, 0
def answer(self, qname):
self.queries += 1
return self.zone.get(qname)
root = DnsServer('root', {'www.example.com': ('NS', 'com-tld')})
tld = DnsServer('com-tld', {'www.example.com': ('NS', 'example-auth')})
auth = DnsServer('example-auth',
{'www.example.com': ('A', None, '93.184.216.34')})
def resolve(qname, cache):
if qname in cache:
return cache[qname], 0, 'HIT'
hops = 0
root.answer(qname); hops += 1 # 根: 指引到 .com
tld.answer(qname); hops += 1 # TLD: 指引到权威
r = auth.answer(qname); hops += 1 # 权威: 返回 A 记录
cache[qname] = r
return r, hops, 'MISS'
# ---- DNSSEC 风格签名验证(教学模型) ----
def sign(sk, rrset):
msg = '|'.join(f'{k}={v}' for k, v in sorted(rrset.items()))
return hmac.new(sk, msg.encode(), hashlib.sha256).hexdigest()
def ds_of(pk):
return hashlib.sha256(pk).hexdigest()[:16]
root_sk, tld_sk, zone_sk = os.urandom(32), os.urandom(32), os.urandom(32)
tld_ds = sign(root_sk, {'DS.com': ds_of(tld_sk)}) # 根签 TLD 的 DS
zone_ds = sign(tld_sk, {'DS.example.com': ds_of(zone_sk)}) # TLD 签权威 DS
rrset = {'www.example.com': 'A 93.184.216.34', 'TTL': '300'}
rrsig = sign(zone_sk, rrset)
forged = {'www.example.com': 'A 6.6.6.6', 'TTL': '300'} # 投毒伪造
ok = hmac.compare_digest(sign(zone_sk, rrset), rrsig)
bad = hmac.compare_digest(sign(zone_sk, forged), rrsig)
在沙箱(Python 3,无第三方库)上的真实运行输出:
== 迭代解析与缓存 ==
首次解析 : MISS, 外部查询 3 次, 结果: A 93.184.216.34
再次解析(同域) : HIT , 外部查询 0 次, 结果: A 93.184.216.34
第三次解析 : HIT , 外部查询 0 次, 结果: A 93.184.216.34
== DNSSEC 风格验证 ==
信任链: root签com的DS -> 9dedc06d3747...
com签example的DS -> 19ce50b201f1...
记录签名 RRSIG: 1d9890c8660b5f67...
正常记录验证: 通过
篡改记录(投毒 6.6.6.6)验证: 失败 -> 拒绝伪造应答
校验通过: 迭代解析缓存生效(再查 0 次外部查询), 签名验证通过且投毒可检测
五、结果解读
- 缓存的复利效应:首次解析付了 3 次外部查询的完整迭代成本,之后同一域名的查询成本归零。真实递归解析器面对数百万用户共享一份缓存,热门域名的缓存命中率超过 99%——这就是为什么 13 组逻辑根足以服务整个互联网。TTL 是这场平衡术的旋钮:太短则权威服务器被打爆,太长则记录变更(如故障切换)生效缓慢。
- 投毒被检测的机理:攻击者伪造的 A 记录(6.6.6.6)内容不同,用区域公钥重算的签名必然与原 RRSIG 不符,验证失败即拒绝应答。关键点在于签名覆盖的是记录内容本身——与 TLS 1.3 的 Finished 覆盖整个握手副本(见本站该篇)是同一思想:认证数据而非认证信道。
- 信任链的锚定方式:实验里 root 签 com 的 DS、com 签 example.com 的 DS,逐层把信任从根传递到叶子。整条链上只要任一环节签名不符,验证立即失败——这让『在某一级换假公钥』的攻击同样无效。代价是密钥管理复杂度的上移:区域签名密钥(ZSK)与密钥签名密钥(KSK)分离、定期滚动,任何一次滚动失误都会造成全域解析失败(真实的生产事故屡见不鲜)。
六、工程应用
- 本地解析链:Linux 的
/etc/resolv.conf(nameserver)、nsswitch、systemd-resolved 的缓存与 LLMNR/mDNS 干扰是排障第一站;容器内 DNS 走 conntrack,高 QPS 下要警惕 nf_conntrack 表满与 DNS 五秒超时(glibc 串行 A/AAAA 查询的经典坑)。 - 权威服务选型:云解析(DNSPod/Route53)提供任播与按地域智能解析;自建权威推荐 NSD/Knot(纯权威、内存占用小),BIND 功能全但攻击面大。
- 启用 DNSSEC:权威侧在 DNSPod/云厂商控制台通常一键开启(自动管理 KSK/ZSK 滚动与 DS 上报);验证侧用
delv或dig +dnssec检查 AD 位,Unbound/dnsmasq 可做本地验证递归。 - DoH/DoT 部署:公共解析器普遍支持 DoH;企业内网可用 DoT 保护出口查询。注意 DoH 会绕过企业 DNS 审计策略,安全团队需要明确立场。
- 故障切换:基于 DNS 的主备切换要把 TTL 压到 30~60 秒并接受『部分客户端不遵守 TTL』的现实——严格场景用 Anycast/BGP 层切换而非 DNS。
七、常见误区
- 把 DNSSEC 当加密:它只签名不加密,查询内容对链路监听者完全可见;机密性要 DoT/DoH,二者经常被混为一谈。
- 忽视 TTL 与变更预案:把 TTL 设成 86400 然后指望 DNS 切换做容灾,等于把故障恢复时间交给全球缓存的良心。
- 裸域直接 CNAME 到 CDN:违反 CNAME 排他性约束,正确做法是用 ALIAS/ANAME 或 CNAME Flattening(各厂商叫法不同)。
- 递归服务器对公网开放:开放递归会被用作 DNS 放大攻击的反射源(UDP 小查询放大为大响应),必须限制只服务内网。
- 开启 DNSSEC 验证后不管日志:上游区域签名配置错误(如 DS 与 DNSKEY 不匹配、签名过期)会让验证递归直接返回 SERVFAIL,表现为『网站突然打不开』——需要监控验证失败率。
八、参考文献
- Mockapetris, P. Domain Names - Concepts and Facilities / Implementation and Specification. RFC 1034/1035, 1987.
- Arends, R. et al. DNS Security Introduction and Requirements / Resource Records / Protocol Modifications. RFC 4033/4034/4035, 2005.
- Kaminsky, D. Black Ops 2008: It's the End of the Cache as We Know It. Black Hat 2008(缓存投毒攻击披露).
- Hoffman, P., McManus, P. DNS Queries over HTTPS (DoH). RFC 8484, 2018.
- ICANN. Root Zone KSK Rollover Plan. 2018(根区密钥滚动的运营实践).