← News
/PLAYBOOK · 2026-09-12

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:

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

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:

Four detection axes:

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:

FAQ 2: Attacker moves to DoH / DoT. Does detection go dark?

Encrypted DNS kills content inspection. Behavior remains, and policy can restore visibility:

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.