DNS 隧道检测实战:Suricata 规则与日志分析

适用场景

DNS 隧道检测是针对”通过 53 端口外发数据或接收指令”这类隐蔽通道的识别与阻断。它适用的场景非常具体:

  • 企业出口只放行 53、80、443 三个端口,攻击者把 C2 指令与窃取的数据全部塞进 DNS 查询,绕过 HTTP 侧的全部检测。
  • 内网主机失陷后部署了 iodine、dnscat2 一类工具,流量特征就是”某个域名下的超长子域被高频查询”。
  • 云主机上出现无法解释的 DNS 出站请求,需要在影响扩大前判断是恶意隧道还是内部业务。

DNS 隧道之所以难被传统手段发现,是因为 53 端口是基础设施端口,几乎不可能直接封禁;而隧道流量在字节量上远小于其他外发渠道,带宽监控的告警阈值通常捕捉不到。因此检测必须依赖行为特征与统计特征,而不是流量体积。

前置条件

  • 出口或核心交换具备流量镜像能力,或可在 DNS 服务器本机采集查询日志。
  • 已部署 Suricata 6.0+Zeek 4.x+,且能解析 DNS 协议(默认启用)。
  • 内网 DNS 服务器为 BIND 9.11+ 或 dnsmasq 2.80+,具备速率限制能力。
  • 具备日志聚合能力,至少能对 eve.jsondns.log 做命令行统计。
  • 已梳理内部合法的高频域名白名单(CDN、软件更新、广告与遥测域名)。

原理说明

DNS 隧道把数据编码进域名,利用 DNS 的”提问—应答”机制双向传递信息。上行数据编码在查询名中,下行数据编码在应答记录的负载里。为绕过 DNS 记录类型的长度限制,工具会做分片与重编码,因此呈现出几类稳定的可观测特征。

以下是主流工具的行为差异,直接影响检测规则的设计:

  • iodine:上行主要使用 A / CNAME / MX 记录承载编码数据,下行使用 TXT / NULL / PRIVATE 记录。子域名长度显著偏长,且包含大量 Base32 或 Base64 字符。默认使用固定的一级域名并保持长连接心跳。
  • dnscat2:默认可切换 CNAME、MX、TXT、A、AAAA 等多种记录,查询名以标签形式分片,普遍携带十六进制串。为规避单标签长度限制,会把数据切成多个标签。
  • DNS2TCP / 通用自研通道:常见特征是”查询名极短但响应包极大”,或反之”查询包极大而响应极小”,通过 DNS 报文的长度不对称来夹带数据。

由此可归纳出四个可用作检测依据的维度:

  • 标签长度与查询长度:正常域名单标签很少超过 20 字符,隧道场景常出现 40 字符以上的标签;整体查询名长度超过 60 字符的概率显著升高。
  • 字符熵值:正常业务域名的标签具备可读性,熵值一般在 2.5 至 3.3 之间;随机编码数据的香农熵通常高于 3.5
  • 记录类型分布:内网正常查询以 A、AAAA、HTTPS 类型为主,TXT 与 NULL 记录占比极低。TXT 查询集中出现是强异常信号。
  • 单域名查询频次与响应大小:同一一级域名在短时间内的查询次数、以及平均响应报文长度,都能有效区分隧道与正常业务。

操作步骤

1. 编写 Suricata 检测规则

/etc/suricata/rules/dns-tunnel.rules 中新增以下规则。Suricata 的 dns.query 关键字作用于 DNS 查询名缓冲区,dns.rrtype 用于匹配记录类型:

# 1) 超长子域:单次查询名超过 60 字符,60 秒内同源触发 3 次
alert dns $HOME_NET any -> any 53 (msg:"DNS Tunnel - Suspicious Long Query Name"; \
  dns.query; pcre:"/^.{60,}$/"; \
  threshold:type both, track by_src, count 3, seconds 60; \
  classtype:bad-unknown; sid:1000101; rev:1;)

