~/posts/web/dns-beacon-fragment-reassembly-authentication.md

DNS Beacon:分片重组与密文认证

从配置和 XOR 控制值拆解 DNS Beacon,分别重组 TXT 下载与查询名上传,检查乱序、重复和缺片,再以 HMAC 与 AES 验证三段密文,明确完整数据和部分记录的边界。

date[31:24]
read[23:16]
7 分钟
cat[15:8]
Web 安全

AI 翻译,尚未人工审核

目录
  1. 0x00先从配置确定前缀
  2. 0x01只对控制值和长度做 XOR
  3. 0x02TXT 下载先声明长度,再取编码内容
  4. 0x03A 通道的已见片段不等于完整任务
  5. 0x04上传标签数量不属于十六进制载荷
  6. 0x05认证通过后,再解释明文结构
  7. 0x06按批次维护证据账本
  8. 0x07官方参考

DNS Beacon 分析的第一步不是寻找 AES 参数,而是确定哪些字节属于同一次传输。轮询控制值、长度声明、片段编号和密文都可能表现为查询标签或 IP 形式的回答,解析错一层,就会把正确密钥用在错误输入上。

下面沿 2021 年比赛记录中的 DNS 通信,分别重组任务下载和结果上传。所有查询域名统一替换为 beacon.example;8.8.4.4 等回答值作为协议数据保留,不作为连接目标。验证仅针对已记录字节与候选密钥离线进行,没有运行 Beacon,也没有持有完整 PCAP 或进程内存转储。

先从配置确定前缀

Selected sample configurationjson
{
"transport": "DNS Beacon",
"domain": "beacon.example",
"DNS_Idle": "8.8.4.4",
"DNS_Sleep": 10000,
"maxdns": 245,
"BeaconID": "19997cf2"
}
对象 · 6 个键 · 151 B

这是相关字段摘要,域名为文档占位。记录中的 payload 类型明确为 DNS Beacon;配置里还存在 HTTP 字段,不代表当前流量应该按 HTTP 拆解。BeaconID 用于关联运行实例,不应当成二进制文件永久不变的身份。

该实例的标签用途6 行
配置字段 前缀 承载方向
DNS_beacon 空 轮询任务状态
DNS_A cdn A 回答下载任务
DNS_AAAA www6 AAAA 回答下载任务
DNS_TXT api TXT 回答下载任务
DNS_metadata www 查询名上传元数据
DNS_output post 查询名上传输出

供应商在 4.3 中加入了 DNS 子域前缀覆盖选项,因此这些名字是配置解释线索,不是跨所有样本成立的检测签名。DNS_Idle 是空闲回答及其他控制值的掩码,DNS_resolver 才是另一类解析器配置;两者不应混用。样本精确版本未独立确认。

只对控制值和长度做 XOR

将 A 回答与 idle 值按四字节整数 XOR:

控制与长度的计算4 行
回答值 XOR 8.8.4.4 后 在所在阶段的含义
8.8.4.4 0 无任务
8.8.4.246 0xF2 使用 TXT 任务通道
8.8.4.68 64 本轮任务封装长度
8.8.4.116 112 另一轮 A 通道封装长度
DNS control — 0xF28 位
family0xFmode1checkin
值0xF2= 242
位字段值
7–4family0xF (15)
3–1mode0x1
0checkin0

在该控制族中,0xF0/0xF1、0xF2/0xF3、0xF4/0xF5 分别选择 A、TXT、AAAA,相邻奇数值额外要求 check-in。位域组件中的 mode 已右移一位,显示值 1 对应原始掩码 0x02;0xF2 的 bit 0 为零,不要求本轮先上传元数据。

长度回答要放回传输阶段解释,而不是再次当作控制模式分派。载荷 A 回答中的四个八位组已经是密文字节,对它们重复 XOR 会破坏密文。

TXT 下载先声明长度,再取编码内容

TXT transfer — domain replacedlog
Q A    api.07311917.19997cf2.beacon.example
R A    8.8.4.68
Q TXT  api.17311917.19997cf2.beacon.example
R TXT  ZUZBozZmBi10KvISBcqS0nxp32b7h6WxUBw4n70cOLP13eN7PgcnUVOWdO+tDCbeElzdrp0b0N5DIEhB7eQ9Yg==

07311917 与 17311917 属于同一批次:起始计数为零,后续计数推进,传输标识保持关联。长度查询仍使用 A 类型;收到 64 后,才用 TXT 查询取得 Base64 内容。该回答解码后正好为 64 字节。

