朕喜技术(ZhenxiJS)
2026.09.15
1 分钟阅读

UDP 大响应被静默截断:DNS 超过接收缓冲区的响应收到半截报文(dig timed out / DNS message is malformed),recvfrom 缓冲区与 EDNS0/TC 位排查

日期:2026-09-15 | 分支:2026-09-15-1600 | 期数:第58题 技术栈:Python 3.11 / dnspython 2.6.1 / UDP socket / DNS / EDNS0(RFC 6891) / Docker Compose / dig(BIND 9.20) 关键词:UDP 截断、recvfrom 缓冲区太小、DNS message is malformed、dig timed out、ID mismatch、MSG_TRUNC、MSG_PEEK、EDNS0、TC 位、options edns0、DNS 响应超过 512 字节、UDP 数据报被丢弃、SO_RCVBUF、dnspython from_wire 报错

一、问题背景

内网自建权威 DNS(UDP 5354),部分记录很大(资产清单 TXT,20 段 x 250 字节 ≈ 5KB)。业务侧的 Python 查询代码取不到数据:

{
  "ok": false,
  "wire_bytes_received": 4096,
  "client_recv_buffer": 4096,
  "error": "MALFORMED",
  "detail": "DNS message is malformed."
}

换成手工 dig 验证,现象更迷惑 —— 看起来像「服务器根本没响应」:

;; Warning: ID mismatch: expected ID 46924, got 4660
;; communications error to <ns>#5354: timed out
;; no servers could be reached

但 docker compose logs dns-authority 里清清楚楚:

[srv] 已发送 5080 字节 UDP 响应

同一个服务端,查短记录一切正常。第一反应都会去怀疑服务端 —— 方向就错了。

二、复现步骤

环境自包含、可离线跑(服务端响应在构建时生成,不依赖外网)。

cd scenario/2026-09-15-1600
docker compose up -d --build

# 1) 业务客户端:复现 MALFORMED
docker compose exec -e INTERNAL_DNS_HOST=dns-authority app-client \
    python -u internal_lookup.py big.example.com

# 2) 缓冲区 <-> 收到字节数探针(最有信息量的一步)
docker compose exec -e INTERNAL_DNS_HOST=dns-authority app-client \
    python -u show_size.py

# 3) 断言脚本(复现期期望 PASS=0 FAIL=5)
docker compose exec -e INTERNAL_DNS_HOST=dns-authority app-client \
    python -u check_bad.py --expect-broken

# 4) 真实 dig 交叉验证
docker compose exec app-client dig +tries=1 +timeout=3 +bufsize=8192 \
    @dns-authority -p 5354 big.example.com TXT

复现结果(关键输出):

=== show_size.py 探针 ===
服务端 dns-authority:5354 的完整响应 = 5080 字节(20 段 x 250 字节 TXT)
recvfrom(512  ) 实际收到   512 字节 | 解析失败: DNS message is malformed.
recvfrom(4096 ) 实际收到  4096 字节 | 解析失败: DNS message is malformed.
recvfrom(8192 ) 实际收到  5080 字节 | 解析成功
recvfrom(65535) 实际收到  5080 字节 | 解析成功

=== dig ===
;; Warning: ID mismatch: expected ID 46924, got 4660
;; communications error to 172.19.0.2#5354: timed out
;; no servers could be reached

=== 断言 ===
A. 客户端能收到完整响应且解析成功            FAIL
B. 收到的字节数 == 服务端发送的 5080 字节     FAIL
C. 响应未截断(字节数 != 客户端接收缓冲区)    FAIL
D. 解析不再报 malformed / 半截报文           FAIL
E. 真实 dig 也能拿到同一份响应                FAIL
PASS=0 FAIL=5

两个立即可用的指纹:

  1. 收到的字节数恰好等于缓冲区大小(4096)—— 这不是巧合。
  2. dig 报 ID mismatch —— 说明 dig 收到了报文,只是拒收。

三、根本原因

根因 1(主因):UDP 数据报被内核静默截断,客户端把半截报文当完整的解析

错误代码:

CLIENT_RECV_BUFFER = int(os.environ.get("CLIENT_RECV_BUFFER", "4096"))
data = sock.recvfrom(CLIENT_RECV_BUFFER)[0]   # ← 病根