# 2) 单标签超长:首个标签长度大于 45 字符
alert dns $HOME_NET any -> any 53 (msg:"DNS Tunnel - Overlong First Label"; \
  dns.query; pcre:"/^[A-Za-z0-9+\/=_\-]{45,}\./"; \
  threshold:type both, track by_src, count 2, seconds 60; \
  classtype:bad-unknown; sid:1000102; rev:1;)

# 3) 高频 TXT 查询:正常业务极少出现
alert dns $HOME_NET any -> any 53 (msg:"DNS Tunnel - Abnormal TXT Query Volume"; \
  dns.rrtype: TXT; \
  threshold:type both, track by_src, count 10, seconds 60; \
  classtype:bad-unknown; sid:1000103; rev:1;)

# 4) 非标准端口发起 DNS 查询(常见于隧道工具的备用信道)
alert dns $HOME_NET any -> any ![53,5353] (msg:"DNS Query on Non-Standard Port"; \
  threshold:type both, track by_src, count 5, seconds 60; \
  classtype:bad-unknown; sid:1000104; rev:1;)

关键参数说明:threshold:type both 表示触发后进入静默期,避免一条隧道刷满告警日志;track by_src 按源 IP 独立计数;计数与时间窗需根据内网规模调整,阈值过低会产生大量误报。

suricata.yamlrule-files 中引用该文件后,用 suricata -T -c /etc/suricata/suricata.yaml 校验语法,再重载服务。

2. 用熵值脚本做二次筛查

规则只能覆盖已知形态,熵值统计能发现规则之外的变种。以下脚本读取 Suricata 的 eve.json,对查询名的首个标签计算香农熵并筛选异常:

#!/usr/bin/env python3
# 保存为 /usr/local/bin/dns_entropy.py
import json, math, sys
from collections import Counter

WHITELIST = ("in-addr.arpa", "ip6.arpa", "cdn.example.com", "windowsupdate.com")

def entropy(s: str) -> float:
    if not s:
        return 0.0
    c = Counter(s)
    n = len(s)
    return -sum((v / n) * math.log2(v / n) for v in c.values())

def analyze(eve_path: str, min_len: int = 30, min_ent: float = 3.5):
    hits = {}
    with open(eve_path, encoding="utf-8", errors="ignore") as f:
        for line in f:
            try:
                e = json.loads(line)
            except json.JSONDecodeError:
                continue
            if e.get("event_type") != "dns":
                continue
            dns = e.get("dns") or {}
            q = (dns.get("rrname") or "").rstrip(".")
            if not q or len(q) < min_len:
                continue
            if q.endswith(WHITELIST):
                continue
            label = q.split(".")[0]
            ent = entropy(label)
            if ent < min_ent:
                continue
            key = (e.get("src_ip"), ".".join(q.split(".")[-2:]))
            hits.setdefault(key, []).append((round(ent, 2), len(q), q))
    for (src, base), items in sorted(hits.items(), key=lambda kv: -len(kv[1])):
        print(f"[!] src={src} base_domain={base} hits={len(items)}")
        for ent, ln, q in items[:3]:
            print(f"    entropy={ent} len={ln} query={q}")

if __name__ == "__main__":
    analyze(sys.argv[1] if len(sys.argv) > 1 else "/var/log/suricata/eve.json")

执行 python3 /usr/local/bin/dns_entropy.py,输出中同一源 IP 对同一基础域名反复出现高熵长子域,即为隧道嫌疑。建议将该脚本加入 crontab 每 10 分钟运行一次,并把结果写入独立日志,与其他告警做关联。

3. 基于 Zeek 日志做域名维度统计

Zeek 的 dns.log 更适合做聚合统计,用 zeek-cut 提取字段后按基础域名归并:

