← All resources

DNS visibility series

The Most Expensive Breach on Earth Still Speaks Plaintext DNS

Third piece in the DNS (in)visibility series. Part 1 turned on server-side DNS logging, part 2 did the client side. If you made it this far, or you jumped ahead because your servers and clients are already logging, this is the part where forensic readiness starts paying. Good DNS telemetry, kept long enough to matter, can turn an investigation that takes days into one that takes an hour.

The SolarWinds hack, for example, got attributed to "nation-state" hackers, which always sounds like "oh, there was nothing the poor things could do". I beg to differ. Nation-state crews breach organizations in dumb, straightforward ways all the time, especially when nothing about the target demands more. Why would anyone spend a million on a break-in he can have for ten bucks? For the most part I regard the nation-state label as an excuse to deflect the debate from the real problem, which is sloppy security and worse practice.

The SolarWinds hackers established command and control (C2) through DNS, using encoded subdomains of avsvmcloud[.]com, before handing selected victims off by CNAME to a second stage over HTTP (panhardware[.]com, databasegalore[.]com, freescanonline[.]com, thedoccloud[.]com, deftsecurity[.]com, according to SolarWinds). This is something that could have been easily detected if only the security team paid attention to DNS. The domains are "look-alikes" but were never contacted prior to the breach, the domain had sat parked since 2018 and woke up in February 2020, the request/response frequency was unlike any normal resolve, the subdomains that carried the data out didn't look like anything legit. In short, it just took someone to look, and no one did.

Hence, "nation-state" has nothing to do with the stealthiness here. DNS tunnelling was first proposed in 1998, had widely available tools by 2000 and was pretty mainstream by 2004. The attackers were inside SolarWinds from September 2019 and it went public in December 2020.

Your SIEM is lying to you about IP addresses

Before we dive into the detections that DNS visibility opens up, I want to address a core problem in how IP addresses get resolved in modern SIEM products. You're handed netflow logs. Timestamp, protocol, source IP, source port, destination IP, destination port. Nobody can do much with a bare IP address, so SIEM vendors (and firewall, WAF and proxy logs too) are happy to provide "enrichment". How do they do that? The labor-intensive route is to cross-reference DHCP, Active Directory and VPN logs to match IPs to hostnames. This rarely gets done. The "easy" path is a reverse DNS lookup. In theory that sounds fine (forget the latency and the overhead), but the majority of public IP addresses have no pointer record (PTR) configured.

As a result, the security analyst is either handed "no-resolve" (reverse DNS lookup returned nothing), or, possibly worse, a hostname that does not reliably represent the IP at hand. Worse, because "no-resolve" tells you nothing and a wrong hostname tells you something false. An IP address is like a hotel room number. If a guest causes trouble in Room 302 at 2:00 AM, looking up who is checked into Room 302 the next afternoon tells you absolutely nothing about who caused the damage. To know who was actually there, you have to look at the check-in desk log (the DNS request/response) right before the incident happened. Anything else is just guesswork.

To fix this you stop looking IPs up and start remembering them. It's called passive DNS. Stream your client-side and server-side DNS into a lookup table keyed on the resolved address, hold each entry for roughly its TTL, and stamp it with the time of the answer. When a firewall or netflow record hits the SIEM, join it against that table on address and time. Now the flow carries the name the host actually asked for, at the moment it asked, instead of whatever that address happens to point at today.

You get a second thing for free. Any outbound connection with no matching answer in that table is traffic to an address nobody looked up. Hardcoded C2, a poisoned hosts file, or an endpoint resolving somewhere you can't see. That's a detection on its own.

I'm not going into implementation instructions here because that really depends on your SIEM, but the vendor guides cover it. Splunk does it with KV Store lookups, Sentinel with a pre-populated dynamic Watchlist of IP-to-domain pairs, Google SecOps with schema-on-ingest enrichment via UDM, Elastic with ECS and transform jobs. Most SIEMs have some way of doing the same thing.

How to read the rules

A note on format before we start. I'm writing these in Sigma rather than any one product's query language, because Sigma converts. Whatever you're running, there's a backend that'll turn these into your own dialect, and where there isn't, the YAML is plain enough to translate by hand in a few minutes. Treat them as templates rather than something to paste into production. None of them survives contact with your own traffic untuned.

They're written for Windows endpoints and Windows DNS servers, logging what parts 1 and 2 switched on. The logic carries over to macOS and Linux clients but the field names don't, so if you're feeding those into the same pipeline you'll be rewriting the logsource and the mappings before any of this fires.

