← All resources

DNS visibility series

The Best DNS Logging on Your Network Is Free, Built-In, and Switched Off

In my previous post, Your DNS Architecture Has a Blind Spot the Size of Active Directory, I covered how to turn on server-side DNS logging in an organizational network. This second part is about the other end of the wire. Client-side DNS logging on Windows, macOS and Linux.

Last time I mentioned how IT tends to put a spoke in this wheel. Here they've got a fresh set of objections, and most of them are wrong too.

"We don't want another agent on the endpoint." You don't need one.

"We already have DNS logs on the server." Different signal, different purpose. They don't overlap the way people assume.

"We'll drown in events." Nobody is suggesting you ship raw client DNS to the SIEM. Filter it at the source, same as last time.

Why client-side at all?

Server-side DNS tells you what your infrastructure did with a query. Client-side tells you who asked, and catches things that never reach a Domain Controller.

Process and user attribution. Server-side says 10.0.1.45 asked for evil-c2.com. Client-side says powershell.exe, PID 4820, running as jsmith, and hands you a process ID to join back to whatever spawned it. It also saves you the DHCP-lease and NAT-table archaeology needed to turn an IP into a machine at 3am.

Local resolution that never hits the wire. A poisoned hosts file, or an answer already in cache, resolves without a single packet leaving the machine. Server-side sees nothing at all.

Off-network endpoints. Laptops on home Wi-Fi, hotel networks, or split-tunnel VPN resolve through whatever local resolver they were handed. No DC involved, no server-side event anywhere.

Endpoints resolving somewhere they shouldn't. If a host's configured resolver is 8.8.8.8 or something stranger, client-side sees the query even though your DC never does.

Why you still need the server side

The CTO is going to say "pick one." The answer is no, and it's three holes deep.

Unmanaged devices. Client-side logging only exists where you can push policy or an agent. IoT, printers, appliances, guest Wi-Fi, BYOD, that one unmanaged server nobody admits owning, all invisible. The DC sees them anyway.

Tamper resistance. Malware with admin or kernel access disables ETW, blinds EDR hooks, and kills log forwarders. Domain Controller logs sit outside the compromised endpoint's blast radius.

Forwarder hijacking. Client logs only show the first leg. Repoint the DC's forwarders and the endpoint still records a clean query to a legitimate internal server, while only events 260/261 show where the DC actually went. (Both covered in Part 1.)

Windows: you don't need Sysmon for this

Windows has logged DNS client activity for years. It's off by default, which is why almost nobody knows it's there.

Sysmon's DNS event isn't magic, and it isn't Sysmon's. Event ID 22 is a thin consumer of the Microsoft-Windows-DNS-Client ETW provider. You can read the same source without installing anything, because Windows already ships a channel fed by it.

# Enable the channel AND raise its size in one go. The default is tiny.
wevtutil sl Microsoft-Windows-DNS-Client/Operational /enabled:true /ms:104857600  # 100 MB

# Read it back
Get-WinEvent -LogName "Microsoft-Windows-DNS-Client/Operational" |
    Where-Object Id -eq 3008 | Select-Object -First 20

Set that size deliberately, because the default is 1 MB. On an active workstation a 1 MB circular buffer overwrites itself in minutes, so without /ms you've enabled a log that can't retain anything long enough to be useful.

Microsoft's own Windows Event Forwarding guidance for intrusion detection recommends collecting 3008.

Event 3008 is the one you want. "Query completed", emitted once per lookup. I ran this on a Windows 11 machine to check the fields rather than trust the docs, and it carries more than I expected.

QueryName    = example.com
QueryType    = 28                          (1 = A, 28 = AAAA)
QueryStatus  = 0                           (0 = success, 9003 = NXDOMAIN)
QueryResults = 2606:4700:10::6814:179a; ... ;::ffff:104.20.23.154;

Name, record type, success or failure, and the actual resolved addresses in a structured field. Worth pausing on if you read Part 1 - the Windows server analytical log has no answer field at all, the resolved IP is buried in a raw PacketData blob you have to parse. The client channel just hands it to you, the easier source is also the richer one.

