DNS 报文结构解析:从一次 dig 查询说起

用 dig 发起一次查询,结合 RFC 1035 逐字段拆解 DNS 头部、问题区与资源记录,顺便搞懂域名压缩指针 0xC00C。

DATE
LEN
848 字
TTL
2 min

DNS 是我们每天用得最多、却最少认真看过的协议。它的报文格式非常紧凑,很适合作为“逐字节分析”的入门练习。

一次查询

$ dig www.example.com A

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41870
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;www.example.com.               IN      A

;; ANSWER SECTION:
www.example.com.        300     IN      A       203.0.113.80

;; SERVER: 192.0.2.53#53(192.0.2.53) (UDP)
;; MSG SIZE  rcvd: 60

笔记

示例中的 IP 均替换成了 RFC 5737 规定的文档保留地址,你自己实验时会看到真实地址。

dig 的输出其实就是把报文的各个部分“翻译”成了人话。下面对照报文格式一一拆开。

整体结构

一个 DNS 报文由五部分组成:

区段内容
Header固定 12 字节
Question要查什么(域名 + 类型 + 类)
Answer回答的资源记录(RR)
Authority权威服务器的 RR
Additional附加 RR(如 EDNS 的 OPT 伪记录)

Header:12 字节

按 RFC 1035 的习惯,DNS 头部画成 16 位宽:

HDRDNS Header(含 RFC 4035 的 AD/CD 位)
0123456789101112131415
ID16
QR1
Opcode4
AA1
TC1
RD1
RA1
Z1
AD1
CD1
RCODE4
QDCOUNT16
ANCOUNT16
NSCOUNT16
ARCOUNT16

对照 dig 输出:

  • id: 41870 → ID,客户端随机生成,用来匹配请求和响应;
  • flags: qr rd ra:
    • QR=1:这是响应(请求为 0);
    • RD=1:客户端希望递归查询(Recursion Desired);
    • RA=1:服务器支持递归(Recursion Available);
    • 没有 AA,说明这不是权威服务器给的答案,而是递归解析器的缓存或转发结果;
  • status: NOERROR → RCODE=0;常见的还有 NXDOMAIN(3)、SERVFAIL(2)、REFUSED(5);
  • QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 → 四个计数字段。

Z 位的历史

RFC 1035 中 Z 是 3 个保留位。后来 DNSSEC(RFC 4035)从中拿走了两位:AD(Authentic Data)和 CD(Checking Disabled),所以现在只剩 1 位 Z。看老资料时注意区分。

Question:域名是怎么编码的

域名不是以点分隔的字符串,而是一串“长度 + 内容”的标签,以 0 结尾:

www.example.com  →
03 77 77 77 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00
 3  w  w  w  7  e  x  a  m  p  l  e  3  c  o  m  .

后面紧跟 2 字节 QTYPE(A=1、NS=2、CNAME=5、MX=15、TXT=16、AAAA=28)和 2 字节 QCLASS(IN=1)。

所以这个问题区共 17 + 4 = 21 字节。

Answer:资源记录与压缩指针

每条资源记录(RR)的格式是:

HDRResource Record
0123456789101112131415
NAME(可变长,常为压缩指针)16
TYPE16
CLASS16
TTL32
RDLENGTH16
RDATA(长度由 RDLENGTH 决定)

如果每条记录都完整写一遍域名,报文会很浪费。DNS 用压缩指针解决:当一个 16 位值的最高两位是 11 时,剩下 14 位表示“这个名字在报文中的偏移量”。

最常见的指针是 c0 0c:

0xC00C = 1100 0000 0000 1100
         ^^ 指针标志   偏移 = 0x0C = 12

偏移 12 正好是紧跟在 12 字节头部之后的位置,也就是问题区里的第一个域名。所以 Answer 中的 NAME 只需要 2 字节。

于是这条 A 记录的长度是:2(NAME)+ 2(TYPE)+ 2(CLASS)+ 4(TTL)+ 2(RDLENGTH)+ 4(IPv4 地址)= 16 字节。

验证一下报文长度

dig 显示 MSG SIZE rcvd: 60,我们来算算:

部分字节数
Header12
Question(域名 17 + 类型 2 + 类 2)21
Answer(A 记录)16
Additional(EDNS OPT 伪记录:根域名 1 + 类型 2 + 类 2 + TTL 4 + 长度 2)11
合计60 ✓

能把报文长度一字节不差地算出来,说明格式真的看懂了。

一次完整的解析过程

dig 问的只是本地的递归解析器。解析器背后还要做一系列迭代查询:

SEQ递归 + 迭代解析(缓存为空时)
Client Resolver Root TLD Auth www.example.com A?(RD=1) www.example.com A? 去问 .com 的 NS(referral) www.example.com A? 去问 example.com 的 NS www.example.com A? A 203.0.113.80(AA=1) A 203.0.113.80(RA=1)

用 Scapy 自己构造一个查询

from scapy.all import IP, UDP, DNS, DNSQR, sr1

q = IP(dst="192.0.2.53") / UDP(dport=53) / DNS(rd=1, qd=DNSQR(qname="www.example.com", qtype="A"))
ans = sr1(q, timeout=2, verbose=0)
if ans:
    ans[DNS].show()

Wireshark 过滤器

dns                                   # 所有 DNS
dns.flags.response == 0               # 只看查询
dns.qry.name == "www.example.com"     # 指定域名
dns.flags.rcode != 0                  # 出错的响应
dns.qry.type == 28                    # AAAA 查询

小结

  • DNS 默认走 UDP 53;响应被截断(TC=1)时会改用 TCP 重试,区域传送(AXFR)也走 TCP;
  • 头部固定 12 字节,四个计数字段决定后面有几条记录;
  • 域名用“长度 + 标签”编码,靠 0xC0 开头的压缩指针复用;
  • 现代 DNS 几乎都带 EDNS(0) 的 OPT 伪记录,用来声明更大的 UDP 报文尺寸等能力。

下一步想看看 DoH / DoT 加密之后,流量里还能看出什么。

↑↓ 选择 · Enter 打开 · Esc 关闭