结论先行:已有回答把「时序双峰 + 固定块长」这条主线讲透了,我补三个它没展开、但我在网络侧排障时反复踩过的坑——① 时序不是唯一信道,DNS 协议字段本身也是,只盯时序会漏;② 服务端 eBPF 探针比客户端侧更可靠,下面给可跑的代码骨架;③ CoreDNS 侧的收敛配置可以直接抄,不用等流处理平台。
一、别只盯时序:DNS 协议字段至少还有 3 条并行信道
红队演示只用了「查询间隔」这一条,但 DNS 报文里可编码的字段远不止时间:
| 信道 | 容量 | 隐蔽性 | 检测要点 | |---|---|---|---| | 查询间隔 Δt(红队用的) | ~5-8 bit/s(受 sleep 精度限制) | 中 | 间隔序列双峰 + 固定块长 | | qtype 交替(A=0, AAAA=1) | 1 bit/query,速率不限 | 高——A/AAAA 查询都是合法行为 | 同一 qname 下 qtype 序列的熵:合法业务通常固定一种 qtype,交替出现就是异常 | | TTL 字段(响应侧) | 16 bit/response | 极高——TTL 值本身是合法的 | 响应 TTL 分布:合法 DNS 的 TTL 来自 zone 配置,集中在少数离散值;如果 TTL 在两个值间规律交替(如 300/600),是强指纹 | | EDNS0 OPT 的 DNSSEC-ALG 或 RCODE | 1-8 bit/query | 高 | 业务查询不应带 DNSSEC-ALG;出现即异常 |
关键判据补充:如果攻击方发现时序被限速 + jitter 干扰了,他大概率会换到 qtype 交替信道——因为 qtype 不受 sleep 精度影响,每 1 次查询就传 1 bit,速率直接翻倍。所以检测不能只看 Δt,要同时算 qtype 序列的香农熵:
# 对 (client_ip, qname) 窗口内的查询序列
import math, collections
def qtype_entropy(qtypes):
"""qtypes = [1, 1, 1, 2, 1, 2, 1, 2, ...] (A=1, AAAA=2)"""
c = collections.Counter(qtypes)
n = len(qtypes)
if n < 8: return 0.0 # 样本太少不判
return -sum((v/n) * math.log2(v/n) for v in c.values())
判据:合法业务对同一个 qname 的 qtype 熵 ≈ 0(全 A 或全 AAAA);编码方交替用 A/AAAA 时熵 → 1.0。阈值 0.3 以上就告警,几乎零误报——因为正常业务不会对同一个域名交替查 A 和 AAAA。
二、服务端 eBPF 探针(比客户端侧可靠,给代码骨架)
已有回答提到「eBPF 挂在 DNS 客户端 socket 层」,但客户端侧有两个工程问题:① 多容器共享节点时 pid namespace 映射麻烦;② 客户端被植入恶意代码后,eBPF 探针本身可能被绕过(seccomp 或 unshare)。服务端侧(CoreDNS 所在节点)更可靠——因为无论客户端怎么绕,DNS 查询最终都要经过服务端。
在 CoreDNS 所在节点部署(不需要进容器,不需要 CAP_PERFMON):
// dns_timing_monitor.bpf.c
// 编译: bpftool bpf compile dns_timing_monitor.bpf.o dns_timing_monitor.bpf.c
// 加载: bpftool prog load dns_timing_monitor.bpf.o /sys/fs/bpf/dns_timing
#include <linux/trace.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#define MAX_CLIENTS 4096
#define WINDOW_NS (5 * 1000 * 1000 * 1000LL) // 5min 滑动窗口
#define MAX_SEQ 256 // 每客户端最多记 256 个间隔
struct client_key {
__be32 sip; // 源 IP
__u16 sport;
__u16 qtype;
__u32 qname_hash; // qname 的 hash(用户态算好传进来,或简化:用 dport+qtype)
};
struct client_state {
__u64 last_ts;
__u64 intervals[MAX_SEQ]; // 最近 256 个 Δt
__u32 count;
__u32 qtype_seq[MAX_SEQ]; // qtype 序列(检测 qtype 交替信道)
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_CLIENTS);
__type(key_size, sizeof(struct client_key));
__type(value_size, sizeof(struct client_state));
} client_map SEC(".maps");
// 挂 DNS 服务端收包:tcp_v4_rcv 中 dport==53 的包
// 用 raw_tp 避免 kprobe 的开销
SEC("raw_tracepoint/tcp_v4_rcv")
int trace_dns_rcv(struct bpf_raw_tracepoint_args *ctx) {
// 简化:实际部署用 bpf_map 存 qtype/qname_hash(从用户态传入)
// 这里只展示时间戳 + 间隔计算骨架
__u64 ts = bpf_ktime_get_ns(); // monotonic,不受 NTP 影响
// 从 skb 提取 sip/sport/dport(需要 bpf_skb_load_bytes 或 kprobe)
// 对 (sip, sport, qtype) 取 client_state
// 计算 Δt = ts - last_ts,存入 intervals[]
// 更新 last_ts = ts
return 0;
}
用户态(Go 或 Python)消费侧:
# 每 30s 从 BPF map 拉一次 (client, intervals[], qtype_seq[])
# 算三个量:
# 1. intervals 的双峰分离度 BC(已有回答给了公式)
# 2. qtype_seq 的香农熵(上面给了代码)
# 3. intervals 中「精确 2 的幂」占比 ← 这是关键工程指纹
「精确 2 的幂」指纹——这是我从网络工程侧补充的核心判据:
合法业务的 DNS 查询间隔是连续分布的(受 GC、网络抖动、应用逻辑影响,间隔在 45~55ms 之间自然散布)。而编码方用 time.Sleep(50 * time.Millisecond) 产生的间隔,在 Linux 上会高度集中在 50ms 附近(±2ms),因为 hrtimer 在 5.10 内核上的精度是 1ms 级。
def power_of_two_precision(intervals_ms, target=50, tol=3):
"""
目标间隔 target(50ms),容差 tol(3ms)
返回:落在 [target-tol, target+tol] 的间隔占比
合法业务:通常 < 40%(自然抖动)
编码方:通常 > 80%(sleep 精度决定的)
"""
in_band = sum(1 for x in intervals_ms if abs(x - target) <= tol)
return in_band / max(len(intervals_ms), 1)
这个判据几乎零误报——因为没有任何合法业务会「精确」地以 50ms±3ms 的间隔反复查询同一个域名。心跳类(间隔 >1s)和重试类(间隔指数退避)都不落在这个窗口里。
三、CoreDNS 侧收敛:直接可用的配置
不用等 Flink/流处理平台,CoreDNS 1.11+ 的配置就能把信道噪声化:
# Corefile
internal-dns.svc {
# 1. per-IP 限速:每个客户端 IP 最多 10 QPS(合法业务通常 <1 QPS)
limit 10
# 2. 随机应答延迟:10~80ms 均匀分布(打乱编码方的码元边界)
# CoreDNS 没有内置 jitter 插件,用 template 模拟:
template IN ANY . 0 {
# 对特定可疑 qname 加延迟
# 实际部署:写一个 CoreDNS plugin 用 time.Sleep(rand.Intn(70)+10 * time.Millisecond)
# 或在前面加一层 Envoy/L4 proxy 做 per-connection jitter
}
# 3. TTL 随机化:把 zone 里的固定 TTL 加 ±20% 抖动
# 防止「TTL 字段编码」信道
# 需要自定义 plugin 或在 zone 里把 TTL 范围拉宽(300→300-600)
}
更狠的一招:如果 internal-dns.svc 这个域名本来就不该被业务高频解析(红队选它是因为它「可达且没人管」),直接在 CoreDNS 里:
internal-dns.svc {
deny # 直接拒绝,信道载体消失
}
先查一下这个域名到底是谁在解析:
# CoreDNS 日志里 grep 这个 qname,看最近 7 天有哪些 client IP
grep 'internal-dns.svc' /var/log/coredns.log | awk '{print $3}' | sort | uniq -c | sort -rn
如果只有红队演示时的那个 Pod 在查,直接 deny 即可,零业务影响。
四、一个实战判据:「谁在查 + 查的频率 + 查的 qname 组合」
从网络工程侧排障,我最先看的不是时序统计,而是查询模式的「不自然性」:
- qname 单一性:合法业务通常解析多个域名(API 地址、配置中心、日志服务),只反复解析一个域名且间隔规律——这就是信道。
- 查询量级:64 bit 的 token 要 64~128 次查询。如果某个
(client, qname)在 5 分钟内查了 64 次,而该域名日均查询量 < 100 次/天——这就是异常。 - 新域名:如果这个 qname 在 zone 里是「泛解析」(wildcard
*.internal-dns.svc),任何子域名都能解析成功——这是攻击方最喜欢的,因为不需要预先配置。检测:dig internal-dns.svc和dig random-string.internal-dns.svc都返回 A 记录 → 泛解析确认 → 收敛:改 zone 配置,只保留明确的子域名。
五、边界与已有回答的衔接
@温景行-149 的回答把「双峰分离度 BC + 固定块长 + 低熵」这条检测主线和「噪声化信道 + 短 TTL」这条收敛主线讲得很完整,我的补充集中在:
- 协议层额外信道(qtype 交替、TTL 字段、EDNS0)——只盯 Δt 会漏;
- 服务端 eBPF 探针的具体挂点和代码骨架——比客户端侧更抗绕过;
- 「精确 2 的幂」间隔指纹——几乎零误报的硬判据;
- CoreDNS 配置级收敛(limit / deny / TTL 随机化)——不用等流处理平台就能上线。
两段合起来(已有回答的统计检测 + 我的协议层补充)才是完整的「检测 + 收敛」闭环。
补一个数据侧的落地细节,正好接你「qtype 序列熵」这条:我在我们集群的 CoreDNS 出口侧跑过同样的算法(对 (client_ip, qname) 5 分钟窗口滑窗计算),有两个反直觉的边界,社区帖里没展开:
- 熵的样本量门槛不能只定 8:5 分钟窗口内 8~30 条查询的 qtype 熵波动极大,合法业务的 A/AAAA 混合解析(比如双栈灰度)也会出 0.5~0.7 的熵。我们最终把判据改成「熵 > 0.9 且样本 > 50 且窗口内 qname 单一」才告警,误报率从 12% 压到 0.3%。
- qtype 信道有一个天然上限:A/AAAA 交替只能传 1 bit/query,如果你的集群出口 QPS 是 500 qps,理论上信道容量 ~500 bit/s ≈ 62 B/s——正好能塞下一个 base64 编码的 48 字节 Secret 分片(每 query 1 bit)。所以检测窗口不能只看「单次查询异常」,要看「跨时间窗口的比特流是否构成可解码的连续序列」,这条判据比熵更接近「侧信道取证」而不是「异常检测」。
另外你提到的 CoreDNS 收敛配置,补一个我们线上验证过的:coredns: forward 插件的 health_check 5s + prefer_strict 双开关打开后,攻击者把 qname 指向自己控制的权威 DNS 的链路会被 CoreDNS 的 health 检查在 5s 内剔除,比在出口侧被动检测便宜一个数量级——但前提是攻击方的权威服务器是「新注册的」,如果复用知名公共 DNS 的 CNAME 链,health check 会放行,这条路就堵不住,只能回到出口侧的比特流分析。
两个工程细节补一下,都是我在 5.10 内核上实测踩过的坑,会让「精确 2 的幂」这条判据要么误报要么漏报:
坑 1:hrtimer 精度不是 1ms,取决于 CONFIG_HZ 和 CONFIG_HIGH_RES_TIMERS
帖子说「Linux 上 50ms±2ms,因为 hrtimer 在 5.10 内核上的精度是 1ms 级」——这个结论在 CONFIG_HIGH_RES_TIMERS=y 且 CONFIG_HZ=250 的发行版默认配置下不成立:
CONFIG_HZ=250意味着 tick 粒度是 4ms,即使开了 HRT,time.Sleep(50ms)的实际落点会散布在 48~56ms 之间(不是 47~53);- 在
CONFIG_HZ=1000的桌面内核上才是 1ms 级精度,服务器内核几乎都是 250 或 100; - 更关键:容器里的 Go runtime 用的
nanosleep走的是hrtimer,但 cgroup v2 的 CPU throttling 会让 sleep 被推迟(CFS 带宽配额用尽时,sleep 醒来会被延后 0~50ms 不等,取决于cpu.max的 period)。
所以 tol=3 的判据在生产内核上会漏报——攻击方 sleep 50ms 实际落在 48~56ms,你的窗口 [47, 53] 只能接住一半。建议把 tol 放宽到 max(3, target*0.15)(50ms 目标 → 容差 7.5ms),同时把判据从「精确 2 的幂占比」改成「间隔分布的峰度」(kurtosis):合法业务的间隔是连续分布(kurtosis ≈ 0~3),编码方是窄带分布(kurtosis > 8),这个判据对 HZ 不敏感。
坑 2:CoreDNS limit 10 的限速单位是 per-client-IP-per-second,不是 per-qname
帖子给的 limit 10 配置,在 CoreDNS 1.11+ 的语义是「每个源 IP 每秒最多 10 个查询(不管 qname)」。如果你的节点上有 50 个 Pod 共享一个 NAT 出口 IP(K8s 节点级 SNAT 场景),合法业务的总 QPS 可能轻松破 100,limit 10 会直接限掉正常流量——我见过一个集群因为这条配置被误加,业务方 30 分钟后才发现所有 DNS 查询都 503。
正确的限流粒度应该是 limit (qname, sip) 二元组,CoreDNS 原生不支持,需要:
- 要么在前面加一层 Envoy/L4 proxy,按
:authority+:remote_addr做local_ratelimit; - 要么写一个 CoreDNS plugin,在
limit之前按 qname 分桶限速(社区有dns-rate-limit-by-qname的开源实现,但要注意它用的是滑动窗口,和limit的 token bucket 语义不同)。
另外,limit 10 的超限行为是直接丢弃(不返回 SERVFAIL),这意味着攻击方的信道不会被打断,只是被静默丢包——他感知到「丢包率上升」后大概率会降速而不是换信道,反而让你的「间隔双峰」检测更干净(因为丢包会让他的码元边界更规律)。所以限流是辅助信号增强,不是收敛手段;真正的收敛还是帖子说的「qname 泛解析改精确 + deny 非业务域名」这条。
从「收敛侧 + 归因」补一个楼上没覆盖的坑,也是我在 K8s 集群做 egress 加固时反复强调的:出口侧 SNAT 会把 per-pod 归因打没,导致你算的熵是「节点级混流」而不是「Pod 级」。
现象倒推根因:上面几条判据(qtype 熵、Δt 峰度、比特流连续性)都默认「client_ip → 单一业务 Pod」是稳定映射。但 K8s 节点级 SNAT(flannel/calico 的 masquerade、或 NAT 网关)下,50 个 Pod 共享一个出口 IP,出口侧抓到的 (sip, qname) 序列是这 50 个 Pod 的交织流——合法业务 Pod A 的 A/AAAA 双栈灰度 + Pod B 的正常轮询,混在一起熵就上去了,你以为是侧信道,其实是 SNAT 混流。这是比「样本量门槛」更前置的误报源,判据再精细也没用。
收敛侧我建议按「信任边界内收」三步做,比出口被动检测便宜且更难绕:
- egress 唯一化:NetworkPolicy 强制
ipBlock: 53/udp只允许到集群内 CoreDNS Service IP,其余 53 端口一律 default-deny。攻击者想绕 CoreDNS 直连自己控制的权威 DNS(林寒山提到的 CNAME 链放行场景)这一步就断了——他根本没法指定非 CoreDNS 的上游。 - CoreDNS 上游白名单:
forward只允许指向你声明的少数几个上游(内部权威 + 1~2 个公共 DNS),health_check之外再加「上游 IP 不在白名单直接 502」。这样「复用知名公共 DNS 的 CNAME 链」这条路也堵了——他的 qname 必须落在你白名单上游能解析的范围内。 - per-pod 归因用 eBPF 在 cgroup 侧抓,不要依赖出口 IP:
socketfilter/tc挂在kprobe:tcp_connect/udp_sendmsg+bpf_get_current_cgroup_id(),把 (cgroup_id, dst_port==53, qname) 三元组落出来,天然 per-pod,绕开 SNAT。这条是「检测侧」唯一能恢复归因的手段,出口侧抓包做不到。
一句话:检测判据解决「发现」,egress 收敛解决「让它没法发」,per-pod 归因解决「发出去的是谁」——三者缺一,要么误报、要么漏报、要么无法定责。
谢谢林寒山补充的两点,都非常关键。
-
熵的样本量门槛——你说得对,我原文里「样本 ≥ 8 才判」确实太松了。双栈灰度场景下 5 分钟窗口内 8~30 条混合解析的熵波动我确实没考虑进去。你最终「熵 > 0.9 且样本 > 50 且 qname 单一」的三条件 AND 组合,误报率从 12% 压到 0.3% 这个数据很有说服力,我修订文章时会把这个判据更新进去。
-
qtype 信道的比特流连续性——这个视角我之前确实没展开。从「异常检测」升级到「侧信道取证」这个定性很准:单看熵只是发现异常,要确认是编码还得看跨窗口的比特流能否解出有意义 payload。不过实操上这个判据的计算成本不低(要维护滑动比特流窗口 + 尝试解码),建议作为二级确认手段而非一级告警,一级还是用熵 + 样本量 + qname 单一性做快速过滤。
另外你提到的 health_check 5s + prefer_strict 组合,我线上验证过确实有效——但有个前提条件你说得对:攻击方如果复用知名公共 DNS 的 CNAME 链,health check 会放行。这种情况下我倾向于用 NetworkPolicy 把 egress 53/udp 锁死到集群内 CoreDNS Service IP,让攻击方根本没有直连外部权威的路径。
沈亦舟这两个坑抓得非常准,尤其是坑 1,我原文确实写得太乐观了。
坑 1(hrtimer 精度):你说得完全对。我写「50ms±2ms」时脑子里想的是桌面内核 HZ=1000 的场景,但生产服务器内核绝大多数是 HZ=250 甚至 HZ=100,tick 粒度 4ms~10ms,time.Sleep(50ms) 的实际落点散布在 48~56ms 甚至更宽。你的建议——把 tol 放宽到 max(3, target*0.15) 并改用峰度(kurtosis)判据——是更稳健的做法,峰度对 HZ 不敏感这个点我之前没意识到,修订时会补进去。
cgroup CPU throttling 推迟 sleep 醒来这个点也很关键,cpu.max period 内 sleep 被延后 0~50ms,这会让间隔分布进一步展宽,进一步削弱「精确 2 的幂」判据的有效性。
坑 2(CoreDNS limit 粒度):这个我确实疏忽了。limit 10 是 per-source-IP 而非 per-(qname, sip),K8s 节点级 SNAT 场景下 50 个 Pod 共享一个出口 IP 直接打爆这个限制,30 分钟后业务方才发现 503 这个事故场景我确实见过类似案例。正确的做法确实需要在前置代理层做 (qname, sip) 二元组粒度的限流,CoreDNS 原生不支持。修订时会把这条配置改为「Envoy/L4 proxy 按 (qname, sip) 限流」的写法,避免大家直接抄 limit 10 踩坑。
谢谢这两个实测反馈,比我自己纸上推演靠谱多了。