3008 is the one I'd keep if I had to skimp on events. But enabling the channel gets you the whole resolution chain, and a few of the other events pull their weight. I resolved a single fresh name on the same machine and it logged ten of them. Most repeat what 3008 already says, but three don't. 3009 lists every DNS server the machine is configured to use, with the calling PID. 3010 is the query sent to a specific server, carrying that server's IP. 3011 is the response from that server, with a status code.

Here's what those three give you that 3008 doesn't. A 3010 with no matching 3011 is a query that left the machine and never came back, which a dead C2, a tarpit, or a sinkholed exfil domain all look like, and which 3008's status won't separate from an ordinary NXDOMAIN. And 3009 and 3010 tell you which resolver the host actually used, so a host talking to one it shouldn't shows up here before we ever get to rogue resolvers.

The catch is volume, up to ten events per lookup at full tilt. So treat collection as a dial rather than a switch and ship the tier that fits the host.

Baseline, about 1x. 3008 alone. Name, type, status, and the answer, which is all most endpoints need.

Middle, about 3x. Add 3010 and 3011 for the which-server and did-it-answer signal, and fold in 3009 as a near-free config check, since it repeats the same resolver list every lookup and dedupes to almost nothing. This is the tier I'd put on the crown jewels and the most exposed assets, DMZ servers, or the user endpoints out browsing the web.

Full, about 10x. Every event in the chain, including the cache-lookup and wire-call pairs. Worth it on a host under active investigation, overkill everywhere else.

Every event in this channel carries the client's PID, you just have to know where to read it. 3006 and 3008 are emitted in the calling process's own context, so the event's own process ID is the real client, not svchost. The service-emitted events (3009, 3010, 3011, and the cache and wire ones) run as svchost instead, but carry an explicit client PID field naming the real caller. A lookup from a PowerShell at PID 8124 showed 8124 either way, as the process ID on 3008 and as the client PID on 3010 and 3011, while svchost (PID 2056) was only ever the emitter. No Sysmon, no correlation gymnastics.

To turn that PID into image, parent, and user, pair it with Windows' native process-creation auditing, which, like everything else here, is off by default.

# Log every process creation as event 4688 (Security log)
auditpol /set /subcategory:"Process Creation" /success:enable

