Finding DNS tunnelling in a 4 GB capture without a signature

Entropy, subdomain cardinality and query-rate shape — three statistics that find a covert channel faster than any rule matching a known tool.

Signature-based detection for DNS tunnelling works right up to the moment someone changes their tooling. The statistics do not care what tool you used, because they are properties of the channel itself: you are pushing bytes through a field designed for hostnames, and hostnames do not look like that.

Three passes, no signature, about six minutes of wall clock on a 4 GB capture.

Do not open the PCAP

The first mistake is loading it into a GUI. Convert to Zeek logs and work on text.

$ zeek -r gateway-2026-07-26.pcap LogAscii::use_json=F
$ wc -l dns.log conn.log
  2314887 dns.log
   184220 conn.log

2.3 million queries is a normal day. It is also far too many to look at, which is why every pass below reduces before it inspects.

Pass 1 — subdomain cardinality per registered domain

A tunnel encodes data into the leftmost labels, so every query is unique. Sort by distinct subdomains per parent domain and the channel rises to the top on its own:

$ zeek-cut query < dns.log \
  | awk -F. 'NF>2 { parent = $(NF-1)"."$NF; print parent"\t"$0 }' \
  | sort -u \
  | cut -f1 \
  | uniq -c \
  | sort -rn \
  | head -10
 184922 cdn-telemetry.example
   9114 googleapis.com
   4402 akamaiedge.net
   1980 windowsupdate.com
    712 office365.com

Pass 2 — entropy of the leftmost label

Cardinality alone flags some legitimate services. Encoded payload is near-uniform over its alphabet; a real hostname is not. Shannon entropy per character separates them cleanly:

import math, sys
from collections import Counter

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

for line in sys.stdin:
    query = line.strip()
    label = query.split(".", 1)[0]
    if len(label) < 12:          # short labels are noisy at this scale
        continue
    h = entropy(label)
    if h > 3.6:                  # empirically ~3.2 for English-ish hostnames
        print(f"{h:.2f}\t{len(label):3d}\t{query}")
$ zeek-cut query < dns.log | python3 label_entropy.py | sort -rn | head -4
4.61     58    kNzQ4YjFhLWQ3MmYtNGQ4ZS1hMzJlLTk5.cdn-telemetry.example
4.58     61    ZjM5YmM2NDMtODk0YS00YjIxLWFmZjItYWJ.cdn-telemetry.example
4.55     60    OWY4ZTIxMGEtMzMzYy00YTMwLWI0NTgtMzk.cdn-telemetry.example
3.71     22    d2h5LWFyZS15b3UtaGVyZQ.cdn-telemetry.example

Base32-shaped, 58–61 characters, sitting just under the 63-byte label limit — which is itself a signature of a tool that maximises throughput per query.

Pass 3 — rule out the boring explanation

Two things still fit the evidence: a covert channel, or a badly-designed telemetry SDK. conn.log settles it.

$ zeek-cut id.orig_h query ts < dns.log \
  | grep 'cdn-telemetry.example' \
  | awk '{print $1}' | sort | uniq -c | sort -rn | head -3
 184901 10.30.4.87
     14 10.30.4.12
      7 10.30.9.31

One host, 99.99% of the traffic, and the query rate is metronomic — a jitter of ±0.4 s around a 2 s beacon:

$ zeek-cut ts query < dns.log | grep 'cdn-telemetry.example' \
  | awk 'NR>1 { printf "%.1f\n", $1-prev } { prev=$1 }' \
  | sort | uniq -c | sort -rn | head -5
  91204 2.0
  42881 1.8
  38664 2.2
   6109 1.6
   5844 2.4

Telemetry SDKs batch and back off. They do not hold a 2-second cadence for eleven hours.

The rule that came out of it

Written on the shape, not on the tool, so retooling does not evade it:

alert dns $HOME_NET any -> any 53 (msg:"POLICY DNS query with oversized encoded label"; \
  dns.query; content:"."; \
  pcre:"/^[A-Za-z0-9+\/=_-]{40,63}\./"; \
  threshold:type both, track by_src, count 50, seconds 60; \
  classtype:policy-violation; sid:9000117; rev:2;)

For the same shape in Sigma, against Zeek dns.log in a SIEM:

title: High Distinct Subdomain Cardinality Per Parent Domain
logsource:
  product: zeek
  service: dns
detection:
  selection:
    query|re: '^[A-Za-z0-9+/=_-]{40,63}\.'
  timeframe: 5m
  condition: selection | count(query) by src_ip > 200
falsepositives:
  - DNS-based antivirus cloud lookups (scope out the vendor's parent domain)
  - Some CDN cache-key schemes
level: high

What I would keep from this

Cardinality, entropy and inter-arrival regularity. All three are cheap, all three are computable from dns.log with tools that ship with the operating system, and none of them care which tunnelling utility was used. A signature would have told me the tool's name and nothing else — and only if it happened to be a tool someone had already written a signature for.

Corrections and additions are welcome — this is a working document. Get in touch, or post a case of your own in the community.

Discussion

Guidelines

Sign in to comment. Corrections and additions are the point — this is a working document.