In case you missed the earlier parts, part 1 turned on server-side DNS logging, part 2 did the client side, and part 3 covered what to detect once you can see it.
Whenever I teach cyber workshops to blue teams and opsec people, one of my opening lines is always "I am not a bearer of good news". In this fourth part of my DNS (in)visibility series I'm going to saw off the branch the other posts are sitting on. I'm not writing this to put you down or to discourage what we've just built, but to show you something most hackers know and most defenders don't. A skilled attacker can walk straight past all the logging we just turned on. It takes effort, and it comes with restrictions and dependencies, but the fact remains.
If you're thinking "oh, why did we waste all that time if it doesn't work", you might be in the wrong profession. I'd call you naive if you expected anything in security to be bullet-proof. That is not how good defense works (yes, layered defense blah blah goes here). We're in a cat and mouse situation here (you're the cat), and the mouse has tricks up his sleeve that get him past the standard traps. That doesn't make the traps useless, of course. Not every mouse is a mighty mouse, and even the mighty ones can't pull every trick at once. In any case, I'll come back to what we can actually do about it once we're through the offensive side. It's a short answer, and you won't love it. Now, let's hack.
"Mu ha ha - we got Sysmon you fool". Right.
I must confess, Sysmon is probably the first MS tool that got me to say "hey, that's really cool" (sorry for the geekiness). I credit that to Mark Russinovich more than I do MS, but either way when it was first released in 2014 it was like obtaining spectacles of true seeing exactly when you have to cross a dungeon full of secret doors in a D&D adventure. Like commercial EDRs, Sysmon watches the system from the kernel, but unlike them it writes straight into the event log, which makes it an ideal tool for forensics and incident response. It's also the honest version of what your EDR is doing - same layers, same hooks, same blind spots, without the marketing between you and the telemetry. So when I show you what Sysmon misses, assume your EDR misses it too unless you've tested otherwise.
When it reached v10.0 (mid 2019) it finally introduced the much anticipated DNS query logging feature (event ID 22), and although few have successfully deployed Sysmon as an on-going monitoring tool it did offer the option to monitor DNS activity on endpoints ad-hoc. My excitement quickly turned into disappointment when I realized its limitations. It doesn't log every DNS query/response - just the ones that pass through the OS built-in DNS client. Sounds good enough? Not quite. Try running nslookup to resolve an address from the console - and you'll be in for a big surprise. Tools that implement their own resolver (build DNS packets and write them straight to a socket) never touch the Windows DNS client. It's true not only for the built-in nslookup, but for tools like dog, doggo and q, which I ran and confirmed, and I'd expect the same from dig and kdig. Same for everything you implement using python (scapy, dnslib or raw-bytes), powershell or anything you can code, here's an example.
from dnslib import DNSRecord
r = DNSRecord.question("example.com").send("208.67.222.222", 53, timeout=5)
print(DNSRecord.parse(r))
Three lines. Ordinary UDP socket, no elevation, nothing dropped on disk that isn't a pip install. Event ID 22 never fires.
One thing worth knowing. Sysmon's DNS event isn't a kernel hook at all. Event ID 22 is a thin consumer of the Microsoft-Windows-DNS-Client ETW provider. Which means you can subscribe to it yourself without Sysmon, as we did in part 2, and it means anything not using the Windows DNS client is invisible no matter how you configure Sysmon.
To be fair to it, plenty does get logged. ping, Resolve-DnsName with and without -DnsOnly, [System.Net.Dns]::GetHostAddresses, Test-NetConnection, Invoke-WebRequest, HttpClient, TcpClient - all logged, all attributed to the right process. I went looking for the outliers rather than expecting these to fail, and the pattern is clean. Anything that calls getaddrinfo or DnsQuery_* shows up, however exotic the wrapper.
OK.. nice but it will still show event ID 3 (network connection) on UDP:53
Right - so although we lose DNS payload visibility (query name, response value etc) one could claim this event would still be visible as an event ID3 with a UDP transport to port 53 - which still reads like DNS. That's true - BUT you will not be able to correlate between that and an actual socket that is targeted at the resolved IP, nor will you be able to see the hostname in the log.
But there's an easy way to avoid the UDP:53 as well - use DoH, DoT or DoQ (DNS over HTTPS, TLS or QUIC). Encrypted DNS came to provide better privacy for users and for hackers as well. Doing so you could choose both TCP and UDP transports and use any port as long as there's a server listening on the other side. Ideally, to blend in with the rest of the traffic one would use DoH (TCP) with port 443 so that Sysmon event ID3 would appear like any web traffic. If that avoids using the Windows built-in resolver as well, event ID22 will not be logged.
curl.exe -H "accept: application/dns-json" `
"https://cloudflare-dns.com/dns-query?name=example.com&type=A"
{"Status":0,"TC":false,"RD":true,"RA":true,"AD":true,"CD":false,
"Question":[{"name":"example.com","type":1}],
"Answer":[{"name":"example.com","type":1,"TTL":276,"data":"23.192.228.80"}]}
There's a hunt here that dies easily. Point the DoH client at a hostname and resolving cloudflare-dns.com is itself a normal lookup, so it lands in event ID 22 and you can alert on it. Point it at 1.1.1.1 instead and there's nothing left to resolve. No config change and nothing to drop on the box, since curl.exe has been in Windows since 1803.
Not all DoH is blind, which took me by surprise. When the Windows DNS client itself is configured for DoH, the resolution still flows through the client, and event ID 22 fires above the transport - so the query name lands in the log in cleartext while the packet leaves the machine encrypted. I confirmed both ends of that. Event ID 22 logs the query, not the transport, and it has no server field and no transport field, so it can't tell you which of the two you're looking at.
But can I become completely invisible?
Yessssssssss. There are actually two different ways to go at this; the first requires an NDIS-layer driver (pre)installed - if npcap/winpcap are available on the system or you can install them you can completely bypass the entire TCP/IP Windows stack and communicate with the network interface directly (no event 22 nor event 3 will be ever created), but loading it is loud. Event ID 7 records wpcap.dll and Packet.dll loading and npcap.sys shows up as a driver load in event ID 6, so a process loading those that isn't Wireshark or an approved capture tool is something worth escalating. That rule isn't in the default Sysmon config, by the way.
So here's an even better path, one that doesn't need a specialized driver. An elevated user can open a raw socket with IP_HDRINCL, hand-build the IP/UDP headers, and send. Receiving is separately possible via the SIO_RCVALL ioctl, also with no third-party driver. I ran a full query and response with neither leg touching Winsock, and Sysmon logged nothing at all.
import socket, struct, random
# dns_payload(name) builds an A query; checksum(data) is the standard 16-bit
# ones-complement sum. Both elided here, neither is interesting.
RESOLVER = "208.67.222.222"
SRC = local_ip()
SPORT = random.randint(20000, 60000)
TXID, payload = dns_payload("example.com")
# --- send: SOCK_RAW + IP_HDRINCL, we write the IP and UDP headers ourselves
udp_len = 8 + len(payload)
pseudo = socket.inet_aton(SRC) + socket.inet_aton(RESOLVER) + struct.pack(">BBH", 0, 17, udp_len)
udp_nock = struct.pack(">HHHH", SPORT, 53, udp_len, 0) + payload
udp = struct.pack(">HHHH", SPORT, 53, udp_len, checksum(pseudo + udp_nock)) + payload
fields = (0x45, 0, 20 + len(udp), random.randint(0, 65535), 0, 64, socket.IPPROTO_UDP)
addrs = (socket.inet_aton(SRC), socket.inet_aton(RESOLVER))
iph = struct.pack(">BBHHHBBH4s4s", *fields, 0, *addrs)
iph = struct.pack(">BBHHHBBH4s4s", *fields, checksum(iph), *addrs)
s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
s.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
s.sendto(iph + udp, (RESOLVER, 0)) # needs Administrator
s.close()
# --- receive: SIO_RCVALL, promiscuous, still no Winsock transport
def sniff(result):
r = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)
r.bind((SRC, 0))
r.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1)
r.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON)
r.settimeout(8)
while True:
pkt = r.recv(65535)
ihl = (pkt[0] & 0x0F) * 4
if pkt[9] != 17: continue # not UDP
body = pkt[ihl:]
if struct.unpack(">H", body[2:4])[0] != SPORT: continue # not ours
if struct.unpack(">H", body[8:10])[0] == TXID:
result["reply"] = body[8:]; break
r.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF); r.close()
Start sniff() on a thread before the send and you get the answer back. Neither leg touches Winsock, so there is no event ID 3 to go with the missing event ID 22.
Raw sockets do not bypass the TCP/IP stack. They enter TCPIP.sys below the transport layer, and Sysmon's network hook sits at the transport layer - the packet passes underneath the sensor. And that's why this beats Npcap. Npcap leaves a library load behind. A raw socket loads nothing unusual, just stock ws2_32, already resident in every process that touches the network.
Notice where the line falls, because it isn't where you'd hope. Everything that defeats event ID 22 runs as any user with tools already on the box. No admin, no dropped binary, no unusual API. Everything that defeats event ID 3 needs administrator.
"All right, we'll call it a draw." (The Black Knight, Monty Python and the Holy Grail)
So for those of you who still believe that "if you never give up, you can't possibly lose", here it is in our own words. We've evaded every host sensor in this post, but not network-based security. Sniff the traffic off the interface and the query and the response are sitting there in cleartext inside the packet. No EDR I know of goes that far. And yes, that assumes somebody was sniffing during the incident and not after it.
True, but here's where QUIC stings back. Windows forbids raw TCP sockets, which rules out classic DoH, DoT and anything else riding TLS over TCP. But it allows raw UDP, and QUIC is a userspace transport - the library builds its own packets and hands the socket datagrams. Nothing about it lives in the kernel, which is what makes a raw socket underneath it possible at all. There's still plumbing to write - IP and UDP headers on the way out, demultiplexing the SIO_RCVALL firehose on the way back - but it's a shim around a working library, not a reimplementation of QUIC. I've run both halves separately and not fused them. Do that and DoQ (UDP/853) or DoH3 (UDP/443) sits on top of a transport neither Sysmon event ID sees. And it encrypts the DNS payload. I captured the packets and found zero frames containing the query name, just to make sure. The only cleartext I found was the SNI, which tells little and can be encrypted too. Here's the DoQ client, running on aioquic's ordinary socket. I'll leave the raw-socket plumbing to you. A personal coding challenge, if you find it interesting enough.
import asyncio, struct
from dnslib import DNSRecord
from aioquic.asyncio.client import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import StreamDataReceived
SERVER_IP, SERVER_NAME, PORT = "94.140.14.14", "dns.adguard-dns.com", 853 # AdGuard, by IP
class DoQClient(QuicConnectionProtocol):
def __init__(self, *a, **kw):
super().__init__(*a, **kw); self._streams = {}
def quic_event_received(self, event):
if isinstance(event, StreamDataReceived):
fut, buf = self._streams.get(event.stream_id, (None, b""))
buf += event.data
self._streams[event.stream_id] = (fut, buf)
if event.end_stream and fut and not fut.done():
fut.set_result(buf)
async def query(self, wire):
sid = self._quic.get_next_available_stream_id()
fut = asyncio.get_event_loop().create_future()
self._streams[sid] = (fut, b"")
# RFC 9250: two-byte length prefix, and the DNS message ID must be zero
self._quic.send_stream_data(sid, struct.pack("!H", len(wire)) + wire, end_stream=True)
self.transmit()
return await asyncio.wait_for(fut, timeout=12)
async def main():
cfg = QuicConfiguration(alpn_protocols=["doq"], is_client=True, server_name=SERVER_NAME)
async with connect(SERVER_IP, PORT, configuration=cfg, create_protocol=DoQClient) as client:
q = DNSRecord.question("example.com"); q.header.id = 0
resp = await client.query(q.pack())
ln = struct.unpack("!H", resp[:2])[0]
print(DNSRecord.parse(resp[2:2 + ln]))
asyncio.run(main())
It takes three wise monkeys. Not two.
The network telemetry your EDR collects is not the same thing as traffic sniffed off the interface. NDR has plenty of downsides, but don't let anyone tell you an EDR covers this. Every sensor in this post is a hook that somebody put somewhere, and you can always get underneath a hook. Packets on the wire are just the physics of the connection. That's why we started Red Hand, to give the network signal a proper seat at the evidence table.
The way I see it, a security story gets told in three different voices. The first and best-known is the system voice, what you can see from inside the OS of the suspect host. Which process ran, who ran it, where it ran from, what its command line was, and what started it. It's usually our first stop (name one organization without EDR on its endpoints). The second voice is application logs. Authentication, services, scheduled tasks, web servers, firewalls, VPNs, databases, you get the point. For years these sat scattered across local storage on enterprise endpoints. More than 15 years ago we started aggregating them under a single dashboard called SIEM, and they did give us a perspective the EDR never had.
The third voice is network monitoring. We tried this before. Gartner called it NDR (Network Detection and Response), vendors built products around it, and organizations spent serious money trying to make sense of all that traffic. I watched it become a lot of infrastructure, a lot of alerts, and a lot of people trying to figure out which ones mattered. The network is simply too big to treat everything as suspicious. If you don't already know your network well, the interesting stuff gets buried very quickly.
It's 2026. I think it deserves another go. It can show you things the other two can't. If incident responders want the full picture of what happened, they need system, log and network visibility. The evasion techniques I showed above will not survive network monitoring. That's my best "what should we do" for this post.