Lesson 3 of 5 · 25 min read

DNS Hunting — DGA, Tunnelling, and Fast-Flux from Hypothesis to Finding

DNS is the most abused protocol in adversary tradecraft — it’s allowed outbound everywhere, it’s often not inspected, and it’s the universal naming system that every C2 implant must use to find its server. DNS hunting finds adversaries hiding in the protocol.


The DNS Hunter’s Query Pipeline

Every DNS hunt follows the same pipeline:

ALL DNS QUERIES

      ▼ Step 1: Top-N filter (remove known-good domains)
RARE DOMAIN SET

      ▼ Step 2: Apply behavior-specific filter (DGA entropy, tunnelling volume, etc.)
CANDIDATE SET

      ▼ Step 3: Pivot (WHOIS, passive DNS, endpoint source process)
VERDICT

Never skip Step 1. Without the top-N filter, Steps 2 and 3 are buried in noise.


DGA Hunt

Hypothesis: A host infected with DGA malware is rotating through algorithmically-generated domain names seeking its C2. Evidence: high NXDOMAIN volume, high-entropy domain labels, burst pattern.

Step 1: NXDOMAIN Ratio Hunt

Zeek_dns_CL
| where TimeGenerated > ago(7d)
| summarize 
    total_queries = count(),
    nxdomain_count = countif(rcode_name_s == "NXDOMAIN"),
    noerror_count = countif(rcode_name_s == "NOERROR")
  by id_orig_h_s, bin(TimeGenerated, 1h)
| extend nxdomain_ratio = toreal(nxdomain_count) / toreal(total_queries)
| where nxdomain_ratio > 0.25 and nxdomain_count > 30  // >25% NXD + >30 in 1h
| order by nxdomain_ratio desc

Step 2: Domain Entropy Filter

// Add entropy calculation (approximate using label length as proxy)
Zeek_dns_CL
| where TimeGenerated > ago(1h)
| where rcode_name_s == "NXDOMAIN"
// Extract second-level domain label
| extend sld = tostring(split(query_s, ".")[-2])
| extend label_len = strlen(sld)
| where label_len between (8 .. 20)  // DGA sweet spot: not too short, not CDN hash
// Approximate entropy filter: no repeated vowel patterns (heuristic)
| where not(query_s matches regex @"[aeiou]{2}")  // consonant-heavy = high entropy
| summarize 
    nxd_count = count(),
    sample_labels = make_set(sld, 5)
  by id_orig_h_s
| where nxd_count > 30

Step 3: Validate with WHOIS on NOERROR Domains

After filtering, find the few NOERROR responses (the active C2 domains):

Zeek_dns_CL
| where TimeGenerated > ago(1h)
| where rcode_name_s == "NOERROR"
| where id_orig_h_s in (dga_candidate_hosts)  // from previous query
| project TimeGenerated, id_orig_h_s, query_s, answers_s

WHOIS on each NOERROR domain will show recent registration — DGA C2 domains are always fresh.


DNS Tunnelling Hunt

Hypothesis: A host is exfiltrating data or maintaining C2 via DNS by encoding data in query subdomains and reading responses from TXT records.

Step 1: High-Volume TXT/NULL Query Hunt

Zeek_dns_CL
| where TimeGenerated > ago(24h)
| where qtype_name_s in ("TXT", "NULL")
// Exclude legitimate TXT senders: SPF/DMARC checkers, mail servers
| where not(id_orig_h_s in (mail_server_ips))
| summarize 
    query_count = count(),
    unique_domains = dcount(tostring(split(query_s, ".")[-2]))  // unique eTLD+1
  by id_orig_h_s
| where query_count > 50    // >50 TXT queries in 24h = anomalous for workstation
| order by query_count desc

Step 2: Unique FQDN Ratio (Tunnelling Signature)

// Find domains with many unique subdomains (tunnelling) vs one subdomain repeated (DGA)
Zeek_dns_CL
| where TimeGenerated > ago(24h)
| where id_orig_h_s in (tunnelling_candidates)
| extend registered_domain = tostring(strcat(split(query_s, ".")[-2], ".", split(query_s, ".")[-1]))
| summarize 
    unique_fqdns = dcount(query_s),
    query_count = count(),
    avg_label_len = avg(strlen(tostring(split(query_s, ".")[0])))
  by id_orig_h_s, registered_domain
| where unique_fqdns > 30           // many unique subdomains to same registered domain
| where avg_label_len > 30          // long first labels = encoded data
| order by unique_fqdns desc

Step 3: Decode a Sample Subdomain

# Take the first label of a suspicious query:
# e.g., query = "V2hhdCBpcyB0aGUgYW5zd2VyIHRvIGxpZmU=.tunnel.attacker.com"
first_label="V2hhdCBpcyB0aGUgYW5zd2VyIHRvIGxpZmU="
echo "$first_label" | base64 -d
# → "What is the answer to life"

Decoded text confirms exfiltration. Binary output (file bytes) confirms data staging. Command strings confirm C2.

Volume estimation:

Queries observed: 847 over 2 hours
Average label length: 52 characters
Base64 efficiency: 0.75 (3/4 — 4 base64 chars = 3 bytes)
Data volume: 847 × 52 × 0.75 ≈ 33,000 bytes ≈ 33 KB

Slow but persistent tunnelling (47K/day) can exfiltrate meaningful data over weeks.


Fast-Flux Hunt

Hypothesis: An adversary is using a fast-flux DNS configuration to prevent IP-based blocking of their C2 infrastructure.

Zeek_dns_CL
| where TimeGenerated > ago(24h)
| where rcode_name_s == "NOERROR"
| where id_orig_h_s in (hunt_scope_hosts)
// Group by domain, find domains with many unique IP answers
| extend answers = split(answers_s, ",")
| mv-expand answer = answers to typeof(string)
| summarize 
    unique_answers = dcount(answer),
    min_ttl = min(toint(TTLs_s)),
    query_count = count()
  by query_s
| where unique_answers >= 5      // ≥5 different IPs for same domain
| where min_ttl <= 60            // TTL ≤ 60 seconds (rotation every minute)
| order by unique_answers desc

Legitimate CDN false positives: Some major CDNs (Akamai, CloudFront) rotate IPs with low TTL for load balancing. Filter: CDN-flagged domains (top-N list or ASN lookup on resolved IPs). Legitimate CDN = IPs in same ASN. Fast-flux = IPs in many different ASNs (each a compromised bulletproof host).