Two things about Sigma are worth knowing before the rules make sense. It's stateless by design, which is awkward when the signal is the count. SigmaHQ has had a Possible DNS Tunneling rule since 2019 that counts queries per parent domain, and it still sits in their unsupported/ folder because that aggregation won't convert to anything. The modern answer is a correlation rule, which wraps a plain matching rule in a counting layer the backend compiles separately.

The other is that lists belonging to your environment don't go inside a rule. Your approved resolvers and your threat feed live in a pipeline, and the rule refers to them by name with |expand. The spec is strict about it, a tool that can't resolve a placeholder has to reject the rule outright rather than quietly matching nothing. Here's the pipeline every rule below refers to.

# dns-detections-pipeline.yml
name: dns-detections
priority: 10
vars:
    approved_resolvers:
        - 10.0.1.10                  # DC01
        - 10.0.1.11                  # DC02
        - 10.0.2.10                  # branch DC
        - '2001:db8:1::10'           # the same resolvers over v6, or the rule
        - '2001:db8:1::11'           # fires on every single lookup
    threat_feed_domains:
        - 'evil-c2.example'          # populated from your feed at conversion time
    threat_feed_ips:
        - '203.0.113.44'

Those v6 entries aren't decoration. On the Windows 11 box I tested this on, the resolver that actually answered was 2620:0:ccc::2, because the machine preferred the v6 resolver over the v4 one sitting right next to it in the same config. A v4-only approved list alerts on every lookup the host makes.

Rogue DNS Detection

Now that you have DNS visibility, detecting unapproved resolvers is the first thing to do. Every other detection in this post quietly assumes DNS goes where you think it goes, and this is the rule that checks. Here the client-side telemetry proves useful, because the Windows DNS-Client events carry the resolver address. The one missing piece is a list of your organization's approved resolvers, in most cases all the local and cloud AD servers, so even if you don't already have it, IT can produce it in an afternoon. It's ok to start with an incomplete list, the gaps fill up fast during tuning.

Here are two templates, because this detection is worth having twice. The first reads the client logs and tells you which process went where. The second reads your firewall and covers everything you can't install an agent on.

title: DNS Query Sent To An Unapproved Resolver
id: 437f7dc2-22e8-4b2e-b25f-7b27ac87edda
status: experimental
description: Detects a Windows endpoint sending a DNS query to a resolver that is not
    on the approved list. Catches a manually changed resolver, DHCP tampering, and
    malware pointing the host at its own infrastructure.
references:
    - https://redhand.io/resources/dns-client-side-logging?src=resources
author: <you>
date: 2026-09-13
tags:
    - attack.command-and-control
    - attack.t1071.004
    - attack.defense-evasion
    - attack.t1562.001
logsource:
    product: windows
    service: dns-client
    definition: 'Microsoft-Windows-DNS-Client/Operational, event 3010. This is the
        middle collection tier from part 2, not the 3008-only baseline.'
detection:
    selection:
        EventID: 3010
    approved:
        DnsServerIpAddress|expand: '%approved_resolvers%'
    condition: selection and not approved
falsepositives:
    - Laptops off-network taking a resolver from DHCP, unless you scope the rule to
      corporate networks or add those ranges
    - A DNS server nobody told you about, which is a finding of a different kind
level: high

Mind the field name, because the provider sets a trap here. DnsServerIpAddress on 3010 and 3011 is the single resolver actually used for that query. DNSServerAddress, which is what you'd probably reach for, is also a real field, but it lives on 3009 and holds the semicolon-delimited list of every resolver the host is configured with. Get them the wrong way round and your rule matches nothing while looking perfectly reasonable. I had them the wrong way round until I read a live event.

That 3009 field is worth knowing about for its own sake. A rule against it catches a bad resolver the moment it's configured, before anything resolves through it, which is earlier signal than 3010 gives you. I'd still build the alert on 3010, because negating a match against a semicolon-delimited list gets fragile fast, but reach for 3009 if config drift is what you're after.

One more thing this rule hands you. 3010 also carries ClientPID, the process that asked, so this doesn't just tell you a host went somewhere it shouldn't, it names the process that did it.

The firewall version covers the rest of the estate.