# 查询量最高的基础域名 TOP 20(判断是否存在异常集中的隧道域名)
zcat /var/log/zeek/dns.log.*.gz /var/log/zeek/dns.log \
  | zeek-cut query | grep -v '^$' \
  | awk -F. 'NF>=2 {print $(NF-1)"."$NF}' \
  | sort | uniq -c | sort -nr | head -20

# 查询名字符长度分布:长尾中是否出现 60+ 的聚集
zcat /var/log/zeek/dns.log.*.gz /var/log/zeek/dns.log \
  | zeek-cut query | awk 'length($0)>=60 {c++} END {print "long_queries="c+0}'

# 统计 TXT 记录查询量与来源 IP
zcat /var/log/zeek/dns.log.*.gz /var/log/zeek/dns.log \
  | zeek-cut id.orig_h qtype_name \
  | awk '$2=="TXT" {c[$1]++} END {for (i in c) print c[i], i}' \
  | sort -nr | head -20

正常内网的域名查询量呈幂律分布,头部集中在少数业务域名;若 TOP 列表中突然出现一个陌生且查询量极高的基础域名,应优先核查其归属与解析记录类型。

4. 在 DNS 服务器侧施加限制

检测之外必须做阻断,从解析层压缩隧道的可用带宽。BIND 9 使用响应速率限制(RRL):

options {
    directory "/var/cache/bind";
    recursion yes;
    allow-recursion { 10.0.0.0/8; 192.168.0.0/16; };
    allow-query     { 10.0.0.0/8; 192.168.0.0/16; };

    rate-limit {
        responses-per-second 10;
        errors-per-second     5;
        nxdomains-per-second  5;
        window                5;
        slip                  2;
        ipv4-prefix-length   24;
        ipv6-prefix-length   56;
    };
};

参数含义:responses-per-second 10 限制单前缀每秒响应数,直接压低隧道吞吐;slip 2 表示超限后每 2 个请求仅正常应答 1 个,其余返回截断响应以节约带宽;ipv4-prefix-length 24 表示按 /24 聚合计数,避免单一客户端更换源端口规避限制。

dnsmasq 环境可使用转发上限与本地服务限制:

# /etc/dnsmasq.conf
interface=eth1
bind-interfaces
no-resolv
server=223.5.5.5
server=119.29.29.29
dns-forward-max=150
stop-dns-rebind
domain-needed
bogus-priv

进一步可启用 RPZ(Response Policy Zone),把已知隧道域名与公网加密 DNS 解析器域名统一指向黑洞地址:

# named.conf.local 中定义
zone "rpz.local" {
    type master;
    file "/etc/bind/db.rpz.local";
};

# /etc/bind/db.rpz.local 内容片段
$TTL 60
@ IN SOA localhost. admin.localhost. ( 1 3600 600 86400 60 )
  IN NS  localhost.
; 已知隧道与 DoH 解析器域名统一返回 NXDOMAIN
*.dns2tcp.example.     CNAME .
cloudflare-dns.com.    CNAME .
dns.google.            CNAME .

同时应在边界阻断公网 DoH / DoT,否则加密 DNS 会绕开全部检测:

iptables -A FORWARD -p tcp --dport 853 -j REJECT   # DoT
iptables -A FORWARD -p udp --dport 853 -j REJECT
# 强制内网使用本地解析器
iptables -t nat -A PREROUTING -i eth1 -p udp --dport 53 -j REDIRECT --to-ports 53
iptables -t nat -A PREROUTING -i eth1 -p tcp --dport 53 -j REDIRECT --to-ports 53

配置验证

# 1) Suricata 规则语法校验
suricata -T -c /etc/suricata/suricata.yaml

# 2) 用 dig 模拟隧道特征,观察是否告警
dig @127.0.0.1 $(python3 -c "import base64,os;print(base64.b32encode(os.urandom(30)).decode().rstrip('=').lower()[:55]+'.tunnel.example.com'") TXT
tail -f /var/log/suricata/fast.log

