Reading a patch tuesday diff for fun: the dhcp client memcpy that copies more than four bytes (CVE-2026-44815)
Table of Contents
TLDR;⌗
June 2026 was the biggest Patch Tuesday Microsoft has ever shipped 208 CVEs. One of them,
CVE-2026-44815 (This is as bad as Etner blue, Wanna cry isse), is a CVSS 9.8 “DHCP Client Service Remote Code Execution.”
I pulled the patched dhcpcore.dll, diffed it against last month’s build and the whole bug fits on one screen: a function
called GetOriginalSubnetMask does a memcpy into a 4-byte buffer using a length field that came
off the wire from a DHCP server, with no check that the length is actually 4. The June patch adds
exactly one check: if (len != 4) bail. This post walks the entire 1 day workflow end to end how I
got the binaries, how I diffed them, how I read the patch and how I reasoned about reachability so
that if you are new to 1-day analysis (PAtch diffing) you can do the next one yourself. I am deliberately not publishing
the last mile to a weaponized exploit and there’s a Snort rule at the bottom for my blue team bros.

why even look at the dhcp client⌗
When I triage a Patch Tuesday for 1-day candidates, I sort by three things:
- Severity + “exploitation more likely.” CVSS 9.8 with RCE in the title goes to the top.
- A single, self-contained binary. Kernel TCP/IP is great but
tcpip.sysis 3.5 MB of pain. A client DLL that parses one protocol is a much friendlier first target. - Attacker-controlled input with an obvious wire format. DHCP is a TLV (type length value) protocol. TLV + “RCE” in the same sentence is almost always a length-handling bug. That is a pattern you learn to smell.
CVE-2026-44815 hits all three. It’s in the DHCP client, which means the attacker is a server a
rogue or compromised DHCP server on the local link answering your DHCPDISCOVER. The client is the
victim. That’s a classic, underrated surface: every laptop that joins a coffee shop network is a DHCP
client.
One more thing made this irresistible. The public ZDI writeup flagged an “odd incongruity”: the CVSS vector says no privileges required, but Microsoft’s text says it needs an authenticated user. Those two statements look contradictory. By the end of this post you’ll see they are both true and why is the most interesting part of the bug.
step 1: get both binaries (you do not need a windows box)⌗
You need two versions of dhcpcore.dll: the patched one (June) and the previous one (May). The
classic way to do this is to download the MSU from the Microsoft Update Catalog and unpack it but modern
Windows updates ship forward-delta patches that need the Windows-only msdelta API to reconstruct.
Annoying if you’re on macOS or Linux.
The shortcut: Winbindex. It indexes essentially every Windows binary
ever shipped, by version and hash, and links each one to the Microsoft symbol server, which serves the
full PE (not a delta). So you can grab any historical full binary with curl.
The symbol-server URL is keyed by the PE’s TimeDateStamp and SizeOfImage, concatenated as hex:
https://msdl.microsoft.com/download/symbols/<name>/<TimeDateStamp:08X><SizeOfImage:X>/<name>
Pull the Winbindex JSON for dhcpcore.dll, find the June 2026 entry and the one right before it, build
those two keys, and download both. On Win11 24H2 that’s:
| build | KB | role |
|---|---|---|
10.0.26200.8524 |
KB5089573 (May) | OLD (vulnerable) |
10.0.26200.8655 |
KB5094126 (June) | NEW (patched) |
While you are on this page, grab the PDBs too. Microsoft publishes public symbols for system binaries on the same server. Parse the PE’s CodeView (RSDS) debug record to get the PDB GUID+age and download:
https://msdl.microsoft.com/download/symbols/dhcpcore.pdb/<GUID><AGE>/dhcpcore.pdb
This step is the single biggest force multiplier in Windows 1-day work. With public PDBs applied, every
function in your decompiler has its real name. You diff GetOriginalSubnetMask vs GetOriginalSubnetMask,
not FUN_18001a100 vs FUN_18000ff44. Do not skip the PDBs.
step 2: diff the two builds⌗
I run Ghidra headless and dump every function’s decompiled C to JSON, once per build, then compare by function name. Two beginner-grade gotchas worth calling out, because both cost me time:
- Apple Silicon. Ghidra’s decompiler is a native binary that only ships for
linux_x86_64. On an arm64 host (or arm64 container)openProgramsilently fails and every function decompiles to an empty string and then your diff cheerfully reports “nothing changed.” Run Ghidra in an amd64 container under emulation and it just works. If your diff says a security patch changed nothing, suspect your tooling before you believe it. - Diff noise. Don’t diff raw decompiler text. Microsoft rotates WPP trace GUIDs and bumps trace message
IDs every month, the linker shuffles addresses, and the decompiler renumbers locals. All of that makes
unchanged functions look different. Normalize first: strip hex literals,
FUN_/DAT_/LAB_autonames, and local-variable suffixes, collapse whitespace, then compare. After normalizing, the real patch pops out of the noise.
For dhcpcore.dll, after filtering the CRT/thunk noise, the meaningful changes were tiny:
changed (most-changed first):
0.516 LeaseIpAddressEx
0.603 GetOriginalSubnetMask <-- 245 -> 367 bytes, address moved a lot
0.852 RenewIpAddressLeaseEx
... + 4 brand-new helper functions
GetOriginalSubnetMask grew by ~50% and moved. A small parsing function that suddenly gains code on a
9.8-RCE month is exactly where you look first.
step 3: read the patch⌗
Here’s the real diff straight out of the decompiler (Ghidra’s DecompInterface, OLD vs NEW, public
PDB applied). I’ve trimmed the pure variable-rename noise the ... marks omitted lines but the
security relevant change is verbatim:
undefined4 GetOriginalSubnetMask(longlong param_1, undefined4 *param_2)
{
...
AcquireSRWLockShared((PSRWLOCK)(param_1 + 0x2a0));
puVar4 = *(undefined8 **)(param_1 + 0x2b8);
do { // walk stored DHCP option list
...
} while (*(int *)(puVar4 + 2) != 0xdd); // node tagged 0xdd
- memcpy(param_2, (void *)puVar3[6], (ulonglong)*(uint *)(puVar3 + 7)); // copy len bytes, UNCHECKED
+ uVar1 = *(uint *)(puVar4 + 7); // uVar1 = server-controlled option length
+ if (g_fVelocityDhcpGetOriginalSubnetMaskFix == 0) {
+ memcpy(param_2, (void *)puVar4[6], (ulonglong)uVar1); // legacy path = STILL unchecked
+ }
+ else {
+ if (uVar1 != 4) { // <-- THE FIX: a subnet mask is exactly 4 bytes
+ WPP_SF_DJ(0x401, 0x3c, 0x180057630, uVar1);
+ goto LAB_18001a181;
+ }
+ memcpy(param_2, (void *)puVar4[6], (ulonglong)uVar1); // len == 4, safe
+ }
...
LAB_18001a181:
ReleaseSRWLockShared((PSRWLOCK)(param_1 + 0x2a0));
return 0;
}
Here’s the same function in the patched (June) build, written out cleanly:
undefined4 GetOriginalSubnetMask(longlong ctx, undefined4 *out)
{
if (out == NULL) return 0x57; // ERROR_INVALID_PARAMETER
*out = 0;
AcquireSRWLockShared(ctx + 0x2a0);
// walk the per-lease stored-option linked list at ctx+0x2b8,
// looking for the node whose internal tag == 0xDD
node = *(void**)(ctx + 0x2b8);
do {
cur = node;
if (cur == (ctx + 0x2b8)) { /* not found */ goto done; }
node = *(void**)cur;
} while (*(int*)(cur + 0x10) != 0xdd);
uint len = *(uint*)(cur + 0x38); // <-- length of the stored option
if (g_fVelocityDhcpGetOriginalSubnetMaskFix == 0) {
memcpy(out, *(void**)(cur + 0x30), len); // <-- OLD, UNCHECKED path
...
} else {
if (len != 4) { /* WPP log, bail */ goto done; } // <-- THE FIX
memcpy(out, *(void**)(cur + 0x30), len);
...
}
done:
ReleaseSRWLockShared(ctx + 0x2a0);
return 0;
}
And the old (May) build is just the g_fVelocityDhcpGetOriginalSubnetMaskFix == 0 branch with no
sibling i.e unconditionally:
memcpy(out, *(void**)(cur + 0x30), *(uint*)(cur + 0x38));
That’s the whole bug. Let me spell out why it’s bad.
outis the caller’s output buffer for a subnet mask. A subnet mask is an IPv4 address 4 bytes. The caller hands over a pointer to a 4-byte slot.len(*(uint*)(cur + 0x38)) is the length of a stored DHCP option, which was populated from a DHCP server’s response packet.- The old code copies
lenbytes into the 4-byte slot. Iflen > 4, you overflow the destination bylen - 4bytes with fully attacker-controlled content (*(void**)(cur + 0x30)is the option’s value, also straight off the wire).
The fix is one line: a subnet mask is always 4 octets (RFC 2132 §3.3 literally says “its length is 4 octets”), so reject anything that isn’t 4. The bug existed because nobody enforced an invariant the RFC already guaranteed if the other side plays by the rules. Attackers don’t.
This is the single most common shape of bug you will find in TLV parsers: trusting the length field of a value whose size is “supposed to be” fixed. Once you’ve seen it once, you’ll see it everywhere.