# Optional but worth it: include the full command line on 4688
reg add "HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System\Audit" `
    /v ProcessCreationIncludeCmdLine_Enabled /t REG_DWORD /d 1 /f

Join 4688 to 3008 on the PID and you've rebuilt the whole story natively. excel.exe spawned powershell.exe (PID 4820), running as jsmith, which resolved evil-c2.com and got back an answer. Sysmon's DNS event has no parent and no command line, so this actually gives you more context than EID 22. You just do the join yourself.

Two gotchas I hit. 4688 writes NewProcessId in hex while the DNS event's PID is decimal, so convert before joining, and PIDs get reused, so window the join on time. 4688 also fires on every process start, so it's a real volume decision, not a free one.

It also catches a lot you don't want in the SIEM. As promised, filter at the source. Most of the volume is routine plumbing rather than anything a user or attacker did, internal domains, approved Microsoft and Google hostnames, connectivity checks, and the expected SOA, PTR, and service-record chatter. Drop that at the log shipper, the same way we dropped internal zones server-side, and keep everything else.

One thing I would not drop is WPAD, LLMNR, and mDNS. The volume is negligible, and their presence is a finding on its own, they're spoofing and relay vectors you'd want disabled, so a host emitting them at all still has them turned on.

macOS: it's already on, but nobody can make sense of it

This surprised me more than anything else I tested. macOS has the best out-of-the-box combination of persistence and process attribution of the three, and it's on already, a byproduct of unified logging rather than anything DNS-specific.

mDNSResponder writes to the unified log at Default level, which means it persists. On a stock machine I pulled 60 days of history, 143 MB, that nobody had configured. And it carries process attribution for free.

DNSServiceQueryRecord(1D000, 0, <mask.hash: 'xa30jMpSGVWeoy...'>, Addr) START PID[8361](firefox)

PID, process name, query type, START/STOP timing. Windows makes you resolve the process yourself. macOS just tells you.

Read it live or after the fact.

log stream --predicate 'process == "mDNSResponder"' --info
log show --last 24h --predicate 'process == "mDNSResponder"'

The catch is in that log line. The query name is <mask.hash: '...'>. Apple masks it by default, and there is no way to unmask it from a shell. log config --mode "private_data:on" is rejected outright on the versions I tested, and raising the log level to debug changes nothing.

The only mechanism is a com.apple.system.logging configuration profile with Enable-Private-Data, installed through System Preferences or pushed by MDM. With that profile in place I got clean output immediately. Zero mask.hash values, query names in cleartext, in both the live stream and the persisted log.

Two things to know before you push it fleet-wide. It's system-wide across every subsystem, not DNS-specific, so everything any process logs as private becomes readable. And it's forward-only. The 60 days already on disk stay hashed.

You just can't read the names without a deliberate, fleet-wide privacy decision.

Linux: native gets you names, eBPF gets you the process

Here I break my own promise, but not as badly as you'd expect.

Native first. systemd-resolved ships resolvectl monitor, which needs no install (it ran without root in my testing) and streams queries and full answers.

→ Q: 10-3-2-125.nip.io IN A
← S: success
← A: 10-3-2-125.nip.io IN A 10.3.2.125

It also does JSON, so it pipes straight into a log shipper.

stdbuf -oL resolvectl monitor --json=short >> /var/log/dns-monitor.jsonl

Two gotchas that cost me time. It block-buffers when piped, so without stdbuf -oL you lose everything on kill. And the JSON events carry no timestamp, so you stamp them at ingest.

Which brings me to the actual problem. The JSON carries no client identity at all. No pid, no uid, no comm, no exe. And resolvectl monitor is a live stream, not a log, so nothing is retained. You can turn on resolvectl log-level debug to get retention in the journal, but it only attributes D-Bus clients, and on a standard Ubuntu system nsswitch.conf routes getaddrinfo through the stub listener instead, so ordinary applications land in the unattributed bucket anyway.

That's the gap eBPF can fill, because a syscall-level probe runs in the calling process's context, so the PID is valid at the moment of the query. I measured it against six resolution paths and it caught every one, including a hand-built raw socket that resolvectl monitor never saw.

Writing your own is more work than it looks. A reliable probe has to watch more than one syscall, since different resolvers put the query on the wire different ways and hooking only the obvious sendto misses some paths. So don't roll your own, reach for a tool that already solved it. Aqua's Tracee ships a DNS event that carries the query and the process that made it, and runs as a single container.

docker run --rm -it --pid=host --cgroupns=host --privileged \
    -v /etc/os-release:/etc/os-release-host:ro \
    aquasec/tracee:latest --output json --events net_packet_dns_request

Each event hands you the query name, type, and class next to the PID and process name, which is exactly the attribution resolvectl monitor won't give you. In my testing it tagged a plain getent lookup and a browser's resolve each with the process behind it. That os-release mount isn't optional, Tracee refuses to start without it. bpftrace and bpfcc-tools are there if you'd rather build something bespoke, but they're a toolkit, not a DNS logger.

One availability note. resolvectl monitor landed in systemd 252, so it's on Ubuntu 23.04 and newer (24.04 for LTS users) and RHEL 9 and newer. Earlier releases have systemd-resolved but not the monitor subcommand.

Light at the End of the DNS Tunnel

Our starting point looks bleak. It's 2026, and not one of the three gives you DNS visibility by default. Windows logs it and ships with the log switched off. macOS logs it and hashes the names so you can't read them. Linux doesn't log it at all, and couldn't tell you which process asked if it did. It's as if the vendors worked to keep you blind to your own DNS.

And yet, the DNS visibility crisis turns out to be mostly a case of nobody reaching for the light switch. The fix takes no third-party tools (Linux is the only exception, and even there you can do without if you're willing to give up process attribution), no licence fees, and no ninja-level configuration. Turning it on jumps your forensic readiness out of the middle ages more or less instantly, I've watched dozens of cases where that one field was the difference between hours of manual dumpster-diving and an incident folded up in minutes. The vendors left all of this lying on the floor. We just have to pick it up.