Detect Command and Control via DNS-over-HTTPS Covert Channel in IBM QRadar
Adversaries increasingly transport command-and-control traffic inside DNS-over-HTTPS (DoH) requests issued directly to public resolver endpoints such as cloudflare-dns.com, dns.google, and doh.opendns.com, deliberately bypassing the enterprise's own recursive resolvers. This defeats the single most valuable DNS control most organisations own: because the query never reaches the corporate resolver, it never appears in DNS server analytical logs, never hits an RPZ/DNS-firewall blocklist, and never gets sinkholed, so a domain-based blocklist that would have stopped classic DNS tunnelling has no visibility at all. The traffic that remains on the wire is an ordinary-looking TLS session to a well-known, high-reputation cloud resolver on port 443 — which reputation-based proxy and firewall controls will pass. Frameworks such as godoh, Merlin, Sliver's DoH transport, and the Godlua backdoor implement this pattern, encoding tasking and exfiltrated data into DoH wire-format requests (RFC 8484) issued as HTTP POST bodies with content-type application/dns-message, which — unlike the GET/dns= variant — leaves no query content in URL strings or proxy access logs. Detection therefore has to pivot off behaviour rather than content: an endpoint process that is not an approved browser opening sustained sessions to a public DoH endpoint, at a machine-regular cadence with little or no jitter, while the same host's queries stop appearing in corporate resolver telemetry. This differs from the T1071.004 base record and from THREAT-DNSTunnel-Exfil (which key on high-entropy subdomain labels and TXT/NULL record volume in resolver logs) in that no resolver log entry is produced at all — the fingerprint here is the combination of resolver-bypass, DoH endpoint identity, non-browser initiating process, and low-jitter beacon pacing.
MITRE ATT&CK
- Tactic
- Command and Control
QRadar Detection Query
SELECT
sourceip AS ClientIP,
destinationip AS ResolverIP,
"URL Host" AS DohHost,
"User Agent" AS ClientUserAgent,
username AS UserName,
COUNT(*) AS BeaconCount,
SUM(destinationbytes) AS TotalResponseBytes,
(MAX(endtime) - MIN(starttime)) / 1000 AS DurationSeconds,
((MAX(endtime) - MIN(starttime)) / 1000) / COUNT(*) AS AvgGapSeconds,
MIN(starttime) AS FirstSeen,
MAX(endtime) AS LastSeen
FROM events
WHERE
starttime > (NOW() - 86400000)
AND destinationport = 443
AND (
"URL Host" IN ('cloudflare-dns.com', 'mozilla.cloudflare-dns.com', 'dns.google', 'doh.opendns.com', 'dns.quad9.net', 'doh.cleanbrowsing.org', 'dns.nextdns.io')
OR INCIDR('1.1.1.1/32', destinationip)
OR INCIDR('8.8.8.8/32', destinationip)
OR INCIDR('8.8.4.4/32', destinationip)
OR INCIDR('208.67.222.222/32', destinationip)
)
AND NOT INCIDR('10.50.53.0/24', sourceip)
AND "User Agent" NOT ILIKE '%Mozilla%'
GROUP BY sourceip, destinationip, "URL Host", "User Agent", username
HAVING COUNT(*) >= 30
ORDER BY BeaconCount DESC QRadar AQL query over the events table populated by proxy and NGFW URL-filtering DSMs, selecting sessions to public DoH endpoints either by the normalised URL Host property or by resolver address. Address matching uses INCIDR, QRadar's genuine CIDR-aware predicate, for both the resolver match and the exclusion of the sanctioned internal forwarder range — the IN() list is used only for hostname string equality, which is what it is actually suited to. A non-browser user-agent filter removes the bulk of legitimate browser DoH, and the derived AvgGapSeconds column supports the beacon-pacing assessment during triage. Where your proxy DSM does not populate User Agent, drop that predicate and rely on the endpoint DSM correlation described in the playbook.
Data Sources
Required Tables
False Positives & Tuning
- Browser Secure DNS traffic on estates where the User Agent property is not populated by the proxy DSM and the browser filter therefore has no effect
- Endpoint agents and VPN clients with a bundled DoH stub resolver
- Automation, CI and monitoring probes exercising a public DoH endpoint on a fixed schedule
- Sanctioned internal DoH forwarders outside the excluded address range
Other platforms for THREAT-C2-DNSOverHTTPSCovertChannel
Testing Methodology
Validate this detection against 4 adversary techniques from Atomic Red Team. Each test below lists the behaviour to exercise and the telemetry you should expect to see. Executable commands and cleanup steps are available with Pro.
- Test 1Low-Jitter DoH POST Beacon to Cloudflare from a Non-Browser Process
Expected signal: DeviceNetworkEvents (or Sysmon Event ID 3): approximately 40 ConnectionSuccess records from curl to cloudflare-dns.com / 1.1.1.1 on port 443 spaced 10 seconds apart. Proxy access logs with TLS inspection additionally show POST /dns-query with content-type application/dns-message. Corporate DNS resolver logs show no corresponding query for example.com, which is the resolver-bypass half of the signature.
- Test 2PowerShell DoH Resolver-Bypass Beacon to Google Public DNS
Expected signal: DeviceNetworkEvents: approximately 40 ConnectionSuccess records with InitiatingProcessFileName powershell.exe to dns.google / 8.8.8.8 on port 443 at a 10-second cadence. Sysmon Event ID 3 records the same connections with Image set to the PowerShell binary. The corporate DNS server logs contain no example.com query from this host.
- Test 3OpenDNS DoH Beacon from a Scripted macOS Client
Expected signal: DeviceNetworkEvents on the macOS device: approximately 40 ConnectionSuccess records from curl to doh.opendns.com / 208.67.222.222 on port 443 at a 10-second cadence. Proxy logs show GET /dns-query with a dns= parameter rather than a POST body, which is the GET variant of RFC 8484.
- Test 4Jittered DoH Beacon Evasion Baseline
Expected signal: DeviceNetworkEvents: approximately 40 ConnectionSuccess records from curl to cloudflare-dns.com / 1.1.1.1 on port 443 with irregular spacing across roughly 30 minutes.
References (12)
- https://attack.mitre.org/techniques/T1071/004/
- https://attack.mitre.org/techniques/T1572/
- https://datatracker.ietf.org/doc/html/rfc8484
- https://github.com/sensepost/godoh
- https://github.com/Ne0nd0g/merlin
- https://github.com/BishopFox/sliver
- https://blog.netlab.360.com/an-analysis-of-godlua-backdoor-en/
- https://support.mozilla.org/en-US/kb/canary-domain-use-application-dns-net
- https://chromeenterprise.google/policies/#DnsOverHttpsMode
- https://learn.microsoft.com/en-us/defender-endpoint/advanced-hunting-devicenetworkevents-table
- https://github.com/SigmaHQ/sigma/tree/master/rules/windows/network_connection
- https://docs.zeek.org/en/current/scripts/base/protocols/ssl/main.zeek.html
Response Playbook
Triage
- Identify the initiating process and its full path from the DeviceNetworkEvents result. A browser is expected to speak DoH; svchost.exe running from a non-System32 path, a signed-but-unusual LOLBin, an interpreter (powershell.exe, python.exe, node.exe), or an unsigned binary in %APPDATA%/%TEMP%/%ProgramData% resolving DNS over HTTPS is not, and is the single strongest discriminator in this detection.
- Confirm the resolver-bypass half of the fingerprint: query your corporate DNS server analytical logs (or Sentinel DnsEvents) for the same host over the same window. A host generating sustained network activity while producing near-zero corporate resolver queries is actively bypassing the resolver, which is the condition that makes this technique worth alerting on.
- Assess the jitter figure directly. Human-driven browsing produces a wide interval spread; a standard deviation at or below a few seconds against a fixed mean is a scheduler, not a person. Plot the raw gap series before dismissing the alert, because a single long idle gap can inflate the standard deviation and mask an otherwise metronomic beacon.
- If proxy or TLS-inspection telemetry is available, confirm the RFC 8484 request shape: HTTP POST with content-type application/dns-message. Genuine browser DoH traffic uses the same shape, so treat this as corroboration of the channel, not of maliciousness — it is the combination with a non-browser process that matters.
- Compare response sizes against a DNS baseline. DoH responses carrying C2 tasking or exfiltrated content are typically much larger and far more uniform in size than the mixed distribution of ordinary A/AAAA answers; a tight cluster of large responses indicates data transport rather than name resolution.
- Check the host for a recently written DoH client configuration or a browser policy change enabling Secure DNS, and check whether the process was launched by a parent consistent with legitimate software installation or by a suspicious execution chain (Office, script host, scheduled task, service creation).
- Determine whether the same process name, hash or path is beaconing to a public DoH endpoint on any other host in the estate — a single implant deployed at scale will show the identical process/interval signature across multiple devices.
Containment
- Block outbound access to public DoH endpoints at the perimeter by hostname and by resolver address range, and enforce it for all clients except the sanctioned internal forwarders — this is the control that converts the technique back into ordinary resolver-visible DNS.
- Deploy the canary domain use-application-dns.net as an NXDOMAIN on the corporate resolver, which instructs Firefox to disable its default DoH; pair it with the Chrome/Edge DnsOverHttpsMode group policy set to 'off' or 'secure' with an enterprise template, so that browser DoH stops competing with the signal.
- Isolate the endpoint via EDR network isolation once a non-browser initiating process is confirmed, before killing the process — DoH implants commonly re-establish immediately and an uncontained kill only costs you the running process's memory.
- Rotate credentials for any account that was interactively logged on to the host during the beaconing window, since a DoH channel is a fully bidirectional transport capable of moving credential material out.
- Add the specific process hash and path to an EDR block rule and sweep the estate for the same indicator, as DoH-capable loaders are typically deployed to multiple hosts in one campaign.
Evidence Collection
- Full DeviceNetworkEvents (or proxy access log) history for the device/process/destination tuple across the entire activity window rather than just the alerting window, since beacons commonly run for days before crossing a volume threshold.
- Corporate DNS resolver logs for the same host and window — the absence of expected queries is itself the evidence of resolver bypass and should be captured explicitly rather than inferred later.
- A process memory dump of the beaconing process while it is still running; DoH implants hold the resolver endpoint URL, the encoding scheme and often the decoded tasking buffer in memory.
- The on-disk binary or script implementing the DoH client, its parent process chain, and any dropped configuration file naming the resolver endpoint, together with prefetch and Amcache/Shimcache entries establishing first execution time.
- TLS inspection or full packet capture of the DoH sessions if available, which yields the actual dns-message bodies and is the only artifact that can establish what was queried and what was returned.
- Any browser or OS policy artefact (registry values under the Chrome/Edge DnsOverHttps policy keys, Firefox network.trr preferences) showing when and by what mechanism DoH was enabled on the host.
Escalation Criteria
- !The initiating process is not a browser and is unsigned, or is signed but executing from a user-writable directory — treat as a confirmed implant and escalate to incident response immediately.
- !Corporate resolver logs confirm the host produced effectively no normal DNS traffic during the beaconing window, establishing deliberate resolver bypass rather than incidental browser DoH.
- !Response-size analysis or recovered dns-message bodies indicate bulk data transport rather than name resolution, implying exfiltration over the C2 channel (T1041) is already in progress.
- !The same process indicator or beacon interval signature is observed on more than one host, indicating a campaign rather than an isolated installation.
- !The beaconing host sits in a server, datacenter or privileged management segment rather than a standard user endpoint subnet, where no browser DoH explanation is even plausible.
- !The activity was sustained for more than 48 hours before detection, indicating a long-established channel and a correspondingly wide window for tasking and data movement.
Investigation Guide
Related Techniques
Forensic Artifacts
- >
DeviceNetworkEvents / Sysmon Event ID 3 records tying the DoH sessions to a specific process, path, hash and user — the primary attribution artifact, since network-only telemetry cannot name the client. - >
Proxy access logs with TLS inspection, showing POST requests to the DoH endpoint with content-type application/dns-message and the response sizes. - >
Zeek ssl.log entries with server_name set to the DoH provider and the associated JA3/JA3S fingerprints, which cluster non-browser TLS stacks distinctly from real browsers. - >
Corporate DNS resolver logs for the host, whose sparseness during the beacon window evidences resolver bypass. - >
Registry policy values for Chrome/Edge Secure DNS and Firefox network.trr.mode preferences in the user profile, establishing whether DoH was enabled administratively or by the implant. - >
The DoH client binary or script and its dropped configuration file naming the resolver endpoint, plus prefetch, Amcache and scheduled-task/service persistence artifacts for its execution history. - >
Process memory of the beaconing process, containing the resolver URL, encoding scheme and decoded tasking data.
Tuning Guidance
Start by running the first hunting query in inventory mode for a full week and build two allowlists from the result: the browser executables that legitimately speak DoH in your estate, and the address ranges of any sanctioned internal DoH forwarder or secure-web-gateway egress node. Both allowlists belong in the detection as the ApprovedDohClients dynamic list and the ipv4_is_in_range exclusion respectively — note that resolver exclusions must be expressed with ipv4_is_in_range rather than an in() list, because in() performs string equality and will silently fail to match anything but an exact address literal. The single most effective tuning action is not a query change at all: publish use-application-dns.net as NXDOMAIN on your resolver and set the Chrome/Edge DnsOverHttpsMode policy, which removes the browser-DoH false-positive population wholesale and leaves the remaining hits overwhelmingly interesting. Jitter thresholds should be set from your own baseline; five seconds of standard deviation is a reasonable start for fast beacons, but mature frameworks configured with 30-50 percent jitter will evade it entirely, so run the second hunting query on the jitter-to-mean ratio rather than the absolute value if you suspect a tuned implant. Do not attempt to build the primary detection on corporate DNS resolver logs or on Sentinel DnsEvents — the whole point of the technique is that the query never reaches your resolver, so a resolver-based rule will never fire; resolver telemetry is valuable here only as the negative-space corroboration described in triage. Finally, if your proxy performs TLS inspection, add a content-type application/dns-message arm as a high-confidence standalone rule for non-browser user-agents, and if it does not, substitute Zeek ssl.log JA3 fingerprinting, since non-browser TLS stacks (Go, Python, .NET) cluster into a small, easily-baselined set of JA3 hashes that differ sharply from real browsers.
Hunting Queries
Broad inventory hunt for every process in the estate that talks to a public DoH endpoint, deliberately unfiltered by jitter or volume. The output is intended to be read as an allowlist-building exercise: everything that is not an approved browser or a sanctioned forwarder is a candidate for the tighter beaconing detection, and the process-path column usually makes the anomalies obvious on first read.
// Hunt: hosts whose processes reach public DoH endpoints while producing little or no corporate resolver traffic
let LookbackWindow = 7d;
let DohHostnames = dynamic(["cloudflare-dns.com", "mozilla.cloudflare-dns.com", "dns.google", "doh.opendns.com", "dns.quad9.net", "doh.cleanbrowsing.org", "dns.nextdns.io"]);
DeviceNetworkEvents
| where Timestamp > ago(LookbackWindow)
| where RemotePort == 443
| extend RemoteHost = tolower(tostring(split(tostring(coalesce(RemoteUrl, "")), "/")[0]))
| where RemoteHost has_any (DohHostnames)
or ipv4_is_in_range(RemoteIP, "1.1.1.1/32")
or ipv4_is_in_range(RemoteIP, "8.8.8.8/32")
or ipv4_is_in_range(RemoteIP, "208.67.222.222/32")
| where ipv4_is_in_range(RemoteIP, "10.50.53.0/24") == false
| summarize DohConnections = count(), Destinations = dcount(RemoteIP), FirstSeen = min(Timestamp), LastSeen = max(Timestamp)
by DeviceName, InitiatingProcessFileName, InitiatingProcessFolderPath
| where DohConnections > 10
| sort by DohConnections desc index=sysmon sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventCode=3 DestinationPort=443
| search DestinationHostname="cloudflare-dns.com" OR DestinationHostname="mozilla.cloudflare-dns.com" OR DestinationHostname="dns.google" OR DestinationHostname="doh.opendns.com" OR DestinationHostname="dns.quad9.net" OR DestinationHostname="doh.cleanbrowsing.org" OR DestinationHostname="dns.nextdns.io"
| where NOT cidrmatch("10.50.53.0/24", DestinationIp)
| stats count as DohConnections, dc(DestinationIp) as Destinations, earliest(_time) as FirstSeen, latest(_time) as LastSeen by ComputerName, Image, User
| where DohConnections>10
| sort - DohConnections Hunts purely on pacing regularity, ranking by the ratio of jitter to mean interval so that a slow beacon with a 30-minute period is surfaced alongside a fast one. Deliberately ignores process identity so it also catches implants that have injected into or masqueraded as an approved browser, which the primary detection's approved-client arm would otherwise discount.
// Hunt: metronomic pacing to any public DoH endpoint, surfacing low-jitter callbacks regardless of process identity
let LookbackWindow = 7d;
let DohHostnames = dynamic(["cloudflare-dns.com", "mozilla.cloudflare-dns.com", "dns.google", "doh.opendns.com", "dns.quad9.net"]);
DeviceNetworkEvents
| where Timestamp > ago(LookbackWindow)
| where ActionType == "ConnectionSuccess"
| where RemotePort == 443
| extend RemoteHost = tolower(tostring(split(tostring(coalesce(RemoteUrl, "")), "/")[0]))
| where RemoteHost has_any (DohHostnames) or ipv4_is_in_range(RemoteIP, "1.1.1.1/32") or ipv4_is_in_range(RemoteIP, "8.8.8.8/32")
| sort by DeviceId asc, InitiatingProcessId asc, Timestamp asc
| serialize
| extend PrevTimestamp = prev(Timestamp), PrevDevice = prev(DeviceId), PrevProcess = prev(InitiatingProcessId)
| where DeviceId == PrevDevice and InitiatingProcessId == PrevProcess
| extend GapSeconds = datetime_diff('second', Timestamp, PrevTimestamp)
| summarize Callbacks = count(), AvgGapSeconds = avg(GapSeconds), JitterSeconds = stdev(GapSeconds)
by DeviceName, InitiatingProcessFileName, RemoteHost, bin(Timestamp, 6h)
| where Callbacks > 20 and JitterSeconds < 10
| extend JitterRatio = JitterSeconds / AvgGapSeconds
| sort by JitterRatio asc, Callbacks desc index=proxy sourcetype="bluecoat:proxysg:access:syslog" (cs_host="cloudflare-dns.com" OR cs_host="mozilla.cloudflare-dns.com" OR cs_host="dns.google" OR cs_host="doh.opendns.com" OR cs_host="dns.quad9.net")
| sort 0 c_ip, cs_userAgent, _time
| streamstats current=f last(_time) as PrevTime by c_ip, cs_userAgent
| eval GapSeconds=_time-PrevTime
| where isnotnull(GapSeconds) AND GapSeconds>=0
| bucket _time span=6h
| stats count as Callbacks, avg(GapSeconds) as AvgGapSeconds, stdev(GapSeconds) as JitterSeconds by c_ip, cs_userAgent, cs_host, _time
| where Callbacks>20 AND JitterSeconds<10
| eval JitterRatio=round(JitterSeconds/AvgGapSeconds, 3)
| sort JitterRatio, - Callbacks Atomic Red Team Tests
Simulates an implant using the RFC 8484 POST form of DNS-over-HTTPS against cloudflare-dns.com on a fixed 10-second interval from curl rather than a browser, reproducing all three scored fingerprints (non-browser initiating process, near-zero jitter, sustained frequency) without deploying an actual C2 framework. The base64url payload is a valid DNS wire-format query for example.com A.
Command
for i in $(seq 1 40); do
printf 'q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB' | base64 -d > /tmp/doh_query.bin 2>/dev/null
curl -s -o /dev/null -H 'content-type: application/dns-message' -H 'accept: application/dns-message' --data-binary @/tmp/doh_query.bin https://cloudflare-dns.com/dns-query
sleep 10
done
rm -f /tmp/doh_query.bin
echo 'DoH beacon simulation complete: 40 POST callbacks at a 10s interval' Cleanup
rm -f /tmp/doh_query.bin Expected Telemetry
DeviceNetworkEvents (or Sysmon Event ID 3): approximately 40 ConnectionSuccess records from curl to cloudflare-dns.com / 1.1.1.1 on port 443 spaced 10 seconds apart. Proxy access logs with TLS inspection additionally show POST /dns-query with content-type application/dns-message. Corporate DNS resolver logs show no corresponding query for example.com, which is the resolver-bypass half of the signature.
Expected Detection
BeaconCount reaches 40 against MinBeaconCount of 30, AvgGapSeconds lands near 10 against a 300s ceiling, and JitterSeconds stays under 5 given the fixed sleep. IsUnexpectedClient is true because curl is not in ApprovedDohClients, so DohBeaconScore reaches 3 and the rule fires.
Simulates a Windows implant resolving DNS entirely outside the corporate resolver by issuing repeated DoH requests to dns.google from powershell.exe on a fixed interval. Uses the JSON API form for readability, which still produces the identical network-layer and process-attribution fingerprint the detection scores on.
Command
1..40 | ForEach-Object { try { Invoke-WebRequest -Uri 'https://dns.google/resolve?name=example.com&type=A' -Headers @{ 'accept' = 'application/dns-json' } -UseBasicParsing -TimeoutSec 5 | Out-Null } catch {}; Start-Sleep -Seconds 10 }; Write-Host 'DoH resolver-bypass simulation complete: 40 callbacks to dns.google' Expected Telemetry
DeviceNetworkEvents: approximately 40 ConnectionSuccess records with InitiatingProcessFileName powershell.exe to dns.google / 8.8.8.8 on port 443 at a 10-second cadence. Sysmon Event ID 3 records the same connections with Image set to the PowerShell binary. The corporate DNS server logs contain no example.com query from this host.
Expected Detection
IsUnexpectedClient is true (powershell.exe is not an approved browser), IsLowJitter is true given the fixed Start-Sleep, and IsHighFrequency is true at 40 callbacks with a 10-second mean, giving a DohBeaconScore of 3. The ipv4_is_in_range arm matches 8.8.8.8/32 even if RemoteUrl is not populated.
Simulates a macOS implant beaconing over DoH to doh.opendns.com on a fixed interval from a scripted, non-browser client, validating that the detection's provider list and process-identity arm both cover the third named public provider and the macOS endpoint population.
Command
for i in $(seq 1 40); do
/usr/bin/curl -s -o /dev/null -H 'accept: application/dns-message' 'https://doh.opendns.com/dns-query?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB'
sleep 10
done
echo 'macOS DoH beacon simulation complete: 40 callbacks to doh.opendns.com' Expected Telemetry
DeviceNetworkEvents on the macOS device: approximately 40 ConnectionSuccess records from curl to doh.opendns.com / 208.67.222.222 on port 443 at a 10-second cadence. Proxy logs show GET /dns-query with a dns= parameter rather than a POST body, which is the GET variant of RFC 8484.
Expected Detection
IsUnexpectedClient and IsLowJitter and IsHighFrequency all evaluate true for a DohBeaconScore of 3. On the SPL proxy rule the content-type arm does not contribute for the GET variant, but the non-browser user-agent, jitter and frequency arms still total 3 and exceed the firing threshold of 2.
Control test that beacons to cloudflare-dns.com with a randomised 5-to-90 second interval, deliberately defeating the fixed-jitter arm of the detection. Run this after the low-jitter tests to measure how much of the signal survives on process identity and frequency alone, and to calibrate whether the jitter-to-mean ratio hunting query is needed in your environment.
Command
for i in $(seq 1 40); do
/usr/bin/curl -s -o /dev/null -H 'accept: application/dns-message' 'https://cloudflare-dns.com/dns-query?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB'
sleep $(( ( RANDOM % 85 ) + 5 ))
done
echo 'Jittered DoH beacon baseline complete: 40 callbacks with 5-90s randomised interval' Expected Telemetry
DeviceNetworkEvents: approximately 40 ConnectionSuccess records from curl to cloudflare-dns.com / 1.1.1.1 on port 443 with irregular spacing across roughly 30 minutes.
Expected Detection
IsLowJitter evaluates false because the standard deviation of the interval exceeds 5 seconds. IsUnexpectedClient remains true and IsHighFrequency remains true at a mean interval near 47 seconds, so DohBeaconScore reaches exactly 2 and the rule still fires — this test confirms the composite scoring degrades gracefully rather than failing closed against a jittered implant.