step 4: where does the length actually come from? (reachability)⌗
A diff tells you what changed. It does not tell you whether an attacker can reach it. That’s the part beginners skip and reviewers reject findings over. So let’s trace it.
Two questions: who fills the linked list at ctx+0x2b8, and who calls GetOriginalSubnetMask.
Who fills the list. The same per-context option list at +0x2b8 is touched by the functions that
process a received lease option storage, context refill, the “media connected” path that kicks off a
new DISCOVER/REQUEST when you join a network. In other words, the node tagged 0xDD and its len/value
fields are populated from the DHCP server’s reply options. The attacker who controls those bytes is
whoever answers your DHCP request: a rogue server, or an on-path box doing a DHCP race. No authentication,
no client trust relationship just “be the first DHCP server to answer.” That matches the CVSS
AV:Network / PR:None exactly.
Who calls it. GetOriginalSubnetMask has exactly two callers, and they’re both RPC entry points:
RpcSrvGetOriginalSubnetMask and RpcSrvGetOriginalSubnetMask_New. So the dangerous memcpy fires when
something invokes that RPC against the DHCP client service (running in svchost.exe as
NetworkService). That’s the “authenticated user” half of Microsoft’s description a local caller pokes
the RPC that reads back the stored mask.
Put the two halves together and the “incongruity” dissolves:
- The malicious data is planted with no privileges by a DHCP server on the link.
- The trigger that copies it into the undersized buffer is a local RPC call.
Both statements in the advisory are true. They describe two different ends of the same data flow. Knowing this is what turns “interesting diff” into “I understand the bug class and the threat model,” which is the actual skill 1-day analysis is teaching you.
step 5: how far does this go (and what I’m not publishing)⌗
The answer from static analysis alone: this is a controlled-content overflow of a small output
buffer inside a network service, reachable with attacker-controlled data and an attacker-controlled
overflow length (the option length field is a uint). That is a textbook memory-corruption primitive,
and Microsoft rating it 9.8 RCE is consistent with the destination being a heap/stack buffer adjacent to
something useful in the RPC server context.
What I am not going to hand you, because the four-byte fix is the kind of thing an LLM can weaponize in an afternoon and a lot of machines won’t have rebooted yet:
- the exact DHCP option that lands under internal tag
0xDD, and the precise wire layout that stores an over-length value there; - the specific RPC method sequence and arguments that drive the read-back;
- the allocation/grooming details that turn the overflow into control of execution.
If yo are learning, that’s fine the value here is the method, not a copy paste 0day. You now know how to get the binaries, how to diff them, how to read the patch and how to argue reachability. Reproducing the corruption in a VM with Driver Verifier / pageheap on the DHCP client service is a great next exercise, and it stays on your own lab network.
the twist nobody talks about: the vulnerable code still ships⌗
Look at that patched function again:
if (g_fVelocityDhcpGetOriginalSubnetMaskFix == 0) {
memcpy(out, ..., len); // the original, unchecked code is STILL HERE
} else {
if (len != 4) bail;
memcpy(out, ..., len);
}