多片段时,先按批次和数值计数器排序,保留每条 TXT 的字符串边界,再依据对应封装拼接和解码。所核对解析器按序拼接该通道的编码内容后解码;不要把任意 DNS TXT 记录都当成同一种分片协议,也不要按抓包显示时间混合不同实例的数据。

A 通道的已见片段不等于完整任务

First A data fragment
000000001340F059

19.64.240.89 在这个位置编码 13 40 F0 59,不是 Beacon 下一步要连接的主机。A 回答每条贡献 4 字节,AAAA 回答每条贡献 16 字节。首条声明长度为 112,所以完整 A 通道数据应相当于 28 个四字节片段。

现有摘录只列出计数 1 到 a 的十条载荷回答,共 40 字节,距离声明长度还差 72 字节。这组 A 数据保持“不完整”状态,不补零、不复制尾部,也不据此报告成功解密 112 字节任务。

若另一实例最后一个地址槽位带填充,应依据该实例的长度和封装规则处理。这个 112 字节声明整除四,本身不提供尾部填充行为的证据。

上传标签数量不属于十六进制载荷

post.140.09842910.19997cf2.beacon.example 的 140 应拆成 1 | 40:一位标签数量,加十六进制长度 0x40 = 64。把整体当成 0x140,声明长度就会错误。

一次上传的三个查询3 行
传输标签 数据标签数 数据解释
09842910 1 40,声明 64 字节
19842910 2 首标签去掉数量位,与第二标签拼接,得到 56 字节
29842910 1 debfa06ab4786477,得到最后 8 字节

每个 DNS label 最多 63 个八位组,长十六进制串需要拆分;ASCII 十六进制下,一个字符就是一个八位组。实际可用长度还受完整 DNS 名称长度与配置 maxdns 约束,并非任意堆叠标签都有效。

计数器、Beacon ID 与域名后缀都不属于密文。下面的严格模型只接收已识别的数据标签,并对乱序、重复、冲突和长度分别检查,避免“去掉所有点号再解码”的错误。

dns-reassembly-model.pypython
import base64
from ipaddress import IPv4Address

def xor_value(answer, idle="8.8.4.4"):
    return int(IPv4Address(answer)) ^ int(IPv4Address(idle))

def counted_labels(labels):
    if not labels or not labels[0] or labels[0][0] not in "123456789":
        raise ValueError("invalid label count")
    count = int(labels[0][0])
    if len(labels) != count:
        raise ValueError("label count mismatch")
    return labels[0][1:] + "".join(labels[1:])

def assemble(parts, expected):
    unique = {}
    for counter, data in parts:
        if counter in unique and unique[counter] != data:
            raise ValueError("conflicting duplicate")
        unique[counter] = data
    if sorted(unique) != list(range(1, len(unique) + 1)):
        raise ValueError("missing sequence")
    result = b"".join(unique[n] for n in sorted(unique))
    if len(result) != expected:
        raise ValueError("length mismatch")
    return result

assert xor_value("8.8.4.4") == 0
assert xor_value("8.8.4.246") == 0xF2
assert xor_value("8.8.4.68") == 64
assert xor_value("8.8.4.116") == 112
txt = "ZUZBozZmBi10KvISBcqS0nxp32b7h6WxUBw4n70cOLP13eN7PgcnUVOWdO+tDCbeElzdrp0b0N5DIEhB7eQ9Yg=="
download = base64.b64decode(txt, validate=True)
assert len(download) == 64
first = [
    "2942880f933a45cf2d048b0c14917493df0cd10a0de26ea103d0eb1b3",
    "4adf28c63a97deb5cbe4e20b26902d1ef427957323967835f7d18a42",
]
last = ["1debfa06ab4786477"]
a = bytes.fromhex(counted_labels(first))
b = bytes.fromhex(counted_labels(last))
expected = int(counted_labels(["140"]), 16)
upload = assemble([(2, b), (1, a), (1, a)], expected)
assert (len(a), len(b), len(upload)) == (56, 8, 64)
assert upload != download
addresses = [
    "19.64.240.89", "241.225.135.56", "127.132.170.127",
    "87.30.231.4", "97.156.155.27", "253.162.241.39",
    "61.217.211.72", "154.197.14.224", "211.139.207.53",
    "150.38.89.208",
]
partial = b"".join(IPv4Address(x).packed for x in addresses)
assert len(partial) == 40 and 112 - len(partial) == 72
for parts, length in (
    ([(1, a), (1, b)], 64),
    ([(2, b)], 8),
    ([(1, a)], 64),
):
    try:
        assemble(parts, length)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid input accepted")
