适用场景
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.json或dns.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.yaml 的 rule-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:1000101 或 1000102 告警;熵值脚本能列出该源 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 阻断。任何跳步都会导致大量误报或正常业务中断,反过来削弱后续检测的可信度。