recvfrom(n) 的语义是「最多拷贝 n 字节」。当数据报(5080)> n(4096)时,Linux 的 udp_recvmsg 只拷贝前 n 字节,剩余部分直接丢弃(skb 随读随释放), 并只把 MSG_TRUNC 标志放进辅助数据 —— recvfrom 默认看不到这个标志,也不抛异常。

于是:

  • 调用方以为拿到了完整报文,其实少了 5080 − 4096 = 984 字节。
  • DNS 报文头里声明的 rdata 长度 > 实际字节数,dnspython 的 from_wire 抛 DNS message is malformed.
  • 报错信息把矛头指向「报文格式」,完全掩盖了「数据被内核丢了」这个事实。

铁证(用 recvmsg 才能看到标志):

docker compose exec app-client python3 -c '
import socket, dns.message, dns.rdatatype
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(3)
q=dns.message.make_query("big.example.com",dns.rdatatype.TXT).to_wire()
s.sendto(q,("dns-authority",5354))
data, anc, flags, addr = s.recvmsg(4096, 0)
print("recvmsg(4096) 返回长度 =", len(data), "MSG_TRUNC =", bool(flags & socket.MSG_TRUNC))
'
recvmsg(4096) 返回长度 = 4096 MSG_TRUNC = True

MSG_TRUNC = True —— 内核明确说了「这个数据报被截断了」。

为什么字节数恰好是 4096? 因为 UDP 是面向数据报的,不是字节流(这点和 TCP 完全相反):

TCPUDP
语义字节流数据报(一个 sendto = 一个 recvfrom,边界由内核维护)
缓冲区太小只分几次读完,一个字节都不丢多余部分直接丢弃,且默认不报错
表现recv 返回部分数据是正常的,继续读即可返回长度精确 == 缓冲区大小,数据永久丢失

所以 len(recv结果) == 缓冲区大小 就是截断的定义式指纹。

根因 2(独立 bug,被 dig 的超时暴露):服务端响应不回显查询 ID

修复根因 1 之后,dig 依然超时。继续查发现:

docker compose exec app-client python3 -c '
import socket, dns.message, dns.rdatatype
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.settimeout(3)
q=dns.message.make_query("big.example.com",dns.rdatatype.TXT); q.id=61295
s.sendto(q.to_wire(),("dns-authority",5354))
d=s.recv(65535)
print("我方发出 ID =",q.id," 服务端回 ID =",int.from_bytes(d[:2],"big"))
'
我方发出 ID = 61295  服务端回 ID = 4660 =0x1234

服务端无论问什么都回同一个预置报文,头部 ID 写死 0x1234。 RFC 1035 要求响应必须回显查询的 ID。dig 校验 ID 不通过就把响应丢弃(;; Warning: ID mismatch),继续等一个 ID 对得上的响应,直到 +timeout 耗尽 → 报 timed out / no servers could be reached。

这个 bug 和小响应无关,是全局的 —— 查任意记录都超时:

for n in a.example.com short.example.com big.example.com; do
  docker compose exec app-client dig +tries=1 +timeout=2 @dns-authority -p 5354 $n TXT 2>&1 | grep -E "ID mismatch|timed out"
done
# 三个都 ID mismatch + timed out

这也是排查中最容易漏掉的地方:两个 bug 叠在一起,一个的现象盖住了另一个。 "改大缓冲区就能过"只修好了前半段,dig 照样失败,很容易被误判为"方案无效"。

澄清一个常见误解:TC 位在这题里不是主角

很多人(包括题目提示)会往 TC 位想。但实际报文头是 12348180:

ID=0x1234, FLAGS=0x8180 = QR=1 AA=1 RD=1 RA=0 TC=0 RCODE=0
TC = 0

服务端认为它成功发了个完整应答,TC 位根本没置起。 "被截断的 UDP 载荷会让客户端原地等超时"这个说法其实不准确 —— 真正的因果是「响应被 ID 校验丢弃 → 客户端没拿到合法响应 → 等超时」。 而"截断"影响的是另一条路径(业务客户端):它不校验 ID 就解析,于是撞上 malformed。

四、解决办法

4.1 客户端核心修复:动态分配缓冲,不猜魔法数字

