DNS Tunnel Detection in Practice: Suricata Rules and Log Analysis
DNS tunnels stuff C2 and data into port 53 to bypass detection. Full chain: four Suricata rules for overlong labels and high-rate TXT, Shannon entropy script, Zeek dns.log domain stats, BIND RRL and dnsmasq rate limits, DoH/DoT blocking, FP reduction and process attribution.
Scope
DNS tunnel detection identifies and blocks covert channels that exfiltrate data or receive commands over port 53. Scenarios are specific:
- Enterprise egress allows only 53, 80, 443. Attackers stuff C2 and stolen data into DNS queries and bypass all HTTP-side detection.
- Compromised internal hosts run iodine, dnscat2, or similar. Signature is high-frequency queries of overlong subdomains under one domain.
- Unexplained outbound DNS from a cloud host. Need to tell malicious tunnel from internal business before blast radius grows.
DNS tunnels evade classic detection because 53 is infrastructure and almost never blocked outright. Byte volume is far smaller than other exfil paths, so bandwidth-threshold alerts rarely fire. Detect on behavior and stats, not volume.
Prerequisites
- Egress or core switch can mirror traffic, or query logs can be collected on the DNS server itself.
- Suricata 6.0+ or Zeek 4.x+ deployed, DNS protocol parsing on (default).
- Internal DNS is BIND 9.11+ or dnsmasq 2.80+, with rate limiting.
- Log aggregation in place. At minimum, CLI stats on `eve.json` or `dns.log`.
- Legitimate high-frequency internal domains already inventoried (CDN, software updates, ads, telemetry).
Mechanism
DNS tunnels encode data into names and ride the query–response path both ways. Uplink sits in the QNAME; downlink sits in the answer payload. Tools fragment and recode to dodge per-type length limits, which yields stable observables.
Tool differences that drive rule design:
- iodine: uplink mainly A / CNAME / MX; downlink TXT / NULL / PRIVATE. Subdomains are markedly long, heavy Base32 or Base64. Default uses a fixed apex and a long-lived heartbeat.
- dnscat2: can switch CNAME, MX, TXT, A, AAAA. QNAME is split across labels, often hex. Multiple labels dodge the single-label length cap.
- DNS2TCP / homegrown: tiny queries with huge answers, or huge queries with tiny answers. Length asymmetry carries the payload.
Four detection axes:
- Label length and QNAME length: legitimate labels rarely exceed 20 chars; tunnels often exceed 40. QNAMEs over 60 chars become common.
- Character entropy: business labels are readable; Shannon entropy typically 2.5–3.3. Random encoded data usually exceeds 3.5.
- RR type mix: internal queries are mostly A, AAAA, HTTPS. TXT and NULL are rare. A burst of TXT is a strong anomaly.
- Per-domain query rate and answer size: query count against one apex in a short window, plus mean answer length, separates tunnel from business.
Procedure
1. Write Suricata detection rules
Add the following to `/etc/suricata/rules/dns-tunnel.rules`. `dns.query` matches the QNAME buffer; `dns.rrtype` matches the record type:
`# 1) Overlong subdomain: QNAME exceeds 60 chars; fire after 3 hits from the same source within 60s
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) Overlong single label: first label longer than 45 characters
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) High-frequency TXT queries: rare in normal business traffic
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 query on a non-standard port (common fallback channel for tunnel tools)
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;)`
Key knobs: threshold:type both starts a quiet period after fire so one tunnel does not flood alerts; track by_src counts per source IP. Tune count and window to network scale. Too-low thresholds produce mass FPs.
Reference the file under `rule-files` in `suricata.yaml`, syntax-check with `suricata -T -c /etc/suricata/suricata.yaml`, then reload.
2. Second-pass with an entropy script
Rules cover known shapes. Entropy finds variants they miss. This script reads Suricata `eve.json`, computes Shannon entropy of the first label, and filters outliers:
`#!/usr/bin/env python3
# save as /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")`
Run `python3 /usr/local/bin/dns_entropy.py`. Same src IP repeatedly hitting the same base domain with high-entropy long labels is tunnel-suspect. Cron it every 10 minutes, write a dedicated log, correlate with other alerts.
3. Domain-dimension stats from Zeek logs
Zeek `dns.log` is better for aggregation. `zeek-cut` the fields, roll up by base domain:
`# TOP 20 base domains by query volume (spot anomalously concentrated tunnel domains)
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
# QNAME length distribution: look for clustering of 60+ in the long tail
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 query volume by source 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`
Normal internal query volume is power-law; the head is a few business domains. A previously unknown apex suddenly in the TOP list at extreme volume: check ownership and RR types first.
4. Constrain the DNS server
Detection without blocking is incomplete. Squeeze tunnel bandwidth at the resolver. BIND 9 Response Rate Limiting (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;
};
};`
Meaning: responses-per-second 10 caps answers per prefix per second and cuts tunnel throughput; slip 2 answers 1 of every 2 over-limit queries normally, the rest truncated to save bandwidth; ipv4-prefix-length 24 counts by /24 so one client cannot evade by rotating source ports.
dnsmasq: forward cap and local-service limits:
`# /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) to sinkhole known tunnel domains and public encrypted-DNS resolvers:
`# define in named.conf.local
zone "rpz.local" {
type master;
file "/etc/bind/db.rpz.local";
};
# /etc/bind/db.rpz.local snippet
$TTL 60
@ IN SOA localhost. admin.localhost. ( 1 3600 600 86400 60 )
IN NS localhost.
; known tunnel and DoH resolver domains uniformly return NXDOMAIN
*.dns2tcp.example. CNAME .
cloudflare-dns.com. CNAME .
dns.google. CNAME .`
Block public DoH / DoT at the border or encrypted DNS bypasses everything:
`iptables -A FORWARD -p tcp --dport 853 -j REJECT # DoT
iptables -A FORWARD -p udp --dport 853 -j REJECT
# force internal hosts onto the local resolver
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`
Validation
`# 1) Suricata rule syntax check
suricata -T -c /etc/suricata/suricata.yaml
# 2) Simulate tunnel traits with dig and watch for alerts
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) Verify BIND RRL is in effect (high-rate queries should get truncated answers)
dig @127.0.0.1 test.example.com +tries=20 +short
# 4) Confirm resolver logs and Zeek logs are on disk
ls -l /var/log/zeek/dns.log /var/log/suricata/eve.json`
Pass: simulated queries fire `sid:1000101` or `1000102`; entropy script lists that src IP and simulated domain; after RRL, high-rate queries get truncated or delayed answers.
FAQ
FAQ 1: Alert volume too high; cannot separate real tunnels from business
Most FPs are CDN, software-update, ad, and telemetry domains — they already have long labels and random IDs. Cut FPs as follows:
- Maintain a whitelist. Base domains that fired in `eve.json` but proved legit go into `WHITELIST`. Match on suffix, not the full string.
- Escalate only on multi-axis hits: long label + high entropy + uncommon RR type + high frequency = high confidence. Single-axis hits are logged, not alerted.
- Grade by asset. Prod DB, DC, ops jump host: alert immediately. Office endpoint with EDR and normal behavior: watch item.
- Lengthen the window. 60s → 300s. Judge totals, not spikes. Filters update bursts.
FAQ 2: Attacker moves to DoH / DoT. Does detection go dark?
Encrypted DNS kills content inspection. Behavior remains, and policy can restore visibility:
- Block public encrypted resolvers. Drop 853 and known DoH resolver domains at the border. Force internal hosts onto the local resolver so queries stay observable.
- Watch SNI and certs. DoH rides 443; TLS SNI still names the resolver. Suricata `tls.sni` for rules.
- Connection behavior. Encrypted tunnels still look like few internal hosts, few external IPs, long-lived high-rate sessions. Baseline frequency and duration; detect deviation.
- Endpoint fallback. Find processes holding long-lived 53 or encrypted-DNS sockets (`ss -tunap | grep -E ':53|:853'`). Pair with AIDE or similar for dropper traces.
FAQ 3: Tunnel confirmed. How to attribute the process
Have the suspect src IP. Attribute the socket on the host, then decide response:
`# inspect DNS connections and processes on this host
ss -tunap | grep -E ':53\b'
lsof -i :53 -n -P
# check for suspicious cron jobs and autostart entries
crontab -l; ls -l /etc/cron.*/ /etc/systemd/system/ | grep -i dns
systemctl list-units --type=service --state=running | grep -iE 'dns|tunnel'
# packet capture to confirm encoding traits (short capture to avoid consuming too much bandwidth)
tcpdump -i eth0 -nn -s0 -A 'udp port 53 and host 10.0.0.23' -c 50`
Order: block that host's outbound 53 at the network (keep local recursion), isolate for forensics, then check whether other hosts on the segment queried the same tunnel domain. Wiping implant files on one box often misses persistence; it comes back.
Summary
Hard part is not the tech. Hard part is a comparable baseline inside infrastructure DNS. Three layers: Suricata rules for known shapes; entropy and frequency for unknown variants; resolver RRL plus DoH blocking to shrink usable tunnel bandwidth.
Sequence: collect 7–14 days of DNS logs for a baseline, ship rules in observation mode, switch to alerting after whitelist convergence, then enable rate limits and encrypted-DNS blocks. Skip a step and you get mass FPs or outages, which then burns trust in the detector.