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 数据,拒绝几种明确损坏的安全链,并展示没有认证委派时,即使签名存在也可能仍未获得验证。把这些结果区分清楚,才能让读者理解真正建立了什么信任。
延伸阅读
来源
- RFC 4033 DNS security introduction,DNSSEC 属性与安全、不安全、无效数据分类,已核验 2026-09-12。
- RFC 4034 DNSSEC resource records,DNSKEY、RRSIG 与 DS 的关系,已核验 2026-09-12。
- RFC 4035 DNSSEC protocol modifications,验证、认证委派与解析器行为,已核验 2026-09-12。
- BIND 9.18.49 tools,离线密钥生成、区域签名与 DS 生成,已核验 2026-09-12。