DNS_UDP_MAX = 65535
DNS_UDP_CLASSIC = 512
EDNS_UDP_SIZE = int(os.environ.get("CLIENT_EDNS_UDP_SIZE", "1232"))

class TruncatedResponse(Exception):
    """收到的 UDP 数据报被内核截断(缓冲区小于数据报真实长度)。"""

def _recv_full(sock: socket.socket) -> bytes:
    """收一个完整的 UDP 数据报,缓冲区按数据报真实长度动态分配。"""
    # 阶段1:窥探真实长度(数据报**不消费**,仍留在内核接收队列里)
    peeked = sock.recv(1, socket.MSG_PEEK | socket.MSG_TRUNC)
    real_size = len(peeked)
    if real_size <= 0:
        raise TruncatedResponse("窥探到 0 字节,数据报为空")
    # 阶段2:按真实长度分配,绝不截断
    want = min(max(real_size, DNS_UDP_CLASSIC), DNS_UDP_MAX)
    data, _anc, flags, _addr = sock.recvmsg(want, 0)
    if flags & socket.MSG_TRUNC:      # 兜底:报可识别错误,而不是 malformed
        raise TruncatedResponse(
            "UDP 数据报被截断:真实长度 %d 字节,缓冲区只有 %d 字节" % (real_size, want))
    return data

配合查询里开 EDNS0:

query = dns.message.make_query(name, dns.rdatatype.from_text(rdtype))
query.use_edns(0, 0, EDNS_UDP_SIZE)   # 公告"我能收 1232 字节以上"

三个设计要点:

  1. MSG_PEEK | MSG_TRUNC 拿真实长度,再按需分配 —— 这是唯一不依赖"猜一个够大的数字"的做法。
  2. 校验 MSG_TRUNC:万一还是被截断,报 TRUNCATED 而不是把半截字节流喂给解析器产出误导人的 malformed。
  3. 开 EDNS0:让服务端知道你的接收能力,别因为"可能超 512"就回 TC 位。

4.2 服务端修复:响应回显查询 ID

# 预置报文 ID 写死 0x1234;RFC 1035 要求响应回显查询 ID
payload = data[:2] + payload[2:]

4.3 EDNS0 到底解决了什么、没解决什么(重要区分)

问题归谁管
「协商多大」——服务端该不该发大包 / 该不该置 TC 位EDNS0(/etc/resolv.conf 里 options edns0 就是这个)
「收不收得全」——数据报到了本机,缓冲区够不够接收方缓冲区(recv 尺寸 / SO_RCVBUF)

两者正交,都要有。 只开 EDNS0 不修缓冲区:服务端放心地发大包,你照样截断。 只修缓冲区不开 EDNS0:合规服务端可能按 512 字节上限直接回 TC 位(要求走 TCP),你还是拿不到数据。

4.4 线上自查工具

# 方法 A(最快):字节数恰好等于缓冲区大小 -> 疑似截断
n = len(sock.recvfrom(BUF)[0])
if n == BUF:
    log.warning("DNS 响应字节数 == 缓冲区大小,疑似 UDP 截断")

# 方法 B(最准):校验 MSG_TRUNC 标志
data, _, flags, _ = sock.recvmsg(BUF, 0)
if flags & socket.MSG_TRUNC:
    raise RuntimeError("UDP 数据报被截断,请用 MSG_PEEK|MSG_TRUNC 动态分配缓冲")

配套脚本 app/audit_udp_buffer.py(本次新增):

docker compose exec app-client python -u audit_udp_buffer.py dns-authority 5354 big.example.com
数据报真实长度 = 5080 字节
超过的常见写死缓冲区:512, 1232, 4096
结论:有风险!凡是 recvfrom(4096) 这种固定尺寸的客户端都会拿到恰好 4096 字节的半截报文,
     然后报 malformed 或静默丢数据。

退出码 1 = 有风险、0 = 安全,可以直接塞进巡检或 CI。

五、验证

docker compose down -v && docker compose up -d --build

1) 业务客户端 —— 故意仍传 CLIENT_RECV_BUFFER=4096:

{
  "client_recv_buffer": 4096,
  "edns_udp_size": 1232,
  "ok": true,
  "wire_bytes_received": 5080,
  "rcode": "NOERROR",
  "tc": false,
  "answers": [ "…20 段各 250 字节的 TXT…" ]
}