print("PASS: 4 XOR cases; TXT64; output56+8; 40/112 A bytes; reorder/dedup; 3 rejected cases")

模型通过 4 组 XOR、TXT 64 字节、上传 56+8 字节及 A 通道 40/112 字节检查。相同片段重复出现会去重,同序号不同内容会报冲突;缺序号或长度不足也会报错。TXT 下载与结果上传恰好都长 64 字节,不代表二者内容相同。

认证通过后,再解释明文结构

历史分析使用 Didier Stevens 的 cs-parse-traffic.py 与 cs-extract-key.py。前者的未知密钥模式只提取封装,后者结合内存材料寻找候选;只在内存里发现 16 个字节,尚不足以证明那就是有效密钥。

另一条 48 字节封装与前一批上传分开记录:

Separate output envelope — 48 bytes
00000000A4E8282A67C7EC1E608C43A040DE4934
000000109C26CF5770907BDCDE06D79A32FA7DD1
0000002051A4343AD495215F265A7E396DB1CC28
  1. ciphertext0x00–0x1F
  2. authentication_tag0x20–0x2F

对已给出的三段封装,可独立进行 HMAC 与 AES 校验。实现只接受这一实例对应的格式:尾部 16 字节为截断 HMAC-SHA256,认证输入是前面的密文,AES-128-CBC 使用固定 IV abcdefghijklmnop。先验证 HMAC,再解密;不将默认 PKCS#7 去填充硬套到其业务封装。

dns-verify.mjsjavascript
import {createHmac, createDecipheriv, timingSafeEqual} from "node:crypto";

export function verifyAndDecrypt(blob, aesKey, hmacKey) {
  if (aesKey.length !== 16 || hmacKey.length !== 16)
    throw new Error("expected sample-specific 16-byte keys");
  if (blob.length < 32 || (blob.length - 16) % 16 !== 0)
    throw new Error("invalid encrypted envelope length");
  const cipher = blob.subarray(0, -16);
  const tag = blob.subarray(-16);
  const expected = createHmac("sha256", hmacKey)
    .update(cipher).digest().subarray(0, 16);
  if (!timingSafeEqual(tag, expected))
    throw new Error("HMAC mismatch");
  const decipher = createDecipheriv(
    "aes-128-cbc", aesKey, Buffer.from("abcdefghijklmnop"));
  decipher.setAutoPadding(false);
  return Buffer.concat([decipher.update(cipher), decipher.final()]);
}
本次离线核验结果3 行
输入 总长 / CBC 长度 HMAC 解密后的结构字段
TXT 下载 64 / 48 通过 长度字段 37,首命令字段 78
查询名上传 64 / 48 通过 counter 2,长度字段 25,callback 30
另一条上传 48 / 32 通过 counter 9,长度字段 12,callback 30

候选密钥来自所给记录,不是从 DNS 本身推导;公开内容不保留其值。业务字段按相应格式的大端顺序核对,涉及主机身份的明文不展示。HMAC 通过证明该候选与这些密文相容,不证明完整抓包、全部回调或其他运行实例已被验证。

另一条 240 字节输出记录出现 Solidity 代码和题目字符串,但缺少对应完整密文,因此不将它列入上述独立解密结果。区分“记录中已有的结果”与“实际重新计算的结果”,比只展示一行可读明文更重要。

按批次维护证据账本

重组记录至少保存的字段5 行
字段组 作用
Beacon ID、方向、前缀、传输标识 防止不同下载与上传混合
原帧号、查询名、DNS 类型、原始回答 保留回到输入记录的路径
声明长度、计数器、去重结果 发现缺片与冲突
密文长度、标签校验、业务结构 区分传输错误、密钥错误与解析错误
密钥材料来源与运行实例关联 避免把其他进程的候选错用到当前流量

长度不符时先查分组、序号和捕获完整性;长度正确但 HMAC 不符时,再查候选密钥与封装边界。只有局部文本可读而整体结构不成立,也不应视为解密成功。

这条流程的输出不是一段尽可能长的十六进制,而是可以逐层复核的字节记录:配置解释标签、批次确定顺序、长度限定有效数据、认证约束解密输入。任何层的证据不足,都应在该层停止推断。

官方参考

供应商文档核对 DNS 配置含义,RFC 核对 DNS 标签与记录格式;样本密文字节与控制值另按实际记录验证。

NORMAL~/posts/web/dns-beacon-fragment-reassembly-authentication.md§--
0%zh-CN