DNS visibility series
Your DNS Architecture Has a Blind Spot the Size of Active Directory
So I've been thinking about writing a series of posts on common security visibility gaps, things that stall too many incident responses right as they start. DNS easily takes one of the top spots.
It's 2026, and no modern OS gives you useful, persistent DNS security telemetry out of the box. Not Windows, not macOS, not Linux. The mechanisms exist (Windows DNS-Client ETW provider, macOS mDNSResponder, Linux systemd-resolved), but none of them ships configured as a security telemetry pipeline, and all of them only see resolution routed through the standard OS resolver. The per-OS specifics deserve their own post.
A process asks to resolve a hostname so that it can connect to it. If you want to know who did it, what name it tried to resolve, what answer it got, or whether that happened at all, there's no way to know unless you deploy third-party kernel hooks, complex network monitoring, or EDR. And if you're thinking "my EDR takes care of this," please hold that thought for a little while, I promise to get there after we cover the basics.
This post is the first in a series about DNS (in)visibility. Here we'll dig into server-side logging.
Why Didn't You Just Turn On DNS Logging?
I asked this question so many times until I gave up. I remember asking it at a bank while investigating a breach: "We had DNS logging turned on for a while some time ago, but it overwhelmed our storage and hurt retention so we killed it."
The IT dudes usually say "keep your hands off my Active Directory servers." They aren't entirely wrong to be cautious. Legacy text-based debug logging (dns.log) wrote synchronously to disk and destroyed I/O performance. But Microsoft replaced that with Event Tracing for Windows (ETW) Analytical Logging years ago.
Analytical logging is an ETW channel, designed for low-overhead tracing rather than synchronous per-event disk writes. Microsoft's performance benchmarks show no measurable CPU impact under 50,000 Queries Per Second (QPS), and only ~5% impact at extreme loads reaching 100,000 QPS. If a server undergoes severe stress, Windows gracefully drops log events rather than freezing the DNS service.
Although turning this on is an obvious quick win, you still have to get past the "it works, don't touch it" mentality and ensure all endpoints use only designated DNS servers. I'm used to getting "sure, everyone's on AD DNS here", just to find out later that is ~95% true (=100% not true), that anyone can configure manual resolvers, and that no controls exist to detect unapproved outbound DNS traffic.
To enable Analytical logging cleanly, run these native wevtutil commands on your DCs. Windows requires the channel to be temporarily disabled before changing buffer properties.
# Temporarily disable the channel to modify properties
wevtutil sl "Microsoft-Windows-DNSServer/Analytical" /e:false
# Cap log size at 500MB circular buffer (overwrites oldest events when full)
wevtutil sl "Microsoft-Windows-DNSServer/Analytical" /ms:524288000
# Enable the analytical channel
wevtutil sl "Microsoft-Windows-DNSServer/Analytical" /e:true /q:true
The 4-Leg DNS Lifecycle
This is the cache-miss path specifically. Answers served from cache, from an authoritative zone, or by DNS policy never generate 260 or 261 at all. When an endpoint queries an external domain and your DC has to go and fetch the answer, a four-step choreography happens:
- Event ID 256 (
QUERY_RECEIVED): Endpoint asks the DC for domain resolution. - Event ID 260 (
RECURSE_QUERY_OUT): DC doesn't have a local answer, so it sends an outbound recursive query to an upstream recursive server, typically a configured forwarder (Cloudflare, Umbrella, ISP), though it can also recurse from root hints. - Event ID 261 (
RECURSE_RESPONSE_IN): Upstream server sends the recursive response back to the DC. - Event ID 257 (
RESPONSE_SUCCESS): DC passes the final answer back to the endpoint.
[Endpoint] --- (256: QUERY_RECEIVED) ------> [ Domain Controller ] --- (260: RECURSE_QUERY_OUT) ------> [Upstream Forwarder]
[Endpoint] <--- (257: RESPONSE_SUCCESS) --- [ Domain Controller ] <--- (261: RECURSE_RESPONSE_IN) --- [Upstream Forwarder]
Correlation Mechanics: XID and QXID
A common mistake is assuming the 16-bit transaction ID (XID) generated by the client is preserved across all four legs. RFC 5452 (written against Kaminsky-style cache poisoning) recommends that recursive resolvers use unpredictable query IDs and source ports on outgoing queries. So don't assume the client's XID is preserved upstream.
Windows solved this explicitly, and it's the nicest detail in the whole schema. Event 260 carries two transaction IDs. XID is the freshly randomized ID used upstream, and QXID is the original client query ID. That gives you a join across the whole path. I've read these relationships off the field names in the manifest rather than confirmed them in live telemetry, so verify before you build detections on them.
256.XID == 257.XID client request -> client response
256.XID == 260.QXID client query -> the recursive query it triggered
260.XID == 261.XID recursive query -> recursive response
Event 262 carries QXID as well, so even a recursive timeout traces back to the client that caused it.
XID is only 16 bits, so on a busy resolver join on XID plus QNAME plus a narrow time window rather than XID alone.
The ETW ActivityID is not worth building on either. It comes through null on parsed DNS Server events.
The Stealth Vector: DC Forwarder Hijacking
Volume-conscious SIEM architects often suggest logging only client-facing events: "Just log 256 and 257 to capture endpoint requests and responses, drop 260 and 261."
Don't fall for this trap. If you log only 256 and 257, you're looking at a closed loop between the endpoint and your DC. You're completely blind to what your DC does when it talks to the outside world.
If an adversary gains domain admin privileges or tampers with network routing, they can modify the DC's forwarders to route upstream resolution through rogue DNS infrastructure.
Under this attack:
- Endpoint logs show a normal query sent to your legitimate internal DC.
- Endpoint EDR shows standard traffic to an internal DC IP address.
- Event IDs 256 and 257 show normal client-to-DC resolution.
The client still talks to the DC, so client logs show zero anomalies. The evidence of the malicious upstream path lives on the DNS server itself, primarily in Event IDs 260 and 261. If the attacker changed the forwarder configuration rather than the routing, it lands in the DNS Server audit channel too, as event 537, and unlike the analytical channel, audit is on by default. Either way, none of it is visible from the endpoint.
Implementing the Noise Filter
Adding recursive tracking increases log volume, but you can cut SIEM costs at the log agent level before events hit your indexers:
- Strip PacketData: Events 256-258, 260 and 261 append a raw hex payload called
PacketData. Dropping this single field at the log agent cuts event size roughly in half. - Filter Internal Noise (on Success Only): Internal AD domains (
*.yourdomain.local), reverse lookups (*.in-addr.arpa), and deployment traffic (like SCCM) generate massive volume. However, you should only drop internal lookups when resolution succeeds. Failed internal queries should be rare, cheap to index, and represent prime hunting ground for misconfigurations, spoofing, and lateral movement. - Learn the actual schema, because it isn't what you'd guess. The query name is
QNAMEon every event. There is noQueryNamefield. The peer address alternates -Sourceon 256, 259 and 261 (whoever sent to us),Destinationon 257, 258, 260 and 262 (whoever we're sending to). There is noClientIPAddressat all. AndRCODEonly exists on 257 and 258, so your name filters have to handle events with no response code, or you'll keep every query event you meant to drop. Winlogbeat also treatsevent_datavalues as strings, so compare against"0"and not integer0. - Watch the trailing dot. Names here are fully qualified and can carry the terminal dot (
host.yourdomain.local.), so anchor with\.?$rather thanlocal$. I think that's consistent across versions, but it costs nothing to be tolerant either way.
There's a real trade-off buried in the strip-PacketData step that's worth stating plainly. Look at what event 257 actually contains: QNAME, QTYPE, RCODE, XID, Destination, timing, and flags. There is no field holding the resolved IP address. The answer exists only inside PacketData, as raw bytes. So you can have the resolved address or you can have an affordable log, but Windows won't hand you both in structured form. You'll probably need client-side DNS logging before you can promise anyone a "DNS answers" dashboard (the next post in the series covers that).
In environments I've worked with, filtering successful internal noise has yielded 10x to 20x reductions in DNS SIEM log volume. I remember discovering at one major financial institution that their servers were intensely trying to resolve their own hostnames (and failing), accounting for over half of all organizational DNS traffic. Because their failures weren't dropped by the noise filter, we caught the loop, fixed the application config, and saved a hefty chunk of daily ingestion capacity.
A starting point for Winlogbeat, handling both field schemas, the field type quirks, and response codes, while keeping response failures and recursive timeouts.
winlogbeat.event_logs:
- name: Microsoft-Windows-DNSServer/Analytical
# 256/257 client leg, 260/261 recursive leg, 258/262 failures.
# 259 (IGNORED_QUERY) omitted - add it if you care about malformed inbound.
event_id: 256, 257, 258, 260, 261, 262
processors:
# Strip the raw hex packet payload. Note this is also where the
# resolved address lives - see the trade-off above.
- drop_fields:
fields: ["winlog.event_data.PacketData"]
ignore_missing: true
# Drop internal noise, but only where nothing failed.
# RCODE exists on 257 and 258 only, so events without it are
# queries and are safe to drop on name alone.
- drop_event:
when:
and:
- or:
- not:
has_fields: ['winlog.event_data.RCODE']
- equals:
winlog.event_data.RCODE: "0"
- or:
# QNAME is the name field on every event. Trailing dot optional.
- regexp:
winlog.event_data.QNAME: '.*\.yourdomain\.local\.?$'
- regexp:
winlog.event_data.QNAME: '.*\.in-addr\.arpa\.?$'
- regexp:
winlog.event_data.QNAME: '.*\.sccm-cluster\.internal\.?$'
# Drop DNS traffic to/from a known infrastructure host. Adjust or
# remove - this is an example, not a recommendation.
- drop_event:
when:
or:
- equals:
winlog.event_data.Source: "10.0.1.10"
- equals:
winlog.event_data.Destination: "10.0.1.10"
Core SIEM Field Mapping
The fields worth extracting and indexing. This is the core set, not the full modern event catalogue:
Field (winlog.event_data.*) | Event IDs | Description | Forensic Purpose |
|---|---|---|---|
QNAME | all | Query name | The domain being resolved. Same field name on every event |
QTYPE | all | Record type | A, AAAA, TXT, CNAME. Record-type anomalies can contribute to tunnelling detection |
XID | all | 16-bit transaction ID | Per-leg only. Randomized on the upstream query |
QXID | 260, 262 | Original client transaction ID | Stitches the recursive leg back to the client query |
Source | 256, 259, 261 | Sender address | The client on 256, the upstream forwarder on 261 |
Destination | 257, 258, 260, 262 | Recipient address | The client on 257, the upstream forwarder on 260 |
RCODE | 257, 258 | Response code | 0 success, 3 NXDOMAIN. Absent on every other event |
Reason | 258, 259 | Failure reason | Why the server failed or ignored the query |
ElapsedTime | 257, 258 | Server-side elapsed time | Latency outliers are worth a look |
PacketData | 256-258, 260, 261 | Raw DNS packet | Optional. Contains the answer, and also the bulk of your volume |
The field names above are from the Server 2019 manifest, so if you're on a different version, spot-check them before you build parsers. And I've covered Microsoft DNS here because that's what I mostly find at clients. If you're running Linux resolvers or forwarders, message me and I'll do my best to help.
Get this far and you're halfway. Next post is the other half, the client, which sees things your DC never will.