Chapter 25

DNS Forensics

DNS is the phone book of the internet — every connection starts with a DNS query. For a network forensics analyst, DNS logs are a high-fidelity record of every host a system tried to contact, when it was contacted, and what IP addresses were returned. DNS also carries malware signatures: DGA domains have distinctive entropy patterns, DNS tunneling produces high-frequency queries with long subdomains, and C2 infrastructure often has young or recently-registered parent domains.

Scenario

A compromised host is making DNS queries to subdomains like aHR0cHM6Ly9jMi5leGFtcGxlLmNvbS9jbWQ.evil.com — base64-encoded data in the subdomain label. This is DNS tunneling: the attacker is encoding C2 commands and exfiltrating data via DNS TXT record responses. You need to reconstruct the tunneled data stream from the DNS queries alone, without needing to see the TCP traffic.

DNS Wire Format for Forensics

  DNS Message Structure
  ═══════════════════════════════════════════════════════════════════

  Header (12 bytes):
  ├── Transaction ID (2 bytes) — correlates query to response
  ├── Flags (2 bytes):
  │     QR=0 (query) / QR=1 (response)
  │     Opcode: QUERY=0, IQUERY=1, STATUS=2
  │     AA (Authoritative Answer): 1 if from authoritative server
  │     TC (Truncated): response was truncated (switch to TCP)
  │     RD (Recursion Desired): client asks resolver to recurse
  │     RA (Recursion Available): resolver supports recursion
  │     RCODE: 0=NOERROR, 1=FORMERR, 2=SERVFAIL, 3=NXDOMAIN
  ├── QDCOUNT: number of questions
  ├── ANCOUNT: number of answer RRs
  ├── NSCOUNT: number of authority RRs
  └── ARCOUNT: number of additional RRs

  Question Section: (FQDN, QTYPE, QCLASS)
    QTYPE: A=1, NS=2, CNAME=5, SOA=6, MX=15, TXT=16, AAAA=28

  Answer Section: (name, type, class, TTL, rdlength, rdata)
    TTL = 0 or very low: often used by malware/DGA (no caching)
    TXT records: used by DNS tunneling (carry arbitrary binary data)

  Key forensic fields per query:
  ├── dns.qry.name: the domain queried
  ├── dns.resp.name: domain in the answer
  ├── dns.a: IP address returned (A record)
  ├── dns.txt: TXT record content (may contain tunneled data)
  ├── dns.flags.rcode: error code (NXDOMAIN = 3)
  └── dns.time: query→response latency

DNS Analysis with tshark

bashdns-analysis.sh
#!/bin/bash
PCAP="$1"

echo "=== DNS Query Volume by Destination ==="
tshark -r "$PCAP" -n -Y "dns.flags.response==0" -T fields -e ip.dst \
  | sort | uniq -c | sort -rn | head -20

echo ""
echo "=== Top queried domains ==="
tshark -r "$PCAP" -n -Y "dns.flags.response==0" -T fields -e dns.qry.name \
  | sort | uniq -c | sort -rn | head -50

echo ""
echo "=== NXDOMAIN responses (failed lookups) ==="
tshark -r "$PCAP" -n -Y "dns.flags.rcode==3" -T fields \
  -E separator="\t" \
  -e frame.time_epoch -e ip.src -e dns.qry.name \
  | sort | uniq -c | sort -rn | head -50

echo ""
echo "=== Long subdomain queries (tunneling indicator: >50 chars) ==="
tshark -r "$PCAP" -n -Y "dns.flags.response==0" -T fields -e dns.qry.name \
  | awk 'length($0) > 50' | sort | uniq -c | sort -rn | head -30

echo ""
echo "=== DNS TXT queries (data exfil via TXT records) ==="
tshark -r "$PCAP" -n -Y "dns.qry.type==16" -T fields \
  -E separator="\t" \
  -e frame.time_epoch -e ip.src -e dns.qry.name

echo ""
echo "=== High-entropy domain labels (DGA detection) ==="
tshark -r "$PCAP" -n -Y "dns.flags.response==0" -T fields -e dns.qry.name \
  | python3 -c "
import sys, math, re
from collections import Counter

def entropy(s):
    if not s: return 0
    counts = Counter(s)
    total = len(s)
    return -sum((c/total)*math.log2(c/total) for c in counts.values())

for line in sys.stdin:
    domain = line.strip()
    # Extract leftmost label (subdomain)
    labels = domain.split('.')
    if labels:
        label = labels[0]
        if len(label) >= 8:  # Skip short labels
            e = entropy(label)
            if e > 3.5:  # High entropy threshold
                print(f'{e:.2f}  {domain}')
" | sort -rn | head -30

DGA Domain Detection

Pythondga-detector.py
#!/usr/bin/env python3
"""
Detect Domain Generation Algorithm (DGA) domains in DNS traffic.
Uses entropy + n-gram analysis + NXDOMAIN rate.
"""
import subprocess, math, re, sys
from collections import Counter, defaultdict

# English bigram frequencies (top pairs in English text)
ENGLISH_BIGRAMS = {
    'th':0.0271,'he':0.0233,'in':0.0203,'er':0.0178,'an':0.0161,
    'on':0.0157,'en':0.0151,'at':0.0145,'es':0.0145,'st':0.0139,
    'or':0.0128,'nt':0.0122,'nd':0.0118,'to':0.0117,'re':0.0116,
    'it':0.0110,'ha':0.0105,'is':0.0100,'ed':0.0099,'ar':0.0097,
}

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

