This page is the exfiltration companion to DNS C2 detection. A C2 channel is a conversation; an exfil channel is a one-way upload encoded into subdomain labels. That changes what you measure: instead of check-in cadence you estimate how many payload bytes a host has pushed into one parent zone, and compare that with how much the zone could plausibly need.
Signals at a Glance
| Signal | Where | ATT&CK |
|---|---|---|
| Estimated encoded bytes per (host, parent zone) per hour | Resolver, Zeek dns.log | T1048.001 |
| Long labels with high character entropy and few repeats | DNS logs | T1048.001 |
| Sequence or counter prefixes in labels (chunk numbering) | DNS logs | T1048.001 |
| Few or uniform answers to a very large number of unique names | DNS logs | T1572 |
| One workstation producing a large share of a zone’s total queries | DNS logs | T1048 |
Estimating Payload per Zone
Base32 packs 5 bits per character, so a label of 50 characters carries roughly 30 bytes of payload. Summing label characters per host and zone gives a conservative upper bound on how much data left through DNS. Rank by that estimate, not by raw query count:
index=zeek sourcetype="bro:dns:json" qtype_name IN ("A","AAAA","TXT","CNAME","MX")
| rename "id.orig_h" as src
| eval parts = split(query, ".")
| eval n = mvcount(parts)
| eval zone = mvindex(parts, n-2) . "." . mvindex(parts, n-1)
| eval sub = mvjoin(mvindex(parts, 0, n-3), "")
| eval sublen = len(sub)
| bin _time span=1h
| stats count as queries sum(sublen) as label_chars dc(query) as unique_names avg(sublen) as avg_label by _time, src, zone
| eval est_kb = round(label_chars * 0.625 / 1024, 1)
| where est_kb > 256 AND unique_names > 500 AND avg_label > 20
| sort - est_kb
``` tune/validate: exclude CDN, AV and mail-filtering zones using a lookup ```
Pair the estimate with a randomness check. A host name such as a printer or a cloud-sync endpoint repeats; encoded data does not. The ratio of unique names to total queries approaches one for exfil:
// Windows DNS / Sentinel (KQL) - near-unique, long-label queries per zone
// tune/validate: Name and ClientIP fields assume the DnsEvents table
DnsEvents
| where TimeGenerated > ago(1d)
| extend labels = split(Name, ".")
| extend zone = strcat(labels[array_length(labels)-2], ".", labels[array_length(labels)-1])
| extend sub = tostring(labels[0])
| summarize q = count(), uniq = dcount(Name), avgSub = avg(strlen(sub)),
maxSub = max(strlen(sub)) by ClientIP, zone, bin(TimeGenerated, 1h)
| extend uniqRatio = todouble(uniq) / q
| where q > 300 and uniqRatio > 0.9 and avgSub > 24
| order by q desc
Separating Exfil from Telemetry
- Allowlist vendor zones that legitimately encode data in names: endpoint security cloud lookups, DNS-based blocklists and some mail-filtering services.
- Block or sinkhole direct outbound DNS to the internet so every query passes your resolvers; otherwise tunnels bypass the logs you are analysing.
- Look for a one-way ratio: exfil zones typically return the same short answer for every query, while real services vary.
- Decode a sample of labels in a sandbox. A base32 or hex decode that yields a file header, such as a ZIP or 7z magic number, is conclusive.
Zeek dns.log with RITA, SigmaHQ DNS rules, Elastic detections for DNS tunnelling, and MITRE CAR analytics on DNS volume. Baseline your own environment — security vendors and CDNs are the main false-positive source, and the payload estimate above is an upper bound that should be validated against a decode.
Build Detections That Fire
Mjolnir Security helps blue teams turn threat intelligence into high-fidelity detections across endpoint, network, identity and cloud telemetry.
- Detection Engineering We build and tune detections mapped to ATT&CK from your own telemetry.
- Threat Hunting as a Service Hypothesis-driven hunts for the behaviours on this page.
- 24/7 Incident Response Containment when a detection fires. Call +1 833 403 5875.
Stay ahead of emerging threats. Get notified when we publish new intelligence reports and advisories.
Subscribe to Alerts