title: DNS Traffic To An Unapproved Resolver
id: d5b95e07-7e6e-42dd-bf1d-a5d41b367471
status: experimental
description: Detects any host sending DNS to a resolver that is not on the approved
    list. Where the client-side rule needs endpoint logging, this one works from
    firewall logs alone, so it also covers printers, appliances, IoT and anything
    else you cannot install on.
author: <you>
date: 2026-09-13
tags:
    - attack.command-and-control
    - attack.t1071.004
    - attack.defense-evasion
    - attack.t1562.001
logsource:
    category: firewall
detection:
    selection:
        dst_port:
            - 53
            - 853
    approved_destination:
        dst_ip|expand: '%approved_resolvers%'
    resolver_itself:
        src_ip|expand: '%approved_resolvers%'
    condition: selection and not 1 of approved_* and not resolver_itself
falsepositives:
    - Any host you approved for direct resolution and forgot to add to the list
level: high

The resolver_itself filter is the one that matters. Your DCs legitimately talk to upstream forwarders on port 53 all day, which is events 260 and 261 from part 1. Without that exclusion the rule fires on your own DNS infrastructure continuously and gets switched off within a week.

These two are a pair rather than alternatives. The client-side rule gives you the process on managed Windows hosts. The firewall rule covers everything else, and it keeps working when endpoint logging is the thing that got turned off. Port 853 is DoT. I've deliberately left DoH out, because 443 to a DoH provider needs a provider list and it belongs in the next post with the rest of the evasion material.

Threat feed integration

It's a quick win to match domain and IP block-lists against the new DNS telemetry. I'd search both server-side (for the qname matches) and client-side (qnames and resolved IPs) against your choice of threat feed. If you don't have one yet it's a good time to get one, and free will do to start, with MISP giving you somewhere to put it. Targeted attacks rarely reuse domains and IP addresses, so this won't catch the crew that came for you specifically. It catches the lower-effort ones, and the lower-effort ones are most of them.

One rule covers both sides of it.

title: DNS Query Or Answer Matching A Threat Feed
id: fc18c088-95ac-435b-8fe4-46a3d5a7a765
status: experimental
description: Matches DNS query names against a domain blocklist and resolved addresses
    against an IP blocklist. The answer side catches fast-flux infrastructure where the
    domain rotates faster than any feed follows it.
author: <you>
date: 2026-09-13
tags:
    - attack.command-and-control
    - attack.t1071.004
logsource:
    product: windows
    category: dns_query
    definition: 'Microsoft-Windows-DNS-Client/Operational event 3008, or Sysmon
        event 22. Both carry QueryName and QueryResults.'
detection:
    selection_domain:
        QueryName|expand: '%threat_feed_domains%'
    selection_answer:
        QueryResults|contains|expand: '%threat_feed_ips%'
    condition: 1 of selection_*
falsepositives:
    - Feeds go stale, and a sinkholed domain stays on a list long after the campaign died
    - Your own security tooling resolving indicators on purpose
    - Shared hosting, where one flagged address serves a thousand innocent sites
level: high

QueryResults needs care. It's a single semicolon-delimited string rather than a list, which is why the answer side uses |contains. IPv4 answers come back IPv4-mapped, as ::ffff:104.20.23.154. And it isn't only addresses, a lookup I ran came back type: 6 elliott.ns.cloudflare.com;, an SOA record carrying a type: N prefix. |contains papers over all of that, at the cost of 203.0.113.4 matching 203.0.113.44. Splitting QueryResults into a multi-value field at ingest, stripping the ::ffff: prefix and the type markers, fixes it properly and turns this into an exact match.

High-frequency lookups to one domain are never good news

DNS is such a basic component of our network mechanics that nobody raises his eyebrows when DNS traffic occurs. In most cases nobody should either, but there are situations where DNS behaviour breaks the normality patterns, and that's exactly where we should be notified and respond. The SolarWinds DNS command and control (C2) channel I described at the beginning of the post is one such classic mis-behaviour. One of the most useful signals across many DNS abuses is repeated lookups from the same host, through the same resolver, under the same parent domain with changing labels. Parent domain meaning the registrable domain, eTLD+1, not the qname with its leftmost label chopped off.

There are several offensive techniques to differentiate here.

DNS C2 tunnels. The client is an internal host, the resolver is either internal or external, and the domain in qname is external and untrusted.

DNS exfiltration. The same, but with long, non-repeating sub-domain prefixes holding encoded or encrypted data, which look like DGA (domain generation algorithm) output.

