DNSSEC 为 DNS 数据增加来源认证和完整性检查。RRSIG 对记录集签名,DNSKEY 发布验证密钥,父区 DS 则通过摘要标识一个子区 DNSKEY。父区签名的是自己的 DS 记录集,不是简单直接签署子区公钥。验证从配置的信任锚出发,沿这些关系抵达所请求数据。理解链中每类记录的责任,才能准确定位发布与验证之间的差异。

坏掉的样子

如果委派没有建立预期信任路径,仅发布签名并不足够。缺失 DS 可能让子区处于不安全状态,错误 DS 则可能让原本应安全的子区验证失败;这是不同状态。已经安全证明不存在 DS,并不要求把每个未签名或独立签名的子区回答都当成无效数据拒绝。不能把“没有形成认证链”和“已有安全链却证明失败”混成一种结果。

签名具有独立于普通缓存 TTL 的生效与到期时间。签名过期后,权威区域可能继续回答记录,验证器却会拒绝。错误时钟、不完整密钥轮换和父区旧 DS,都可能中断一个普通查询看似仍正常的区域。只监控 DNSKEY 或 RRSIG 是否存在,会漏掉真正验证边界;应关注当前数据能否在有效时间和正确委派下被实际验证接受。

AD 标志也需要通过受信任解析路径解读。任意未受保护响应里出现该位,不会使回答自动可信,攻击者可以修改没有认证保护的消息。同样,使用 DO 请求 DNSSEC 记录,并不等于执行验证。应先确认哪个组件验证,以及应用如何信任它的回答,再把显示的标志作为证据。不能只凭工具输出里出现两个字母,就扩大为端到端安全保证。

快速验证

将脚本保存为 check-dnssec.py,准备 Docker 后执行 python3 check-dnssec.py。固定 BIND 9.18.49 环境创建一次性密钥和离线签名父子区域。独立验证解析器信任本地 lab.test 锚,只把这个测试命名空间转发给临时权威。私钥留在容器内部,不更改宿主信任配置和系统时间;不同案例使用独立进程,避免此前缓存影响结果:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype

def run(*args,check=True):
    result=subprocess.run(args,capture_output=True,text=True,timeout=15)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def query(name,kind='A',port=1053):
    return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
    wanted=dns.rdatatype.from_text(kind)
    return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
    def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
    return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
    deadline=time.monotonic()+timeout
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except (OSError,dns.exception.Timeout):pass
        time.sleep(.05)
    raise AssertionError('DNS fixture condition did not become true')
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=5)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)

@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
    with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
        root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
        def save(body):
            zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
            run('named-checkzone','lab.test',str(zone))
        save(records)
        conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
        run('named-checkconf',str(conf))
        output=stack.enter_context(log.open('w'))
        authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
        stack.callback(stop,authority)
        def ready():
            assert authority.poll() is None,log.read_text()
            response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
        wait(ready)
        def update(body):
            nonlocal serial
            serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
        def cache(port=1054,upstream=1053,size=150,options=()):
            logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
            command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
                '--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
                '--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
            process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
            def cache_ready():
                assert process.poll() is None,logfile.read_text()
                return query('lab.test.','SOA',port).rcode()==0
            wait(cache_ready);return process,logfile
        def queries(name,kind='A'):
            return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
        yield root,update,cache,queries

@contextmanager
def named_process(root,config):
    root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config)
    run('named-checkconf',str(conf))
    with log.open('w') as output:
        process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
        try:
            def ready():
                assert process.poll() is None,log.read_text()
                return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None
            wait(ready);yield process,log
        finally:stop(process)