def bigram_score(s: str) -> float:
    """Score how 'English-like' a string is based on bigram frequency."""
    s = s.lower()
    if len(s) < 2:
        return 0.0
    total = 0.0
    for i in range(len(s)-1):
        bg = s[i:i+2]
        total += ENGLISH_BIGRAMS.get(bg, 0)
    return total / (len(s) - 1)

def is_dga_candidate(domain: str) -> tuple[bool, dict]:
    labels = domain.rstrip('.').split('.')
    if len(labels) < 2:
        return False, {}

    # Analyze the SLD (second-level domain, e.g., 'google' from 'google.com')
    sld = labels[-2] if len(labels) >= 2 else labels[0]

    if len(sld) < 6:  # Too short to be DGA
        return False, {}

    entropy = char_entropy(sld)
    bigram = bigram_score(sld)
    digit_ratio = sum(1 for c in sld if c.isdigit()) / len(sld)
    consonant_ratio = sum(1 for c in sld.lower() if c in 'bcdfghjklmnpqrstvwxyz') / len(sld)

    is_dga = (entropy > 3.5 or bigram < 0.015) and len(sld) >= 8
    metrics = {
        "sld": sld,
        "entropy": round(entropy, 3),
        "bigram_score": round(bigram, 4),
        "digit_ratio": round(digit_ratio, 3),
        "length": len(sld),
    }
    return is_dga, metrics

# Extract domains from PCAP
pcap = sys.argv[1]
result = subprocess.run(
    ["tshark", "-r", pcap, "-n", "-Y", "dns.flags.response==0",
     "-T", "fields", "-e", "dns.qry.name"],
    capture_output=True, text=True
)

domain_nxdomain = defaultdict(lambda: {"queries": 0, "nxdomain": 0})

# Also track NXDOMAIN responses
nxdomain_result = subprocess.run(
    ["tshark", "-r", pcap, "-n", "-Y", "dns.flags.rcode==3",
     "-T", "fields", "-e", "dns.qry.name"],
    capture_output=True, text=True
)

for line in nxdomain_result.stdout.splitlines():
    d = line.strip()
    if d:
        domain_nxdomain[d]["nxdomain"] += 1

dga_candidates = []
seen = set()
for line in result.stdout.splitlines():
    domain = line.strip().lower()
    if not domain or domain in seen:
        continue
    seen.add(domain)
    domain_nxdomain[domain]["queries"] += 1

    is_dga, metrics = is_dga_candidate(domain)
    if is_dga:
        metrics["domain"] = domain
        metrics["nxdomain"] = domain_nxdomain[domain]["nxdomain"]
        dga_candidates.append(metrics)

# Sort by entropy descending
dga_candidates.sort(key=lambda x: -x["entropy"])
print(f"{'Entropy':8}  {'Bigram':7}  {'Len':4}  {'NXDOM':6}  Domain")
print("-" * 70)
for c in dga_candidates[:30]:
    print(f"{c['entropy']:8.3f}  {c['bigram_score']:7.4f}  {c['length']:4d}  "
          f"{c['nxdomain']:6d}  {c['domain']}")
print(f"\nTotal DGA candidates: {len(dga_candidates)}")
Mental model: DNS tunneling leaves byte-sized fingerprints

DNS tunneling works by encoding data as subdomain labels: ENCODED_DATA.attacker-domain.com. Because DNS label length is limited to 63 characters and the total FQDN is limited to 253 characters, each DNS query can carry roughly 150–200 bytes of encoded data. The tunneling tool (iodine, dnscat2, etc.) splits the payload into these chunks and sends them as sequential DNS queries. The forensic fingerprints are: (1) a very high query rate to a single second-level domain, (2) subdomain labels that look like base64 or hex (high entropy, specific character set), (3) TXT record queries (tunneling tools often prefer TXT for larger responses), (4) consistent subdomain lengths (the tool uses a fixed chunk size), and (5) the PCAP shows DNS queries but no corresponding TCP/UDP connections — all the data moved through DNS. When you see all five of these together, DNS tunneling is the diagnosis.

Q & A

Q: The organization uses DNS over HTTPS (DoH). Does that mean I lose all DNS visibility?

DoH (DNS-over-HTTPS) encrypts DNS queries inside HTTPS on port 443, making them indistinguishable from normal web traffic at the network layer — you lose per-query DNS visibility in PCAP. However, you retain several detection opportunities: (1) Endpoint DNS query logs: Windows DNS Client logs all queries regardless of transport (Event ID 1007/DNS-Client, or Sysmon EventID 22 with DNS logging enabled). These are often more complete than network DNS logs. (2) DoH provider SNI: DoH requires connecting to a specific HTTPS endpoint (e.g., cloudflare-dns.com, dns.google). If a host is contacting these via TLS, you can flag it even without seeing the queries. (3) Non-standard DoH servers: malware using C2 via DoH typically uses its own DoH resolver (an attacker-controlled HTTPS server that implements the DoH protocol). Traffic to an unknown IP on port 443 with the DoH MIME type (application/dns-message) in the TLS application data is detectable via JA3 and JARM fingerprinting. (4) Block unauthorized DoH: as a defensive control, block outbound HTTPS to known DoH providers from non-approved endpoints and force all DNS through your corporate resolver where you can log it.