home / malware / doh-resolver-beacon
DoH beacon: the name that never reaches DNS telemetry
2026-10-28 · self-authored specimen · Windows 11 23H2, isolated range
At a glance
- Question
- When a client resolves its command-and-control name over HTTPS, does anything on the host or the wire still name the destination — and is that enough to build a rule on?
- Specimen
doh-sync, a 14 KB unsigned .NET 8 console application written for this study. It builds one DNS query, sends it to a DoH resolver, makes a single 128-byte POST to the address it gets back, and exits. No persistence, no second stage, no command handling.- Platform
- Windows 11 23H2 (22631), x64, virtualised guest with a snapshot taken before the first run.
- Tooling
- dnSpy for static analysis, Sysmon 15.15 for host telemetry, Zeek 7.0 on the range sensor, Sigma for the rule.
- Environment
- Default-deny VLAN with no route out;
lab-dohat10.20.30.41serving one zone andlab-c2at10.20.30.42. No real credentials, no real data, reverted to snapshot after each run. See the isolated research range. - Status
- COMPLETE — two runs, one Windows build, one resolver, one sensor.
- Artifacts
- None. The specimen is withheld under the sanitization policy; what is published is the telemetry, the analysis and the rule.
Objective and scope
The question is narrow on purpose. Query-name detection is the cheapest network detection a SOC owns, and it assumes the name travels in clear text. This study builds the smallest client that takes the name out of clear text and asks what a defender is left with.
In scope: the DoH exchange, the address-based connection that follows it, and the metadata both leave behind. Out of scope: a browser's built-in DoH, Encrypted Client Hello, HTTP/3, and any resolver reachable only through a CDN — those are different problems and this evidence says nothing about them. Deliberately not tested: DoH configured system-wide by policy on the endpoint, and queries padded to a fixed length.
Laboratory environment
written before the runA flat monitored VLAN with no gateway. The guest can reach the resolver and the stub server and nothing else; neither can reach outside the range.
The blind spots matter as much as the coverage. Zeek's HTTP analyser reads clear text, so an HTTP/2 exchange inside TLS produces a conn record and an ssl record and no http record at all. The resolver's log names the query because the resolver is ours and logs at the application layer; a public resolver would give a defender nothing. And no EDR product sits in the path, so none of the conclusions below is a claim about EDR.
| Host | Role | Telemetry |
|---|---|---|
lab-win11 | Analyst-controlled guest, local administrator for the run | Sysmon 15.15: process create (1), network connect (3), DNS query (22). Windows Security 4688 with command-line auditing. No Defender real-time telemetry: protection is disabled in the range by design. |
lab-doh | DoH resolver, 10.20.30.41:443, serving one lab zone | Its own application log with query name, type, protocol and answer. Not a network sensor: it sees only queries sent to it. |
lab-c2 | Beacon endpoint, 10.20.30.42:8443 | Its own request log, used only to confirm the POST arrived. It holds no second stage. |
lab-sensor | Zeek 7.0 on the VLAN span port | conn, ssl, dns, http. No decryption, so nothing inside TLS is parsed. |
Methodology
- The resolver and beacon endpoint were provisioned before the run. Both use self-signed certificates, so the client's certificate handling is a property recorded in the run rather than inferred from a vendor default.
- A positive control came first:
Resolve-DnsName control.lab.internalfrom the guest, which must appear in both Sysmon 22 and Zeek'sdns.log. If the control had produced nothing, an empty log during the run would have proved nothing about the specimen. It produced both. - Snapshot revert, then one execution from
C:\Users\lab\Desktop\, followed by ten minutes of guest idle time so any record in the window belongs to the specimen. - The run was repeated from the same snapshot and the records compared field by field. Static analysis came afterwards, so the host records were read without knowing the intended sequence.
- The control query was deliberately made in a separate window from the run, so it cannot be mistaken for a record of the specimen's own resolution.
Evidence and observations
one record per claimTimestamps are UTC on 2026-10-27. Records are reproduced as captured and sanitised: addresses and names are the range's own, and no hash is published because the specimen is withheld.
EV-001An unsigned console binary runs once from the desktop and does nothing else observable yetFACT
Sysmon 1 2026-10-27 10:02:11.480 Image C:\Users\lab\Desktop\doh-sync.exe CommandLine "C:\Users\lab\Desktop\doh-sync.exe" ParentImage C:\Windows\explorer.exe User LAB\lab Signed false Integrity Medium
Interpretation. A double-click, nothing more. The record proves execution and nothing about intent, which is where the question in this study starts: everything after this event is what a defender has to work with.
EV-002The DoH query appears on the wire only as a TLS session to the resolverFACT
zeek conn.log ts 2026-10-27T10:02:12.004 id.orig_h 10.20.30.50 id.resp_h 10.20.30.41 id.resp_p 443 proto tcp service ssl duration 0.041 orig_bytes 486 resp_bytes 1120 zeek ssl.log server_name doh.lab.internal subject CN=doh.lab.internal issuer CN=doh.lab.internal validation_status self signed certificate version TLSv13 ja3 6f0d8e1a9c3b4f27d05a8e6b1c9347d2 (no zeek http.log entry: the request is HTTP/2 inside TLS)
Interpretation. SNI names the resolver, not the name being resolved. The byte counts are consistent with a small POST but establish nothing about its content, and the absence of an http record is a property of the sensor, not of the traffic. A TLS-inspecting proxy would see more; nothing in this range does.
EV-003The only record containing the queried name is the resolver's own logFACT
/var/log/doh/access.log 2026-10-27T10:02:12.037Z 10.20.30.50 h2 POST /dns-query qname=c2.lab.internal qtype=A rcode=NOERROR answer=10.20.30.42
Interpretation. This is the proof that the lookup happened and what was asked, and it is also the limitation: the record exists because the resolver is ours and logs at the application layer. Against a resolver the defender does not operate, this line does not exist, and neither does any substitute for it in the network metadata.
EV-004No DNS record for the name on host or wire, while the control lookup produced bothCORROBORATED
$ zeek-cut query < dns.log | sort | uniq -c
4 range-gw.lab.internal
1 control.lab.internal
$ grep -c c2.lab.internal dns.log
0
PS> $win = Get-WinEvent -FilterHashtable @{
>> LogName='Microsoft-Windows-Sysmon/Operational'; Id=22 }
PS> ($win | Where-Object { $_.TimeCreated -ge '2026-10-27 10:02:00'
>> -and $_.TimeCreated -le '2026-10-27 10:12:00' }).Count
0
PS> ($win | Where-Object { $_.TimeCreated -ge '2026-10-27 10:00:00'
>> -and $_.TimeCreated -lt '2026-10-27 10:01:00' }).Count
1Interpretation. Two independent absences, each with a positive control in the same pipeline: the sensor and the endpoint both recorded a name they were supposed to record. That is what turns "I found nothing" into a finding. The zero is in the run window only — the control's single event is in a different window by design, so it cannot be read as the specimen resolving anything.
EV-005The beacon itself carries no hostname: direct-to-IP TLS with no SNIFACT
Sysmon 3 2026-10-27 10:02:12.311 Image C:\Users\lab\Desktop\doh-sync.exe Destination 10.20.30.42:8443 Protocol tcp Initiated true zeek ssl.log server_name - subject CN=lab-c2 issuer CN=lab-c2 validation_status self signed certificate ja3 6f0d8e1a9c3b4f27d05a8e6b1c9347d2 zeek http.log (none: TLS)
Interpretation. The address came from the resolver, and nothing on the wire connects the two events except timing and the same process image. That correlation is available to an analyst with both logs and is not available to a rule that watches either log alone.
EV-006Static analysis: the name exists only in the specimen's config, and the client accepts any certificateFACT
dnSpy, Program.Main():
BuildQuery("c2.lab.internal", A) dns wire format, then base64url
DoH() POST https://10.20.30.41/dns-query
Content-Type: application/dns-message
Beacon() POST https://10.20.30.42:8443/api/v2/sync
body 128 bytes, then exit after 10s
decoded config (inert text):
{"doh":"https://10.20.30.41/dns-query",
"name":"c2.lab.internal",
"c2":"https://10.20.30.42:8443/api/v2/sync"}
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
No persistence API, no process creation, no download, no second stage.Interpretation. The name is a literal in the config and nowhere else, so a hash or string-based rule would have to match a name a rebuild can change in one line. Certificate validation is disabled, so trust in a resolver's certificate is not a control against this client — only the network path is.
Findings
| Finding | Evidence | Confidence |
|---|---|---|
| The command-and-control name appears in neither the endpoint's DNS events nor the sensor's DNS log, while a control lookup through the same pipeline produced both. | EV-002, EV-004 | CORROBORATED |
| The DoH exchange is visible as a TLS session to the resolver, with SNI naming the resolver rather than the name being resolved. | EV-002 | FACT |
| The only record of the queried name is the resolver's application log, which exists because the resolver is operated inside the range. | EV-003 | FACT |
| The beacon has no hostname on the wire at all: direct to address, no SNI, self-signed certificate. | EV-005 | FACT |
| Detection of DoH has to key on destination and process, not on the queried name. | EV-002, EV-003, EV-005 | INFERENCE |
| Public DoH endpoint lists do not apply to this specimen: its resolver is a private address, so the rule below would not fire on it. | EV-002, EV-006 | INFERENCE |
| This build performs one lookup and one beacon and then exits; it has no persistence and no second stage. | EV-006 | FACT |
Detection
what the rule can and cannot seeThe rule below is a tripwire for commodity DoH: a process that is not a browser opening TLS to a well-known public resolver. It is worth deploying, and it is not coverage for the specimen in this study. The specimen's resolver is a private address, so the rule cannot see the query at all — which is the finding, not a footnote to it.
title: Non-Browser Process Contacts A Public DoH Resolver
id: 7c4e1b83-5a2f-4d6e-9b18-3f0a2c7d5e94
status: experimental
description: A process other than a browser opens TLS to a known DNS-over-HTTPS resolver, where the queried names never reach DNS telemetry.
references:
- https://www.rfc-editor.org/rfc/rfc8484.html
- https://attack.mitre.org/techniques/T1071/004/
author: WMouton
date: 2026/10/28
tags:
- attack.command_and_control
- attack.t1071.004
logsource:
category: network_connection
product: windows
detection:
selection_resolver:
DestinationHostname|endswith:
- 'dns.google'
- 'cloudflare-dns.com'
- 'dns.quad9.net'
- 'doh.opendns.com'
- 'dns.nextdns.io'
filter_browser:
Image|endswith:
- '\chrome.exe'
- '\msedge.exe'
- '\firefox.exe'
condition: selection_resolver and not filter_browser
falsepositives:
- EDR and management agents that run their own resolver
- Command-line clients such as curl used with a DoH endpoint
- A browser build or channel missing from the filter list
level: mediumTwo things have to be checked before this is switched on. DestinationHostname is populated by the product — from a reverse lookup, the DNS client cache or SNI, depending on the sensor — and sometimes not at all: on a Sysmon-only deployment it is often empty for direct-to-IP traffic, so test the field on your own data before trusting the match. And the resolver list is an enumeration of public services, which means it detects the commodity case and misses a resolver the adversary operates or abuses. Neither caveat can be tuned away, because both are about what the telemetry contains.
What covers the private-resolver case is not a rule but a control, discussed in Mitigation. Reading this rule as DoH coverage in general is the mistake worth avoiding.
| Signal | Data source | What it cannot see |
|---|---|---|
| DNS query name | Sysmon 22, Zeek dns.log, or a DNS server log | Any DoH lookup: the name is inside TLS (EV-004) |
| TLS server name (SNI) | Zeek ssl.log, proxy logs, TLS-inspecting egress | The queried name; Encrypted Client Hello; and direct-to-IP connections that send no SNI (EV-005) |
| Contact with a listed public resolver | Proxy or firewall logs; Sysmon 3 when the product resolves the destination name | A private resolver, a CDN-fronted resolver, and a resolver that is allow-listed for other reasons (EV-002, EV-006) |
Mitigation
what removes the behaviour, and what only raises the costRemoving the behaviour. Deny direct HTTPS to resolvers you do not operate, and force endpoints to use the resolver you do: proxy configuration, firewall egress rules, and the Windows and browser DoH settings applied as policy. In this range the specimen's first POST fails the moment egress to 10.20.30.41 is denied, so the name never leaves the host — the only control here that removes the blind spot rather than alerting on it.
Raising the cost. Log the resolver you own, at the application layer, and keep the query log long enough to answer a question asked a week later. The line in EV-003 is the only record that named c2.lab.internal, and it exists because the resolver sits inside the range. Consent to that logging is a policy question as much as a technical one, and it belongs in the same review as the retention period.
Validation and reproducibility
Everything below was run against the published records and re-run after the second execution. The zero is the check: if a rebuild falls back to plain DNS, the counts stop matching in both directions at once.
# resolver: the name appears once, server-side
$ grep -c 'c2.lab.internal' /var/log/doh/access.log
1
# sensor: the control name is present, the C2 name is not
$ zeek-cut query < dns.log | sort | uniq -c
4 range-gw.lab.internal
1 control.lab.internal
$ grep -c c2.lab.internal dns.log
0
# guest: no DNS client event in the run window, one for the control
PS> (Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational'; Id=22} |
Where-Object { $_.TimeCreated -ge '2026-10-27 10:02:00' -and $_.TimeCreated -le '2026-10-27 10:12:00' }).Count
0
# guest: the beacon is a TLS connection to an address, with no SNI
$ zeek-cut id.resp_h id.resp_p server_name < ssl.log | grep 10.20.30.42
10.20.30.42 8443 -The drift check is symmetric. A build that reverted to plain DNS adds one c2.lab.internal line to dns.log, one Sysmon 22 event in the run window, and loses the resolver log line; a build that switched to a public DoH resolver keeps the name out of DNS telemetry but trips the rule in the previous section. Both directions are visible in the same four commands, which is what makes the check usable on a specimen nobody can download.
Limitations and open questions
- DISPROVEN The working hypothesis was that Sysmon 22 would record the query, since event 22 is produced by the endpoint's DNS Client stack and the specimen does resolve a name. It does not: the client speaks HTTPS to the resolver itself, so that stack is never used, and the count is zero in the run window while the control produced one (EV-004). A rule built on that assumption would have looked correct and seen nothing.
- OPEN Encrypted Client Hello was not tested. With ECH the SNI would be a placeholder rather than the resolver's name, so even the weakest remaining signal narrows.
- OPEN The client's transport was TCP by design; HTTP/3 over QUIC was not tested and would remove TLS record structure from the sensor's view entirely.
- BENIGN The same guest's update and connectivity checks resolve names over plain DNS and appear in both
dns.logand Sysmon 22. That is what the normal path looks like in this environment, and it is the baseline the zero in EV-004 is measured against. - Two runs, one Windows build, one resolver, one sensor, no EDR and no decryption. Conclusions about products, resolver software or browser behaviour are outside what this evidence supports.
References
- RFC 8484 DNS Queries over HTTPS.
- RFC 8467 Padding Policies for EDNS(0), the reason query length is a weak signal at best.
- MITRE ATT&CK T1071.004 Application Layer Protocol: DNS.
- Sysmon event reference (1, 3, 22), and the Zeek SSL analyser fields used above.
- Sigma specification for the rule format.
- On this site: the isolated research range this ran in, the sanitization policy that keeps the specimen offline, and the detection set.
Tags
#malware#windows#dotnet#dns#detection