# 3) 校验 BIND RRL 是否生效(高频查询后应出现截断响应)
dig @127.0.0.1 test.example.com +tries=20 +short

# 4) 确认解析日志与 Zeek 日志均已落盘
ls -l /var/log/zeek/dns.log /var/log/suricata/eve.json

验证标准:模拟查询能触发 sid:10001011000102 告警;熵值脚本能列出该源 IP 与模拟域名;RRL 生效后高频查询出现截断或延迟响应。

常见问题

FAQ 1:告警量太大,难以区分真实隧道与正常业务

绝大多数误报来自 CDN、软件更新、广告与遥测类域名,它们本身就存在长子域与随机标识。降低误报的做法如下:

  • 建立并持续维护白名单:把 eve.json 中触发规则但确认合法的基础域名加入 WHITELIST,并按域名后缀匹配而非全串匹配。
  • 多维度同时命中才升级为事件:只有”长子域 + 高熵 + 非常见记录类型 + 高频”四项同时满足时才判定为高置信度,单维度命中仅记录不告警。
  • 按资产重要性分级:生产数据库、域控、运维跳板出现隧道嫌疑时立即告警;已安装 EDR 且行为正常的办公终端可降级为观察项。
  • 拉长统计窗口:把 60 秒窗口放宽到 300 秒,用总量而非瞬时峰值判定,可过滤掉软件更新这类突发流量。

FAQ 2:攻击者改用 DoH / DoT 加密 DNS 后检测是否完全失效

加密 DNS 会让内容层检测失效,但行为层特征依然存在,且可以通过网络策略恢复可见性:

  • 阻断公网加密解析:在边界封禁 853 端口与已知 DoH 解析器域名,强制内网主机使用本地解析器,从源头保证流量可观测。
  • 关注 SNI 与证书:DoH 走 443,但 TLS 握手中的 SNI 会暴露所访问的解析服务域名,可用 Suricata 的 tls.sni 关键字生成规则。
  • 统计连接行为:隧道即使加密也会表现为”少数内部主机与少数外部 IP 建立长时高频连接”,可通过连接频次与持续时间建立基线并检测偏离。
  • 端点侧兜底:在主机层面检查持有 53 或加密 DNS 长连接的进程(ss -tunap | grep -E ':53|:853'),结合 AIDE 等完整性监控发现工具落地痕迹。

FAQ 3:确认是隧道后如何定位发起进程

拿到嫌疑源 IP 后,先在主机上定位连接归属,再决定处置方式:

# 查看该主机的 DNS 连接与进程
ss -tunap | grep -E ':53\b'
lsof -i :53 -n -P

# 检查是否存在可疑的计划任务与自启项
crontab -l; ls -l /etc/cron.*/ /etc/systemd/system/ | grep -i dns
systemctl list-units --type=service --state=running | grep -iE 'dns|tunnel'

# 抓包确认编码特征(短时抓取,避免占用过多带宽)
tcpdump -i eth0 -nn -s0 -A 'udp port 53 and host 10.0.0.23' -c 50

处置顺序建议为:先在网络层阻断该主机的 53 出站流量(保留本地解析),再隔离主机做取证,最后核查同网段是否还有其他主机访问过同一隧道域名。一次性只清掉单台主机的实现文件,很可能遗漏持久化机制导致复现。

总结

DNS 隧道检测的核心难点不在技术实现,而在”如何在基础设施流量中建立可比对的基线”。落地路径可以概括为三层:Suricata 规则覆盖已知形态熵值与频次统计发现未知变种DNS 服务器 RRL 与 DoH 阻断压缩隧道可用空间

实施建议按顺序推进:先采集 7 至 14 天的 DNS 日志建立正常基线,再上线规则并以观察模式运行,白名单收敛稳定后切换到告警,最后启用速率限制与加密 DNS 阻断。任何跳步都会导致大量误报或正常业务中断,反过来削弱后续检测的可信度。