DNS 协议与 DNSSEC 安全扩展

高性能网络 2026-08-02 · 阅读约 22 分钟

DNS 是互联网最古老也最被低估的基础设施:每次打开网页、每次 API 调用之前,都先有一次(或一串)域名解析。它设计于 1983 年,核心假设是『网络里都是好人』——报文明文、无源验证、无完整性校验。这个假设在 2008 年 Kaminsky 缓存投毒攻击后彻底破产,直接催生了 DNSSEC。本文拆解 DNS 的迭代解析与缓存体系、记录类型语义,再用一段零依赖 Python 模拟演示缓存收益与 DNSSEC 风格的签名验证如何抵御投毒。

一、解析体系:迭代、递归与缓存

一次 www.example.com 的完整解析涉及四类角色:

  1. 存根解析器(stub resolver):操作系统内置,只向递归解析器发一个查询,然后等完整答案(递归查询 RD=1);
  2. 递归解析器:运营商或公共 DNS(114.114.114.114、8.8.8.8)提供,替客户端完成全部迭代工作;
  3. 根服务器:13 组逻辑根(A~M,全球上千个任播节点),只回答『.com 归谁管』这类指引;
  4. 顶级域(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:对『不存在』这个否定回答也签名——按字典序签名相邻域名之间的空隙,防止攻击者伪造『域名不存在』的应答。
常见误解:DNSSEC 不提供机密性,DNS 查询内容仍然明文。解决窃听的是 DoT(DNS over TLS,853 端口)与 DoH(DNS over HTTPS,443 端口),它们与 DNSSEC 是正交的两层——一个管信道加密,一个管数据真伪。

四、动手实验:缓存收益与投毒检测

下面代码用三类模拟服务器(根、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。

七、常见误区

  1. 把 DNSSEC 当加密:它只签名不加密,查询内容对链路监听者完全可见;机密性要 DoT/DoH,二者经常被混为一谈。
  2. 忽视 TTL 与变更预案:把 TTL 设成 86400 然后指望 DNS 切换做容灾,等于把故障恢复时间交给全球缓存的良心。
  3. 裸域直接 CNAME 到 CDN:违反 CNAME 排他性约束,正确做法是用 ALIAS/ANAME 或 CNAME Flattening(各厂商叫法不同)。
  4. 递归服务器对公网开放:开放递归会被用作 DNS 放大攻击的反射源(UDP 小查询放大为大响应),必须限制只服务内网。
  5. 开启 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(根区密钥滚动的运营实践).