DNS Beacon 分析的第一步不是寻找 AES 参数,而是确定哪些字节属于同一次传输。轮询控制值、长度声明、片段编号和密文都可能表现为查询标签或 IP 形式的回答,解析错一层,就会把正确密钥用在错误输入上。
下面沿 2021 年比赛记录中的 DNS 通信,分别重组任务下载和结果上传。所有查询域名统一替换为 beacon.example;8.8.4.4 等回答值作为协议数据保留,不作为连接目标。验证仅针对已记录字节与候选密钥离线进行,没有运行 Beacon,也没有持有完整 PCAP 或进程内存转储。
先从配置确定前缀
这是相关字段摘要,域名为文档占位。记录中的 payload 类型明确为 DNS Beacon;配置里还存在 HTTP 字段,不代表当前流量应该按 HTTP 拆解。BeaconID 用于关联运行实例,不应当成二进制文件永久不变的身份。
| 配置字段 | 前缀 | 承载方向 |
|---|---|---|
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:
| 回答值 | 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 通道封装长度 |
| 位 | 字段 | 值 |
|---|---|---|
| 7–4 | family | 0xF (15) |
| 3–1 | mode | 0x1 |
| 0 | checkin | 0 |
在该控制族中,0xF0/0xF1、0xF2/0xF3、0xF4/0xF5 分别选择 A、TXT、AAAA,相邻奇数值额外要求 check-in。位域组件中的 mode 已右移一位,显示值 1 对应原始掩码 0x02;0xF2 的 bit 0 为零,不要求本轮先上传元数据。
长度回答要放回传输阶段解释,而不是再次当作控制模式分派。载荷 A 回答中的四个八位组已经是密文字节,对它们重复 XOR 会破坏密文。
TXT 下载先声明长度,再取编码内容
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 通道的已见片段不等于完整任务
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,声明长度就会错误。
| 传输标签 | 数据标签数 | 数据解释 |
|---|---|---|
09842910 |
1 | 40,声明 64 字节 |
19842910 |
2 | 首标签去掉数量位,与第二标签拼接,得到 56 字节 |
29842910 |
1 | debfa06ab4786477,得到最后 8 字节 |
每个 DNS label 最多 63 个八位组,长十六进制串需要拆分;ASCII 十六进制下,一个字符就是一个八位组。实际可用长度还受完整 DNS 名称长度与配置 maxdns 约束,并非任意堆叠标签都有效。
计数器、Beacon ID 与域名后缀都不属于密文。下面的严格模型只接收已识别的数据标签,并对乱序、重复、冲突和长度分别检查,避免“去掉所有点号再解码”的错误。
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 字节封装与前一批上传分开记录:
ciphertext0x00–0x1Fauthentication_tag0x20–0x2F
对已给出的三段封装,可独立进行 HMAC 与 AES 校验。实现只接受这一实例对应的格式:尾部 16 字节为截断 HMAC-SHA256,认证输入是前面的密文,AES-128-CBC 使用固定 IV abcdefghijklmnop。先验证 HMAC,再解密;不将默认 PKCS#7 去填充硬套到其业务封装。
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()]);
}| 输入 | 总长 / 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 代码和题目字符串,但缺少对应完整密文,因此不将它列入上述独立解密结果。区分“记录中已有的结果”与“实际重新计算的结果”,比只展示一行可读明文更重要。
按批次维护证据账本
| 字段组 | 作用 |
|---|---|
| Beacon ID、方向、前缀、传输标识 | 防止不同下载与上传混合 |
| 原帧号、查询名、DNS 类型、原始回答 | 保留回到输入记录的路径 |
| 声明长度、计数器、去重结果 | 发现缺片与冲突 |
| 密文长度、标签校验、业务结构 | 区分传输错误、密钥错误与解析错误 |
| 密钥材料来源与运行实例关联 | 避免把其他进程的候选错用到当前流量 |
长度不符时先查分组、序号和捕获完整性;长度正确但 HMAC 不符时,再查候选密钥与封装边界。只有局部文本可读而整体结构不成立,也不应视为解密成功。
这条流程的输出不是一段尽可能长的十六进制,而是可以逐层复核的字节记录:配置解释标签、批次确定顺序、长度限定有效数据、认证约束解密输入。任何层的证据不足,都应在该层停止推断。
官方参考
供应商文档核对 DNS 配置含义,RFC 核对 DNS 标签与记录格式;样本密文字节与控制值另按实际记录验证。