The fix is behind a runtime feature flag (g_fVelocityDhcpGetOriginalSubnetMaskFix a WIL/Velocity
gate). Microsoft increasingly ships security fixes this way so they can roll them out gradually and roll
them back via KIR (Known Issue Rollback) if the patch breaks something. I found the same pattern on
every flagship June fix I looked at: tcpip.sys, http.sys, win32k, the RDP client.
The implication for defenders is uncomfortable: a machine can be fully “patched” the June dhcpcore.dll
is on disk and still run the vulnerable branch if the feature isn’t enabled yet (staged rollout
fraction, a KIR rollback, or an admin override). “Patched” and “fixed” are no longer the same statement.
When you’re tracking exposure, you can’t just diff the file version anymore; you have to know the flag
state too.
detection⌗
You can detect this on the wire because the malicious packet has to violate an RFC: a Subnet Mask option (DHCP option code 1) whose length byte is not 4, sent from a DHCP server to a client. Legitimate servers never do this. Same idea generalizes to any fixed width option arriving over length in a BOOTP reply.
Snort / Suricata rule⌗
# CVE-2026-44815 DHCP Subnet Mask option (code 1) with illegal length (!= 4)
# in a BOOTP/DHCP server->client reply. Subnet mask MUST be 4 octets (RFC 2132 3.3).
# An over-length value is the wire artifact of the dhcpcore.dll GetOriginalSubnetMask overflow.
alert udp $EXTERNAL_NET 67 -> $HOME_NET 68 (
msg:"WINDOWS-DHCP rogue server subnet-mask option oversized length (CVE-2026-44815 attempt)";
content:"|63 82 53 63|"; # DHCP magic cookie, start of options
byte_test:1,>,0,240,relative; # there is at least one option after the cookie (sanity)
content:"|01|"; # option code 1 = Subnet Mask
distance:0;
byte_test:1,>,4,0,relative; # length byte immediately after code is > 4 --> illegal
detection_filter:track by_src, count 1, seconds 60;
classtype:attempted-admin;
reference:cve,2026-44815;
sid:2026448150; rev:1;
)
A couple of caveats so you deploy this with your eyes open:
- DHCP options are a flat TLV stream; the
content:"|01|"+ relativebyte_testapproach can mis-fire if the byte0x01appears as option data rather than an option code. For production, prefer a proper DHCP parser (Suricata’sdhcpkeyword / a Zeekdhcpanalyzer script) that walks options correctly and alerts when option 1’s length field!= 4. The rule above is the “I need something in the IDS today” version. - Scope it to server->client (sport 67, dport 68). The bug is in the client; a normal client never sends option 1 as a mask, so direction matters.
- Pair it with host telemetry: crashes or WER reports in
svchost.exehostingDhcp, and theMicrosoft-Windows-Dhcp-Clientoperational log around lease events.
Zeek one-liner equivalent (concept): in dhcp_message, if options contains a subnet-mask option whose
raw length field isn’t 4, log it. That’s far more robust than byte-matching.
what to take away if you’re starting 1-day analysis⌗
- Patches are the best vuln reports you’ll ever get. They tell you the exact function and the exact
missing check. Reading
git log/ a binary diff is reading the answer key. - PDBs change everything on Windows. Named functions turn a 9000-function haystack into a short list.
- Trust your method, not your tools. “The security patch changed nothing” almost always means your decompiler or your normalization is broken (hello, arm64 Ghidra).
- A diff is half the work. Reachability is the other half. Always answer “who controls the input” and “who calls the sink.” That’s what separates a finding from a daydream and it’s what resolved the CVSS-vs-advisory contradiction here.
- TLV + fixed-width field = check the length. This bug is “copy a server-controlled length into a 4-byte mask.” You will meet its cousins constantly.
- “Patched” is now a flag state, not a file version. Feature-gated fixes mean the vulnerable code can still be live on an updated box.
And the meta-point the same one I keep hammering: the gap between “patch ships” and “exploit exists” is basically gone. I read this entire bug off a public diff in an afternoon with commodity tools. Assume someone less friendly did too, the same Tuesday. Patch and verify the feature flag is actually on now, not next maintenance window.
Stay safe out there. If you reproduce the corruption in a lab, keep it in the lab.