from datetime import datetime,timedelta,timezone
import dns.flags,dns.rdataset,dns.zone
with TemporaryDirectory(prefix='xbcatm-dnssec-') as directory:
    root=Path(directory);keys=root/'keys';keys.mkdir()
    parent='lab.test.';child='secure.lab.test.';key_names={}
    for name in (parent,child):
        for role in ('KSK','ZSK'):
            args=['dnssec-keygen','-q','-a','ECDSAP256SHA256','-n','ZONE','-K',str(keys)]
            if role=='KSK':args+=['-f','KSK']
            key_names[name,role]=run(*args,name).stdout.strip()
    def base(name):return f'$ORIGIN {name}\n$TTL 60\n@ IN SOA ns.{name} hostmaster.{name} (1 60 60 3600 60)\n@ IN NS ns.{name}\nns IN A 127.0.0.1\n'
    child_zone=root/'child.zone';child_zone.write_text(base(child)+'www IN A 127.0.0.2\n')
    ds=run('dnssec-dsfromkey','-2',str(keys/(key_names[child,'KSK']+'.key'))).stdout.strip()
    parent_body=base(parent)+'secure IN NS ns.secure.lab.test.\nns.secure IN A 127.0.0.1\n'
    def sign(name,body,label,extra=()):
        plain=root/(label+'.zone');signed=root/(label+'.signed');plain.write_text(body)
        result=subprocess.run(['dnssec-signzone','-S','-K',str(keys),'-n','1','-o',name,'-f',str(signed),*extra,str(plain)],
            cwd=root,text=True,capture_output=True,timeout=15)
        assert result.returncode==0,result.stdout+result.stderr
        return signed
    signed_child=sign(child,child_zone.read_text(),'child-good')
    signed_parent=sign(parent,parent_body+ds+'\n','parent-good')
    parsed=dns.zone.from_file(str(signed_parent),origin=parent);anchor=next(key for key in parsed.get_rdataset('@','DNSKEY') if key.flags==257)
    anchor_parts=anchor.to_text().split();anchor_config=' '.join(anchor_parts[:3])+' '+json.dumps(''.join(anchor_parts[3:]))
    tampered=root/'child-tampered.signed';zone=dns.zone.from_file(str(signed_child),origin=child)
    zone.replace_rdataset('www',dns.rdataset.from_text('IN','A',60,'127.0.0.3'));zone.to_file(str(tampered))
    start=(datetime.now(timezone.utc)-timedelta(days=2)).strftime('%Y%m%d%H%M%S');end=(datetime.now(timezone.utc)-timedelta(days=1)).strftime('%Y%m%d%H%M%S')
    expired=sign(child,child_zone.read_text(),'child-expired',extra=('-s',start,'-e',end,'-P'))
    pieces=ds.split();pieces[-1]='0'*len(pieces[-1]);wrong_parent=sign(parent,parent_body+' '.join(pieces)+'\n','parent-wrong-ds')
    insecure_parent=sign(parent,parent_body,'parent-no-ds')
    outcomes={}
    for label,parent_file,child_file in [('secure',signed_parent,signed_child),('tampered',signed_parent,tampered),
        ('expired',signed_parent,expired),('wrong_DS',wrong_parent,signed_child),('unsigned_delegation',insecure_parent,signed_child)]:
        auth=root/(label+'-authority');resolver=root/(label+'-resolver');auth.mkdir();resolver.mkdir()
        def options(path,port,validation,recursion):return f'''options {{ directory "{path}"; listen-on port {port} {{127.0.0.1;}};
listen-on-v6 {{none;}}; recursion {recursion}; dnssec-validation {validation}; notify no; max-cache-size 8m;
pid-file "{path}/named.pid"; lock-file "{path}/named.lock"; session-keyfile none; }}; controls {{}};'''
        authority_config=options(auth,1053,'no','no')+f'''zone "{parent}" {{type primary; file "{parent_file}";}};
zone "{child}" {{type primary; file "{child_file}";}};'''
        resolver_config=options(resolver,1054,'yes','yes')+f'''trust-anchors {{ "{parent}" static-key {anchor_config}; }};
zone "{parent}" {{type forward; forward only; forwarders {{127.0.0.1 port 1053;}};}};'''
        with named_process(auth,authority_config),named_process(resolver,resolver_config):
            q=dns.message.make_query('www.'+child,'A',want_dnssec=True)
            direct=dns.query.udp(q,'127.0.0.1',port=1053,timeout=2)
            assert any(rr.rdtype==dns.rdatatype.RRSIG for rr in direct.answer) and not direct.flags&dns.flags.AD
            checked=dns.query.udp(q,'127.0.0.1',port=1054,timeout=8)
            outcomes[label]={'authority_A':values(direct),'authority_has_RRSIG':True,'resolver_rcode':dns.rcode.to_text(checked.rcode()),
                'resolver_AD':bool(checked.flags&dns.flags.AD),'resolver_A':values(checked)}
            if label=='secure':assert checked.rcode()==0 and checked.flags&dns.flags.AD and values(checked)==['127.0.0.2']
            elif label=='unsigned_delegation':assert checked.rcode()==0 and not checked.flags&dns.flags.AD and values(checked)==['127.0.0.2']
            else:assert checked.rcode()==dns.rcode.SERVFAIL and not values(checked)
    print(json.dumps({'trust_anchor':parent,'cases':outcomes,'system_clock_changed':False},sort_keys=True))
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'Dockerfile').write_text(recipe)
        built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
            '/usr/bin/python3','-',input=program)
        assert result.returncode==0,result.stderr
        print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
    docker('rm','-f',name);docker('image','rm',name)