关键:环境变量仍是 4096,但 wire_bytes_received = 5080。 证明那个数字已不再是决定性因素 —— 这是"治本"而非"把数字改大"的证明。

2) 断言脚本:

A. 客户端能收到完整响应且解析成功            PASS
B. 收到的字节数 == 服务端发送的 5080 字节     PASS
C. 响应未截断(字节数 != 客户端接收缓冲区)    PASS
D. 解析不再报 malformed / 半截报文           PASS
E. 真实 dig 也能拿到同一份响应                PASS
PASS=5 FAIL=0

3) dig 正常出结果:

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 13731
;; flags: qr rd ra; QUERY: 1, ANSWER: 1
;; ANSWER SECTION:
big.example.com.  60  IN  TXT  "aaaa…" "aaaa…"   ← 20 段

id: 13731 是随机 ID,说明 ID 回声已正确。

4) 回归测试 test_internal_lookup.py: 2 项全 PASS,exit=0。

5) A/B 对照(因果证明): 用原始 buggy 代码另起一套 compose(改名避免冲突), check_bad.py --expect-broken 得到 PASS=0 FAIL=5;修复后 PASS=5 FAIL=0。

6) 兜底检测生效: 用 4096 缓冲 recvmsg 仍能识别 MSG_TRUNC=True → 报可识别的 TRUNCATED,而不是 malformed。

六、经验与避坑

可复用的排查思路

  1. 「收到的字节数恰好等于缓冲区大小」是 UDP 截断的头号指纹。 见到这个等式,先怀疑截断,别先怀疑对端。
  2. TCP 经验会骗你。 TCP 缓冲区小只是"分几次读",UDP 是真的丢且默认不吭声。 把 TCP 的直觉套到 UDP 上,就会得出"缓冲区小一点无所谓"的致命结论。
  3. 报错位置 ≠ 出错位置。 DNS message is malformed 看起来是"报文格式错", 实际是"数据在更底层被内核丢了"。看到一个解析类报错,先问"字节数够不够"。
  4. recvfrom 看不到 MSG_TRUNC,必须用 recvmsg。 这是最隐蔽的一环:API 明明有信息,但用的那个调用拿不到。
  5. 同一现象可能有多个叠加根因。 本题 dig 超时 = ID 不回显(对所有查询)+ 大响应。 用"换个输入试试"做区分实验:查短记录也超时 → 说明不是大响应的问题 → 揪出第二个 bug。
  6. 别急着改数字。 4096 改成 65535 能让断言变绿,但那是碰运气。 MSG_PEEK | MSG_TRUNC 动态分配才是"无论 40 字节还是 65535 字节都对"。

具体避坑点

坑说明
MSG_PEEK | MSG_TRUNC 要两步peek 只窥探不消费,数据报还在队列。必须再 recv 一次才真正读走
Python 里 recv(1, MSG_PEEK|MSG_TRUNC) 返回 bytes不是 C 那样的 int。用 len() 取长度(长度即真实长度)
+bufsize=8192 只影响 dig 自己管不到服务端的 ID 行为。修好一半不等于全好
容器里 strace 可能失败PTRACE_TRACEME: Operation not permitted(无 CAP_SYS_PTRACE)。改用 MSG_TRUNC 标志,证据更直接
沙箱里 tcpdump 可能失败You don't have permission to perform that capture(daemon 受限,--cap-add=NET_RAW 不生效)。改用「服务端日志说发了 N 字节 + 客户端 MSG_TRUNC 说被截断」双向夹逼
docker compose exec 要用 service 名不是 container_name。用错会报 is not running
改了 app/ 里的代码要重建镜像COPY app/ 在构建期发生,不 --build 不生效
别只看 SO_RCVBUF它默认很大(229376),那是内核收包缓冲,和 recvfrom(n) 的 n 是两回事

一句话总结

UDP 收到的字节数 == 你的缓冲区大小,就说明数据被内核丢了,而不是"响应就那么大"。 先 MSG_PEEK | MSG_TRUNC 取真实长度,再按需分配;同时校验 MSG_TRUNC 把截断变成可识别错误。 EDNS0 负责"协商多大",缓冲区负责"收不收得全",两件事都要做。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-15-1600-udp-dns-dig-recfrom-msg-trunc-edns0

分享这篇文章