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-doh at 10.20.30.41 serving one zone and lab-c2 at 10.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 run

A 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.

HostRoleTelemetry
lab-win11Analyst-controlled guest, local administrator for the runSysmon 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-dohDoH resolver, 10.20.30.41:443, serving one lab zoneIts own application log with query name, type, protocol and answer. Not a network sensor: it sees only queries sent to it.
lab-c2Beacon endpoint, 10.20.30.42:8443Its own request log, used only to confirm the POST arrived. It holds no second stage.
lab-sensorZeek 7.0 on the VLAN span portconn, ssl, dns, http. No decryption, so nothing inside TLS is parsed.

Methodology

  1. 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.
  2. A positive control came first: Resolve-DnsName control.lab.internal from the guest, which must appear in both Sysmon 22 and Zeek's dns.log. If the control had produced nothing, an empty log during the run would have proved nothing about the specimen. It produced both.
  3. 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.
  4. 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.
  5. 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 claim

Timestamps 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

source sysmon operational log, exported XML · sha256 not published (specimen withheld)

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)

source zeek conn.log and ssl.log on lab-sensor · sha256 not published

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

source application log on lab-doh · server-side, inside the range

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
1

source zeek dns.log; sysmon event 22 on lab-win11; control query made at 10:00:31, run window 10:02:11–10:02:20

Interpretation. 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)

source sysmon operational log; zeek ssl.log · sha256 not published

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.

source dnSpy decompilation of the specimen, read after the run

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

FindingEvidenceConfidence
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-004CORROBORATED
The DoH exchange is visible as a TLS session to the resolver, with SNI naming the resolver rather than the name being resolved.EV-002FACT
The only record of the queried name is the resolver's application log, which exists because the resolver is operated inside the range.EV-003FACT
The beacon has no hostname on the wire at all: direct to address, no SNI, self-signed certificate.EV-005FACT
Detection of DoH has to key on destination and process, not on the queried name.EV-002, EV-003, EV-005INFERENCE
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-006INFERENCE
This build performs one lookup and one beacon and then exits; it has no persistence and no second stage.EV-006FACT

Detection

what the rule can and cannot see

The 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: medium

Two 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.

SignalData sourceWhat it cannot see
DNS query nameSysmon 22, Zeek dns.log, or a DNS server logAny DoH lookup: the name is inside TLS (EV-004)
TLS server name (SNI)Zeek ssl.log, proxy logs, TLS-inspecting egressThe queried name; Encrypted Client Hello; and direct-to-IP connections that send no SNI (EV-005)
Contact with a listed public resolverProxy or firewall logs; Sysmon 3 when the product resolves the destination nameA 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 cost

Removing 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.log and 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

All malware research

Tags

#malware#windows#dotnet#dns#detection

Related

shares a tag