有效案例中,父区签名 DS 匹配子区密钥,www.secure.lab.test 的 A 地址 127.0.0.2 具有有效签名。直接权威回答携带 RRSIG,但不标记 AD;独立解析器返回 NOERROR、预期地址和 AD。这证明提供签名数据与沿配置父子信任链验证数据不同,不能用权威服务器能列出签名,就代替客户端实际使用的验证路径。

篡改案例把子区 A 数据改成 127.0.0.3,却保留此前签名。直接权威查询仍返回新地址并带有 RRSIG,验证解析器则返回 SERVFAIL,没有地址。这个对照验证了具体数据修改被拒绝,而不是看到一条外形像签名的资源记录,就宣称正文内容没有被改变。签名必须与实际记录集以及可信密钥关系匹配,存在本身没有这样的保证。

另一个案例有意使用过去的有效时间签署子区。签名工具为生成过期实验输入跳过自己的签后检查,解析器验证始终保持启用。第三个案例正确签署父区,却公布错误子区 DS 摘要。两者都在解析器处产生 SERVFAIL,尽管权威仍返回原地址与 RRSIG。过期案例没有通过修改系统时间制造,因此不会影响宿主或其他实验的时钟状态。

最后,签名父区不再发布子区 DS。子区仍提供签名数据,但解析器返回地址而没有 AD。这个经认证的不安全委派,在本例策略下被接受,与篡改、过期和错误 DS 导致的无效安全链不同。每个场景都启动全新权威与验证器,使旧缓存不会掩盖变化;因此比较的是当前链条,而不是某个先前成功结果仍被复用。

让签名、委派和验证都可观察

本例使用本地锚,不测试公共根或注册商更新。生产解析器可以使用公共根锚,也可能具有其他明确配置的信任锚。关键是从验证器已经信任的锚出发形成有效路径,不是所有部署都必须依赖唯一硬编码根密钥。审阅配置时,应明确锚的范围与维护方式,避免把实验的本地信任关系误认为已经覆盖整个互联网命名空间。

签名可以离线完成,也可以通过托管权威流程维护,并不要求回答每个请求的服务器都现场重新签名。应验证发布的 DNSKEY 与 DS 关系、签名时间窗口,以及实际客户端解析器的结果。受控测试中还应包含预期验证失败:一个接受所有回答的解析器也可能看起来很健康,却没有提供验证能力。负向案例能帮助区分可用性与认证属性。

按照实现支持的流程及相关缓存寿命安排密钥轮换与父区协调。不要在链条损坏时悄悄关闭验证,再报告区域已经修复;恢复普通回答与恢复认证数据不同。对照直接权威内容和验证结果,定位发布、委派、签名时间或验证器信任配置中的故障。修复后应重新取得认证成功证据,而不只确认命令又能返回一个地址。

DNSSEC 不加密问题,不向解析器隐藏名称,也不证明认证地址上的应用一定安全。加密 DNS 传输与应用认证处理其他边界。本实验的承诺应保持精确:验证签名 DNS 数据,拒绝几种明确损坏的安全链,并展示没有认证委派时,即使签名存在也可能仍未获得验证。把这些结果区分清楚,才能让读者理解真正建立了什么信任。

延伸阅读

来源