DNS beacons. Similar characteristics, but they don't require a response and their frequency can be lower, sometimes as low as one an hour, which means monitoring over longer periods (24 hours and more).

DNS brute-force and enumeration. High frequency too, but there the attacker mostly comes from a public address and targets a publicly exposed DNS. It happens from within as well, and there the real differentiator is the sub-domain prefixes he tries to resolve, taken from a dictionary, and the NXDOMAIN on most of them.

Here's a template for a generic high-frequency DNS detection. When it fires you still have to investigate which attack category it falls into, or whether it's normal behaviour that now has to go in the baseline.

name: dns_query_external
title: DNS Query To An External Domain
id: 002289a9-93e2-43d4-ab92-121aefd874b5
status: experimental
description: Base rule for the correlation below. It matches almost every external
    lookup on the box, so do not alert on it directly.
author: <you>
date: 2026-09-13
logsource:
    product: windows
    service: dns-client
    definition: 'Microsoft-Windows-DNS-Client/Operational event 3010, which carries
        the resolver address. Middle collection tier from part 2.'
detection:
    selection:
        EventID: 3010
    filter_internal:
        QueryName|endswith:
            - '.yourdomain.local'
            - '.corp.yourdomain.com'
    filter_local:
        QueryName|endswith:
            - '.arpa'
            - '.local'
            - '.home.arpa'
    condition: selection and not 1 of filter_*
level: informational
---
title: One Host Resolving Many Unique Subdomains Under One Parent Domain
id: 8eed6423-d73c-45b6-9cc0-2c86cb46dca3
status: experimental
description: Counts distinct query names per host, per resolver, per parent domain.
    Tunnelling and exfiltration show up as a run of never-repeating labels under a
    single registrable domain, which is a sharper signal than raw query volume.
references:
    - https://zeltser.com/c2-dns-tunneling/
    - https://patrick-bareiss.com/detect-c2-traffic-over-dns-using-sigma/
author: <you>
date: 2026-09-13
tags:
    - attack.command-and-control
    - attack.t1071.004
    - attack.exfiltration
    - attack.t1048.003
correlation:
    type: value_count
    rules:
        - dns_query_external
    group-by:
        - Computer
        - DnsServerIpAddress
        - parent_domain
    timespan: 1h
    condition:
        gte: 10
        field: QueryName
falsepositives:
    - Anything that legitimately produces many unique names under one parent domain.
      CDNs and CDN-backed SaaS, Office 365 and Entra endpoints, cloud service discovery,
      OCSP and CRL checks, browser and agent update channels, security tooling, and
      build pipelines pulling artifacts
    - Baseline the pairing rather than the domain. What is normal is that this host
      produces many unique names under this domain. Allowlist the domain on its own
      and you have handed an attacker somewhere to hide
level: high

Ten an hour is deliberately aggressive, two orders of magnitude tighter than the SigmaHQ rule's thousand, and it only works because it counts distinct names rather than queries. Ten lookups an hour to one domain is every browser tab you have open. Ten never-before-seen labels under one parent domain in an hour is a channel. It still needs an allowlist before it's livable, because Akamai, Office 365, OCSP responders and anybody's build pipeline clear that bar without trying.

parent_domain isn't a field anyone's logs contain, and getting it right needs the public suffix list. Compute it at ingest. On Splunk that's URL Toolbox, ut_parse against the Mozilla list. Sentinel has nothing native, which is where a regex over the suffixes you actually care about will get the job done. SigmaHQ's own rule assumes the same field exists, so at least you're in good company.

If you only shipped 3008 and skipped the middle tier, change the base rule's logsource to category: dns_query, drop the EventID selection, and drop DnsServerIpAddress from the group-by. You lose the resolver half of the couple and keep everything else.

Enter the world of DNS detections

There's plenty more in there than what's above. Newly registered domains and dynamic DNS providers, LOLBins resolving anything at all, outbound connections with no DNS query in front of them, AD reconnaissance against _ldap._tcp records. And the unglamorous one, dead lookups and resolution loops that nobody attacked you with and that are probably still a chunk of your DNS traffic and your ingest bill.

That list is deliberately not a catalogue. Detections go in one at a time, each one tuned against your own traffic until it stops crying wolf, and which ones make the cut depends on what your organization actually faces and what it can afford to chase.

I hope you found this helpful and practical. In the next post I'll discuss how hackers evade this DNS telemetry entirely and become invisible.