Nine days after Microsoft published CVE-2026-41089 — a critical pre-auth stack buffer overflow in the Netlogon service, CVSS 9.8, wormable, RCE on every unpatched domain controller — there was still no public PoC, no technical writeup, no function-level identification of the bug. Microsoft's advisory described it as a stack-based buffer overflow during the authentication handshake. That is a marketing summary, not technical information. We wanted to know exactly which function in netlogon.dll Microsoft fixed and what the bug class looked like at the code level. This post walks through how we located the vulnerable function in roughly four hours of methodical binary diffing — from acquiring the patched DLL to identifying the exact 2-byte terminator overflow — and where the wall stood when we tried to weaponize that finding into a reproducible trigger against an unpatched DC.
MITRE ATT&CK: Pre-attack technique. The vulnerability enables remote code execution against a domain controller without prior authentication, similar in impact to CVE-2020-1472 Zerologon but distinct in mechanism (stack BoF vs cryptographic logic flaw).
Why bindiff a Microsoft patch instead of waiting for a public PoC?
Public PoCs for high-severity Windows CVEs typically appear 3 to 12 weeks after Patch Tuesday — and sometimes never, when the bug is subtle enough that no independent researcher invests the time to reproduce it. CVE-2026-41089 is exactly that profile: APT actors began exploiting it in the wild the day after disclosure (per CrowdStrike telemetry), but the public security research community had produced nothing technical by day nine. For a defender or pentester who needs to demonstrate vulnerability presence to a client today, waiting for someone else to release a PoC is not a workflow. Bindiff is.
The principle is simple. Microsoft only changes what they need to. When a security patch ships, the modified binary differs from the pre-patch version in a small, specific set of functions — typically 5 to 30 functions across an entire DLL with thousands. Comparing those two binaries function by function reveals the vulnerable code path with surgical precision. With public debug symbols available from Microsoft's symbol server, the diff comes back with real function names like NetpLogonPutUnicodeString instead of FUN_180047770. From there, identifying the bug class and tracing the attacker reach is mostly reading C decompilation side by side.
What does the CVE-2026-41089 bindiff workflow look like end to end?
The full workflow is: acquire pre-patch and post-patch versions of netlogon.dll, load both into Ghidra with PDB symbols from Microsoft's public symbol server, run ghidriff to identify all modified functions and produce side-by-side decompilation diffs, then read the diffs for the obvious candidates — string copy helpers, network packet builders, and pre-authentication entry points.
The bottleneck in this workflow is rarely the diff itself. It is acquiring the post-patch DLL. Microsoft ships cumulative updates as .msu packages, which are CAB-wrapped containers of CBS deltas — incremental binary patches that are not directly applicable with msdelta.dll on a sufficiently old baseline. Concretely: trying to install KB5087538 (the May 2026 Server 2019 cumulative) on a Server 2019 system at build 17763.1935 (February 2020) fails with 0x80240017 WU_E_NOT_APPLICABLE because the baseline is six years too old. dism /Online /Add-Package fails identically. The PA30 forward delta in f/netlogon.dll will not apply with stock msdelta.dll because it is CBS-wrapped, not raw msdelta format.
The practical workaround is to pull netlogon.dll from a Windows machine that is already at a recent baseline. Any Windows 11 24H2 installation that has applied the May 2026 cumulative (KB5089549, OS build 26100.8457) has the post-patch DLL sitting at C:\Windows\System32\netlogon.dll. The same DLL is shared structurally with Server 2019 and Server 2022 versions of the function — the bug fix is identical across builds — so a workstation DLL works fine for the diff. The pre-patch DLL comes from the same Windows install before applying the patch, or from any unpatched domain controller in a lab via SMB:
nxc smb <DC-IP> -u administrator -H <NT_HASH> -d <domain> \
--get-file 'C:\\Windows\\System32\\netlogon.dll' netlogon_prepatch.dllWith both DLLs on disk, the rest is one command.
How do you run a binary diff between two Windows DLLs in Ghidra?
The tool of choice in 2026 for Ghidra-based patch diffing is ghidriff — a PyGhidra-native command-line wrapper around Ghidra's Version Tracking engine that produces a Markdown report, side-by-side HTML diffs, and a JSON structured output in a single invocation. It auto-resolves PDB symbols from Microsoft's public symbol server, runs Ghidra analysis on both binaries, applies a chain of correlators (exact byte match, exact instruction hash, BSIM, calling/called heuristics), and writes a sorted report of added, deleted, and modified functions.
Installation is one line, assuming Ghidra 12.x with PyGhidra enabled is already available:
export GHIDRA_INSTALL_DIR="$HOME/tools/ghidra/ghidra_12.1_PUBLIC"
pip install --user ghidriffThen the diff itself:
ghidriff netlogon_prepatch.dll netlogon_postpatch.dll \
--engine VersionTrackingDiff --sxs --output-path ./ghidriff_outEnd to end, including Ghidra analysis of both DLLs with PDB symbols, the run takes 5 to 10 minutes on a modern laptop. The output report for CVE-2026-41089 was 447KB of Markdown plus per-function HTML side-by-side diffs for the most interesting functions. The Diff Stats section at the top of the report is the first thing to read:
added_funcs_len: 5
deleted_funcs_len: 0
modified_funcs_len: 20
matched_funcs_no_changes_len: 4471
match_func_similarity_percent: 99.5547%Four-thousand four-hundred ninety-six total functions across netlogon.dll. Twenty modified. Five new. Zero deleted. Match rate 99.89%. That is the typical signature of a single targeted security patch — Microsoft did not refactor anything, they added bounded validation in a small cluster of functions and shipped it.
Which function did Microsoft actually patch?
Reading the report top to bottom, sorted by diff magnitude, the candidates surface immediately. Length of the diff section per function is a reliable proxy for how much code Microsoft actually changed. For CVE-2026-41089 the top entries were:
| Function | Diff size | Pre-auth reachable? |
|---|---|---|
NlGetLocalPingResponse | 837 lines | Yes — DC discovery handler |
BuildSamLogonResponse | 369 lines | Yes — chained from above |
NetpDcBuildPing | 354 lines | Yes — chained from above |
PrimaryQueryHandler | 243 lines | Yes |
NetpLogonPutUnicodeString | 200 lines | Helper |
NetpLogonPutBytes | 190 lines | Helper |
The Authenticate3 / ReqChallenge / PasswordSet2 family — which most of the early speculation pointed at, because Microsoft's advisory talked about "the authentication handshake" — showed no changes at all. The bug is not in the NRPC handshake. It is in the DC discovery code path, which Microsoft serves via CLDAP UDP/389 and SMB mailslot. That call path lands in NlGetLocalPingResponse, which calls NetpDcBuildPing to construct the NETLOGON_SAM_LOGON_RESPONSE_EX packet, which in turn calls NetpLogonPutUnicodeString and NetpLogonPutBytes to serialize attacker-controlled strings into a fixed-size response buffer on the stack.
The two helper functions are where the bug lives. The side-by-side diff of NetpLogonPutBytes is the smoking gun. In the pre-patch version, the function is a 17-line raw byte copy loop with no upper bound on the destination buffer:
// PRE-patch NetpLogonPutBytes
void NetpLogonPutBytes(undefined1 *param_1, int param_2, longlong *param_3) {
do {
puVar2 = (undefined1 *)*param_3;
uVar1 = *param_1;
param_1 = param_1 + 1;
*puVar2 = uVar1;
*param_3 = (longlong)(puVar2 + 1);
param_2 = param_2 + -1;
} while (param_2 != 0); // copies param_2 bytes, period
return;
}In the post-patch DLL, the same address now contains a function renamed to NetpLogonPutUnicodeStringOld — Microsoft marked the unsafe version as deprecated by adding the Old suffix to its symbol and added a separate, bounded safe path via RtlStringCbCopyExW. The unsafe helper is no longer called from the main path; instead, a Feature flag (Feature_740537659__private_IsEnabledDeviceUsageNoInline) gates the safe path with proper status return values and bounds checks at every step.
The companion helper NetpLogonPutUnicodeString carries a more subtle bug. In the pre-patch version, the function correctly bounds the wide-character copy loop using a count parameter, but writes a 2-byte L'\0' terminator after the loop without checking whether the destination has space for it:
// PRE-patch NetpLogonPutUnicodeString — abridged
do {
if (iVar4 == 0) break; // bound check on count
*local_res8 = wVar1;
local_res8 = local_res8 + 1;
wVar1 = *(wchar_t *)((longlong)local_res8 + lVar3);
} while (wVar1 != L'\0');
*local_res8 = L'\0'; // 2-byte write past the bound
*param_3 = (ulonglong)(local_res8 + 1);When BuildSamLogonResponse calls this helper repeatedly to serialize the response fields, the destination is not the heap. It is a fixed-size wide-character buffer on the stack inside NlGetLocalPingResponse, with the function's stack cookie sitting immediately behind it — the classic layout that Microsoft assigned CWE-121 (stack-based buffer overflow) to. Each helper call advances a shared cursor and writes its 2-byte L'\0' terminator after its bounded copy. The post-patch helper returns STATUS_INVALID_PARAMETER (0x57) when the payload would exceed the available buffer, eliminating the unbounded terminator write entirely. (Our initial read assumed a heap allocation; later dynamic analysis on the target corrected this to a stack buffer — the distinction matters, because it means the overflow target is the stack cookie, not heap metadata.)
Can you reach the vulnerable code path remotely without authentication?
Yes. The NlGetLocalPingResponse function is exercised by every CLDAP NetLogon ping request (UDP/389) and every SMB mailslot logon request (UDP/138 + \MAILSLOT\NET\NETLOGON). Both protocols are pre-authentication discovery primitives — a domain-joined client uses them to locate a domain controller for its domain before it has any credentials. There is no authentication gate in front of NlGetLocalPingResponse, only an early-rejection check that the requested domain matches one the DC serves.
Confirming the reach experimentally is straightforward. On the target DC, enable Netlogon verbose tracing:
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters" `
-Name DBFlag -Value 0x2080FFFF -PropertyType DWord -Force
Restart-Service Netlogon -ForceThen send a CLDAP NetLogon ping from any unauthenticated host with a recognizable marker in the filter:
LDAPMessage searchRequest baseObject="" filter=(&
(DnsDomain=essos.local)
(Host=MARKER_ATTACKER_STRING_HERE)
(NtVer=\x01\x00\x00\x00))
attributes=["Netlogon"]Within milliseconds, %WINDIR%\Debug\netlogon.log contains entries proving the request reached the handler:
[MAILSLOT] Received ping from MARKER_ATTACKER_STRING_HERE((null)) essos.local (null) on UDP LDAP
[MAILSLOT] ESSOS: Ping response 'Sam Logon Response Ex' (null) to \\MARKER_ATTACKER_STRING_HERE Site: Default-First-Site-Name on UDP LDAPTwo refinements from the dynamic-analysis phase are worth stating, because they correct what the early static read implied. First, the NtVer value selects the response builder. NtVer=V5 (\x06\x00\x00\x00) routes to BuildSamLogonResponseEx, which never calls the vulnerable NetpLogonPutUnicodeString; only NtVer=V1 (\x01\x00\x00\x00) reaches the legacy BuildSamLogonResponse that does. Thousands of fuzzing iterations that used the modern V5 value exercised the wrong builder entirely. Second, the Host value shown above only feeds the logging routine (NlPrintRoutine), not the vulnerable copy — the attacker-influenced bytes that actually land in the bounded helper write come from the User filter attribute. The bug is genuinely reachable pre-authentication from any host that can route a UDP packet to port 389, but reaching the vulnerable write specifically requires the V1 path.
Why doesn't simple CLDAP fuzzing crash the unpatched DC?
This is where the honest limits of patch diffing show. We sent over eleven thousand CLDAP and NRPC mutations at an unpatched Server 2016 domain controller — boundary-length strings, repeated spam runs, integer-overflow combinations across iSockaddrLength and EntryCount fields, and direct NRPC calls to DsrGetDcName variants with crafted parameters. Sysinternals procdump was attached to lsass.exe for the entire run as a tripwire. The DC never crashed. The single lsass crash we did eventually produce turned out to be a debugger-instrumentation artifact — a memory dereference inside a breakpoint command we had set, not a CVE trigger — which is a cautionary tale of its own about confirming what actually caused a crash before claiming a PoC.
The first reason naive fuzzing failed was the builder-routing fact above: essentially all of those early mutations carried NtVer=V5, so they were served by BuildSamLogonResponseEx and never touched the vulnerable helper. Forcing NtVer=V1 and tracing with WinDbg/cdb on the target finally put the attacker-controlled bytes (the User field) into NetpLogonPutUnicodeString — but it still did not crash, and dynamic measurement explained why.
The destination is a fixed-size wide-character stack buffer of roughly 264 characters, with the stack cookie immediately behind it. The response builder fills that buffer from several sources: the DC's own DNS host name, NetBIOS domain, and (in one branch) its forest/domain/host names, plus the attacker-controlled field. On a default-named domain controller, the cumulative write across all of those sources lands well short of the buffer end — in our measurements around two-thirds of the way in — so the attacker's portion never reaches the cookie. The bytes the attacker controls are bounded; the bytes that would push the cursor over the edge are the server's own names, which the attacker does not control. Concretely, the DC's own DNS domain name has to be unusually long — on the order of 50 or more characters — before the server-side writes alone carry the cursor into the stack cookie. A domain name of roughly 54 characters is enough to overflow by a couple of bytes; an ordinary name like corp.local overflows by zero. That precondition is the entire reason the bug is rated "Exploitation Less Likely": practically no production domain controller is named that way, so the attacker-controlled field never reaches the cookie on its own.
In other words: locating the bug in netlogon.dll took roughly four hours of bindiff work. Confirming the reach, the builder routing, the exact buffer, and the input field took days more of dynamic analysis — and at the end of that, a reliable unauthenticated trigger against a default-configured DC was still out of reach within our timebox. Engineering a weaponized exploit is a separate project measured in weeks, which is the same gap APT operators close internally and the reason their working exploits do not appear in public for months. We chose to stop here and not publish a trigger, because we do not have one — and on an actively-exploited wormable CVE, the responsible deliverable is detection guidance, not a head start toward a crash.
Does the public CVE-2026-41089 proof-of-concept actually work?
Short answer: the PoC scripts that surfaced publicly do not crash an unpatched domain controller that has an ordinary domain name, despite claiming to. There are two concrete reasons, both of which line up with the analysis above. First, the circulating code sends its CLDAP ping with an NtVer value that has the V5EX bit set — which routes to the safe BuildSamLogonResponseEx builder and never reaches the vulnerable NetpLogonPutUnicodeString at all. Reaching the legacy BuildSamLogonResponse requires the V5EX bit clear. Second, the scripts assume a 130-character username is sufficient on its own, ignoring the long-domain precondition: with a normal domain name the attacker's bounded field cannot reach the cookie regardless of length. We tested the public PoC against unpatched-by-build Server 2016 and Server 2019 domain controllers with ordinary domain names — it produced no crash, and out-of-band checks (boot time, LSASS process, crash dumps) confirmed nothing happened. The PoC's "success" is inferred from a UDP recv timeout, which a single dropped packet triggers on a perfectly healthy DC. The lesson is the one that bit us too: confirm a crash by a stack-cookie WER dump (0xc0000409 / STATUS_STACK_BUFFER_OVERRUN), never by the absence of a reply.
What does this CVE mean operationally for AD environments today?
It means every domain controller running an unpatched build of Windows Server 2012 R2, 2016, 2019, 2022, 2022 23H2, or 2025 is exposed to a pre-authentication remote code execution vector that is actively being exploited by sophisticated threat actors in the wild. The patch is straightforward to deploy — every affected Server SKU has a published Microsoft cumulative — but the operational reality is that domain controllers do not get patched on the same cadence as workstations. Many DCs run on quarterly patch cycles, and some run on annual cycles, particularly in environments with replication or trust dependencies that defenders are reluctant to disturb.
How do you detect whether a domain controller is vulnerable to CVE-2026-41089?
Detecting CVE-2026-41089 is a version check, not an exploitation check. A working PoC is not required to validate exposure: any domain controller reporting an OS build below the patched threshold for its Server SKU is vulnerable. Only domain controllers are affected, because the vulnerable code path lives in the DC NetLogon ping handler. The patched builds are:
| SKU | First patched build |
|---|---|
| Windows Server 2012 R2 | 6.3.9600.23132 (KB5082126) |
| Windows Server 2016 | 10.0.14393.9140 (KB5082198) |
| Windows Server 2019 | 10.0.17763.8755 (KB5087538) |
| Windows Server 2022 | 10.0.20348.5074 |
| Windows Server 2022 23H2 | 10.0.25398.2330 |
| Windows Server 2025 | 10.0.26100.32772 |
Reading the build of a remote DC requires authenticated access. The OS version field returned by SMB negotiate gives only the major build number (14393, 17763, 26100) without the patch-level UBR (9060, 8755, 32772). There are two precise ways to get the patch level, both requiring admin on the DC: read the file version of C:\Windows\System32\netlogon.dll over SMB (\\DC\C$), or read HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion through remote registry. The file-version read is the more reliable of the two, because it measures the exact binary that runs the vulnerable code; the registry UBR is the fallback when C$ is not reachable. Either way, any engagement that reaches local admin or domain admin on a DC can produce a definitive vulnerable-or-not verdict per DC in seconds.
Why is there no safe unauthenticated check like Zerologon?
This is the question that surprises most defenders. The NetLogon ping that reaches the vulnerable code is unauthenticated — anyone who can route a UDP packet to the DC can send it. So why does detecting CVE-2026-41089 still require admin credentials, when CVE-2020-1472 Zerologon can be checked with no credentials at all?
The answer is whether the vulnerability produces a non-destructive signal. Detection strategies fall into two families. Version-based detection reads the patched file version or build number and compares it — always safe, but it needs privileged read access. Behavioral detection exercises the vulnerable protocol path in a non-destructive way and observes a tell-tale response — it can be unauthenticated, but only when the vulnerability produces an observable signal short of the damage itself.
Zerologon has that observable. Its tester sends a NetrServerAuthenticate3 with an all-zero challenge roughly 256 times and watches whether the DC accepts the authentication — a signal that appears before the destructive machine-account password reset, so the tester stops there. PrintNightmare's remote check (CVE-2021-34527) is similar in spirit: it confirms the Print Spooler RPC interface (\pipe\spoolss) is reachable, which needs any authenticated user but not elevation.
CVE-2026-41089 has no such intermediate signal. A patched DC and an unpatched DC take the identical legacy code path through BuildSamLogonResponse; the only difference is the bounds check inside the helper. The sole observable of vulnerability is the crash itself — a stack-cookie overrun (STATUS_STACK_BUFFER_OVERRUN) that takes down lsass and reboots the DC. That is a destructive denial-of-service, not a safe probe, and it depends on the unreliable trigger conditions described above. There is no way to distinguish patched from unpatched over the wire without either privileged version access or knocking the DC over.
| Detection family | CVE-2020-1472 Zerologon | CVE-2021-34527 PrintNightmare | CVE-2026-41089 Netlogon BoF |
|---|---|---|---|
| Safe unauthenticated check | Yes (auth-bypass observable) | Reachability only | No |
| Credentials needed | None | Any user (low-priv) | Local/Domain Admin on DC |
| What you read | Protocol response | RPC pipe binding | netlogon.dll version / OS UBR |
| Why | Non-destructive accept signal | Service-exposure signal | Only observable is the crash (DoS) |
The practical takeaway: build-version enumeration is the only safe way to flag CVE-2026-41089 today, and it belongs in the authenticated, privileged phase of an assessment — not in an unauthenticated network sweep, where it would return "unknown" for every DC. You do not need a working exploit to convince a CISO to apply a CVSS 9.8 patch; you need evidence that a specific DC, by its specific build number, sits in the vulnerable range.
LongLogon: an open-source precondition checker
To put this analysis to work without crashing anything, we built and open-sourced a non-destructive checker. It sends benign CLDAP pings, measures the legacy-builder response, and reports whether a domain controller's domain is long enough that an unpatched DC would overflow, all without sending the overflow itself.
It deliberately does not determine patch state: a patched DC takes the identical code path and refuses the over-long write, so patched and unpatched look the same over the wire on a normally-named DC. Knowing which DCs are actually patched needs a credentialed version check across the fleet. The checker is open source: ADScanPro/CVE-2026-41089-LongLogon.
What did we learn from doing this research the hard way?
Three things worth keeping for next Patch Tuesday.
First: bindiff Microsoft cumulative updates beats waiting for public PoCs every time. The tooling is mature in 2026 — ghidriff with PyGhidra and Microsoft public symbols turns what used to be a weeks-long reverse engineering project into a one-command run that completes during a coffee break. Any defender or pentester who needs function-level CVE intelligence can produce it the day the patch ships, without depending on the security research community to do the work first.
Second: read advisories carefully before deciding how to allocate research time. Microsoft's CWE classification, exploitation-likelihood rating, and impact summary contain real information about whether the bug is reachable by surface-level fuzzing, requires heap grooming, or needs full exploit-dev engineering. CVE-2026-41089's "Exploitation Less Likely" rating combined with CWE-121 was, in retrospect, an accurate prediction that simple fuzzing would not produce a crash. Hours of network-level fuzzing could have been saved by reading the rating first and going straight to dynamic analysis on the target with a debugger attached.
Third: the gap between locating a bug and weaponizing it is enormous, and that gap is the value APT operators capture. The bindiff that took us four hours is what a competent reverse engineer produces in a single sitting. The exploit that those same APT operators are running in production today represents weeks or months of additional work — heap layout analysis, ROP chain construction per Windows build, ASLR and CFG bypass, payload staging. For most defensive use cases, the bindiff is sufficient. You do not need a working exploit to validate that a CVE applies to your environment, to detect exploitation attempts on your network, or to prioritize the patch in your maintenance window. You need the function name, the reach path, and the build version threshold. Those are the deliverables that move risk decisions, and those are reachable in an afternoon.
ADscan is an Active Directory security scanner that maps domain controllers, enumerates per-DC OS build during authenticated assessment, and renders the domain's attack paths in context. It does not yet ship an automated CVE-2026-41089 verdict — the version-comparison check described above is the correct detection approach and is on the roadmap as a credentialed, post-authentication check rather than an unauthenticated sweep.
- Download ADscan LITE (open source): https://github.com/ADScanPro/adscan
- Request PRO beta: https://adscanpro.com/pro
