DNS Tunneling
DNS tunneling encodes arbitrary data inside DNS queries and responses, allowing bidirectional communication with a C2 server using only UDP port 53 — a port that is almost never blocked because it's required for internet access. The technique exploits the fact that DNS queries for arbitrary subdomains are relayed through internal resolvers to external authoritative nameservers that the attacker controls. Tools like iodine, dnscat2, and DNS2TCP implement full TCP-over-DNS tunneling.
A threat actor on a network segment that has TCP traffic blocked by a firewall (only DNS allowed outbound) uses dnscat2 to establish a C2 channel. They register c2.evil.example as their authoritative domain and run a dnscat2 server on it. The implant on the internal host makes DNS queries like session_id.encoded_data.c2.evil.example — the query is forwarded through the corporate DNS resolver to the attacker's authoritative server, which responds with TXT records containing commands. The entire C2 channel exists as UDP/53 traffic.
DNS Tunneling Anatomy
DNS Tunneling Communication Flow ═══════════════════════════════════════════════════════════════════ Attacker's server: authoritative NS for tunnel-domain.com Victim's host: internal machine behind firewall Data encoding (client → server, in query): ├── Data chunk → base32/base64 encode → split at 63-char label boundary ├── Prepend session ID to identify conversation └── Query: SESSION_ID.BASE32_DATA.subdomain.tunnel-domain.com Data encoding (server → client, in response): ├── TXT record: arbitrary text (up to 255 bytes per TXT string, multiple per record) ├── CNAME record: hostname is base32-encoded data └── A record: 4 bytes encoded as IPv4 address (for binary data) DNS query rate: ├── Normal browsing: 10-20 unique queries/minute ├── dnscat2 tunneled SSH: 100-500 queries/minute to one domain └── iodine tunneled TCP: 1000+ queries/minute to one domain Key detection metrics: ├── High query rate to single second-level domain ├── Long subdomain labels (near 63-char limit) ├── Base32/base64 character set in subdomain ├── TXT record queries (most tunneling tools prefer TXT) ├── Consistent timing between queries (automated) └── NXDOMAIN rate near zero (every encoded query gets a response)
DNS Tunneling Detection
#!/usr/bin/env python3
"""
DNS tunneling detection from PCAP or Zeek dns.log.
Multiple detection methods: volume, entropy, query length, TXT frequency.
"""
import subprocess, sys, math, re
from collections import defaultdict, Counter
PCAP = sys.argv[1]
def extract_dns(pcap: str) -> list:
"""Extract DNS queries via tshark."""
result = subprocess.run([
"tshark", "-r", pcap, "-n",
"-Y", "dns.flags.response==0",
"-T", "fields", "-E", "separator=\t",
"-e", "frame.time_epoch",
"-e", "ip.src",
"-e", "dns.qry.name",
"-e", "dns.qry.type",
], capture_output=True, text=True)
queries = []
for line in result.stdout.splitlines():
parts = line.split("\t")
if len(parts) >= 4:
try:
queries.append({
"ts": float(parts[0]),
"src": parts[1],
"name": parts[2].lower().rstrip("."),
"qtype": parts[3],
})
except ValueError:
pass
return queries
def get_sld(name: str) -> str:
"""Extract second-level domain (e.g., 'google' from 'mail.google.com')."""
parts = name.split(".")
if len(parts) >= 2:
return ".".join(parts[-2:])
return name
def char_entropy(s: str) -> float:
if not s:
return 0.0
counts = Counter(s)
total = len(s)
return -sum((c/total)*math.log2(c/total) for c in counts.values())
def is_base32_like(s: str) -> bool:
"""Check if string looks like base32-encoded data."""
b32_chars = set("abcdefghijklmnopqrstuvwxyz234567=")
if not s:
return False
proportion = sum(1 for c in s.lower() if c in b32_chars) / len(s)
return proportion > 0.90 and len(s) > 15
def is_base64_like(s: str) -> bool:
"""Check if string looks like base64-encoded data."""
b64_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=-_")
if not s:
return False
proportion = sum(1 for c in s if c in b64_chars) / len(s)
return proportion > 0.90 and len(s) > 15
# Load queries
queries = extract_dns(PCAP)
print(f"Total DNS queries: {len(queries)}")
# Analysis 1: High query volume per source to single SLD
by_src_sld = defaultdict(list)
for q in queries:
sld = get_sld(q["name"])
by_src_sld[(q["src"], sld)].append(q)
print("\n=== High Query Volume per Source×Domain (>50 queries) ===")
for (src, sld), qs in sorted(by_src_sld.items(), key=lambda x: -len(x[1])):
if len(qs) > 50:
txt_count = sum(1 for q in qs if q["qtype"] == "16")
print(f" {src} → {sld}: {len(qs)} queries "
f"({txt_count} TXT queries)")
# Analysis 2: Long subdomain labels
print("\n=== Long Subdomain Labels (>40 chars in leftmost label) ===")
seen = set()
for q in queries:
labels = q["name"].split(".")
if labels:
label = labels[0]
if len(label) > 40 and q["name"] not in seen:
seen.add(q["name"])
entropy = char_entropy(label)
encoded = is_base32_like(label) or is_base64_like(label)
print(f" [{q['src']}] len={len(label)} ent={entropy:.2f} "
f"{'BASE-ENCODED' if encoded else ''} {q['name'][:70]}")
# Analysis 3: Reconstruct tunneled data
print("\n=== Attempting DNS Tunnel Stream Reconstruction ===")
# dnscat2 pattern: session_id (hex) + encoded data + fixed domain
for (src, sld), qs in by_src_sld.items():
if len(qs) < 20:
continue
# Check if queries follow dnscat2-like pattern
payloads = []
for q in qs:
labels = q["name"].split(".")
if len(labels) >= 3:
# First label might be session ID + data
first = labels[0]
# dnscat2: starts with hex session ID (4-8 chars) then data
if re.match(r'^[0-9a-f]{4,8}', first) and len(first) > 10:
payload_part = first[8:] # Skip session ID
payloads.append(payload_part)
if len(payloads) > 10:
# Try to decode accumulated base32
accumulated = "".join(payloads[:20])
try:
import base64
decoded = base64.b32decode(accumulated.upper() + "=" * ((8 - len(accumulated) % 8) % 8))
print(f" Possible dnscat2 stream: {src} → {sld}")
print(f" Decoded sample (first 50 bytes): {decoded[:50]}" )
except Exception:
pass
Some CDN and cloud monitoring services make very high-frequency DNS queries for health checking, with subdomains that include encoded identifiers (session tokens, timestamp hashes). These can look like tunneling: high query rate, long subdomains, consistent intervals. Before alerting on high DNS frequency, check the destination domain's reputation and ASN. A high-query rate to *.cloudflare.com, *.amazonaws.com, or a known monitoring vendor is almost certainly legitimate. The combination that matters for tunneling detection is: high frequency + unknown/newly-registered domain + base32/hex-encoded subdomains + TXT record queries. When all four are present and the destination domain isn't a known CDN, the probability of tunneling is high.
Q & A
Q: The DNS tunneling uses CNAME chains instead of TXT records and subdomain encoding. How does detection change?
CNAME-based DNS tunneling (used by some variants of iodine and custom tools) encodes data in the hostname returned by CNAME records, rather than in subdomain labels or TXT content. The client sends simple DNS A queries; the server responds with CNAME chains where each CNAME hostname encodes a chunk of data. Detection adjustments: (1) Look at responses, not just queries: in CNAME tunneling, the query names may be short and innocuous — the encoded data is in the CNAME response. Capture and analyze both queries and responses. (2) Analyze CNAME response entropy: a CNAME response like a.b.c.d.legit.com is normal; a CNAME response like aHR0cHM6Ly.5jMi5leGFtcGxl.legit.com has high-entropy labels indicating encoding. (3) CNAME chain depth: tunneling tools sometimes use 3-5 nested CNAME records in a single response to carry more data per query. A CNAME chain depth of 4+ for a single query is unusual. (4) In Zeek, the dns.log captures the full answer section including CNAME chains — analyze dns.answers field for high-entropy values in addition to query names.