HackBlue

Tools · SIEM and detection engineering · 09 / 24

Hunting in DNS logs: newly seen domains, DGA-like names, and tunneling signs

Use DNS query logs from a resolver or endpoint to find rare domains, long random-looking names, high query volume to one domain, and unusual record types.

11 min read · practice only with authorization

Where DNS logs come from

Useful sources include Windows DNS Server analytical logs, Sysmon Event ID 22 on endpoints, resolver logs from Unbound or BIND, protective DNS services, and cloud resolver logs such as Route 53 Resolver query logs. Endpoint sources tie a query to the process that made it, which is very helpful for triage.

Signals worth hunting

Newly observed domains that only one host queries. Long labels with high character randomness, typical of domain generation algorithms. A very large number of unique subdomains under one parent domain, which suggests data encoded in queries. Heavy TXT or NULL record queries. Regular beacon-like intervals to the same domain.

None of these are proof on their own: CDNs, telemetry services, and anti-virus cloud lookups also produce long random-looking names. Use allow lists of known services and pivot to the process.

Lab: rank suspicious domains

Export a day of DNS queries from your lab resolver or a public sample dataset to a CSV with timestamp, client, and query name. Use a short Python script to compute, per registered domain, the number of unique subdomains, the average label length, and the Shannon entropy of the leftmost label. Sort and review the top entries.

Snippet
import csv, math, collections

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

stats = collections.defaultdict(lambda: {'subs': set(), 'ent': []})
with open('dns.csv') as f:
    for row in csv.DictReader(f):
        name = row['query'].rstrip('.').lower()
        parts = name.split('.')
        if len(parts) < 2:
            continue
        parent = '.'.join(parts[-2:])
        stats[parent]['subs'].add(name)
        stats[parent]['ent'].append(entropy(parts[0]))

rows = [(p, len(v['subs']), sum(v['ent']) / len(v['ent'])) for p, v in stats.items()]
for parent, subs, ent in sorted(rows, key=lambda r: (r[1], r[2]), reverse=True)[:20]:
    print(f'{parent:40} unique_subdomains={subs:6} avg_entropy={ent:.2f}')

Triage

For each candidate, identify which hosts queried it, which process made the query if you have endpoint data, when it started, and whether the domain is newly registered. Block confirmed malicious domains at the resolver, and keep the hunting query as a scheduled detection with your allow list.

Diagram

DNS hunting signals

Each is a lead, not a verdict.

  • Rare domain

    Queried by one host, first seen today.

  • High entropy label

    Random-looking names such as x7qk2v9pz.example.

  • Many unique subdomains

    Thousands of distinct names under one parent.

  • TXT-heavy traffic

    Unusual volume of TXT or NULL queries.

  • Beaconing

    Queries at fixed intervals around the clock.

Check yourself

1. Which Sysmon event records DNS queries with the requesting process?

2. Which pattern most suggests data being carried in DNS queries?

3. What commonly causes false positives in entropy-based DNS hunts?