CVE-2026-41089 — Netlogon CLDAP LDAP-Ping Stack Buffer Overflow
| Component | netlogon.dll (Windows DC / LSASS) |
| Bug class | Stack buffer overflow (two-phase shared-cursor) |
| Vector (verified) | CLDAP rootDSE netlogon search over UDP/389 |
| Impact (verified) | Remote DoS (LSASS crash → CRITICAL_PROCESS_DIED → reboot) |
| RCE | No controllable primitive found on the CLDAP path under the tested configuration (see §9–§11) |
| MS rating context | “Exploitation Less Likely” — consistent with findings |
1. Summary
A CLDAP “LDAP ping” (an LDAP rootDSE search retrieving the netlogon attribute, per MS-ADTS §6.3.3) reaches a domain-controller response builder that packs a set of strings into a fixed 528-byte stack buffer (local_258[264] in NlGetLocalPingResponse). The buffer is filled in two independent phases that share one write cursor:
- Phase 1 — three
NetpLogonPutUnicodeStringcalls (logon-server name, username, NetBIOS domain) advance the cursor with per-field caps but no check against the buffer’s total size. - Phase 2 —
NlpUtf8ToCutf8packs the forest / domain / host FQDN strings against its own 260-count budget, blind to how far Phase 1 already advanced the cursor.
Neither phase accounts for the other, so on a DC whose own FQDN strings are long enough, the combined writes overrun the buffer and corrupt the stack cookie, producing STATUS_STACK_BUFFER_OVERRUN (0xC0000409) in LSASS.
The decisive constraint for exploitability: the bytes that cross the buffer boundary are the server’s own forest/domain/host names, not attacker input. The only attacker-controlled string echoed into the buffer (the User field) is capped at 130 wchars and positioned ~0x1E0 bytes before the cookie, so it cannot reach the control data. Confirmed dynamically (§8) and by a negative test (§10).
Prior work / attribution. This analysis builds on two public sources: the original public PoC, and ADScanPro’s patch-diff write-up and GitHub-issue correction (see References). ADScanPro independently established the
NtVerrouting (0x16→0x02), the0x82username cap, and the “long domain, not long username” precondition; this document reaches the same conclusions via independent static + dynamic analysis and adds a byte-level buffer layout, a tighter minimal triggering config, and a negative controllability test.
2. Lab / Tooling
- Targets: two
netlogon.dllbuilds in one Ghidra project (netlogon)netlogon_apr26.dll— pre-fix (April cumulative), 1810 functions, image base0x180000000netlogon_patched.dll— post-fix (May cumulative), 1819 functions
- Static analysis: Ghidra 12.x via a ghidra-mcp bridge (local loopback)
- Diffing: BinDiff (function-level patch confirmation)
- Dynamic: WinDbg over KDNET to an ESXi-hosted DC; user-mode symbol resolution via kernel-context switch (§7)
- PoC under test: public
poc.py(CLDAP sender), invoked aspython poc.py <target_ip> <dns_domain>
3. CVE / Vulnerable Path (verified call chain)
All edges below were confirmed via Ghidra xrefs/callees in netlogon_apr26.dll, not inferred from symbol names. Addresses are from the April build.
1
2
3
4
5
6
7
8
CLDAP UDP/389 rootDSE "netlogon" search
→ ntdsai!LDAP_GetRootDSEAttNetlogon
→ netlogon!I_NetLogonLdapLookupEx (0x180004490)
→ netlogon!NlGetLocalPingResponse (0x180004c50) [owns local_258[264], the 528-byte buffer + cookie]
→ branch on param_7:
param_7 == 0 → LogonRequestHandler (0x18000ae40) → BuildSamLogonResponseEx (0x180009d40) [SAFE]
param_7 != 0 → PrimaryQueryHandler (0x18005ce04) → BuildSamLogonResponse (0x18005ba14) [VULN]
→ NetpLogonPutUnicodeString (0x180020c00) [THE SINK]
The runtime stack captured at the crash (§8) corroborates the top of this chain exactly:
1
2
3
4
5
6
7
8
9
00 netlogon!__report_gsfailure
01 netlogon!NlGetLocalPingResponse+0x3ee
02 netlogon!I_NetLogonLdapLookupEx+0x3c0
03 ntdsai!LDAP_GetRootDSEAttNetlogon+0x7c
04 ntdsai!LDAP_GetDSEAtts+0x56c
05 ntdsai!LDAP_CONN::SearchRequest+0x73b
07 ntdsai!LDAP_CONN::ProcessRequestEx+0x868
0a ntdsai!UDPIoCompletion+0x18c <-- CLDAP over UDP/389
0b NTDSATQ!AtqpProcessContext+0xd6
4. The Sink — NetpLogonPutUnicodeString and the patch
Pre-fix (April) — unbounded manual copy loop
NetpLogonPutUnicodeString (0x180020c00) is a hand-rolled wchar copy loop. It advances a caller-provided cursor (*param_3, derived from the destination buffer) using only a per-field length counter as the bound — no validation that the destination can hold the count, and a terminating L'\0' written one past the loop stop.
Post-fix (May) — bounded copy + checked arithmetic
In netlogon_patched.dll, the function is rewritten:
1
2
3
4
5
6
uVar2 = RtlULongSub(len, (int)dst - (int)orig, &remaining); // checked: space remaining from CURRENT cursor
if ((int)uVar2 < 0) return 0x57; // ERROR_INVALID_PARAMETER
...
uVar2 = RtlStringCbCopyExW(dst, remaining, src, &written); // bounded copy
if (-1 < (int)uVar2) { *param_3 = written + 2; return 0; }
return 0x57;
calls_only_in_b from the BinDiff = ["RtlULongSub", "RtlStringCbCopyExW"]. The RtlULongSub is the missing cross-phase accounting: it computes remaining space from the current cursor position, which is exactly what Phase 1 / Phase 2 failed to share.
Feature-flag gating
The patched DLL retains the vulnerable body, renamed NetpLogonPutUnicodeStringOld (0x180035ae8), and selects between old/new at runtime:
1
2
3
4
5
6
7
8
9
// in patched PrimaryQueryHandler (0x18005d334), at the param_4 == 2 branch:
bVar1 = EvaluateCurrentState(...);
if (bVar1 == 0) { // feature OFF → vulnerable
NetpLogonPutUnicodeStringOld(... +0x108, 0x20, ...);
NetpLogonPutUnicodeStringOld(... +0x48, 0x20, ...);
} else { // feature ON → safe, with error propagation
if (NetpLogonPutUnicodeString(... +0x108, 0x20, ...) != 0 ||
NetpLogonPutUnicodeString(... +0x48, 0x20, ...) != 0) goto error;
}
EvaluateCurrentState (0x1800317d8) is a thin wrapper over EvaluateFeature reading a feature descriptor (g_Feature_1143190843_...), with a registry override path EvaluateCurrentStateFromRegistry → QueryFeatureOverride. This is consistent with Microsoft’s feature-flighting / staged-rollout infrastructure (the EvaluateFeature / QueryFeatureOverride family). EvaluateCurrentState does not exist by name in the April build — it is new in the fix.
Why a vulnerable twin ships: This answered the “is it normal to leave a vulnerable function reachable?” question. It is intentional staged-rollout behavior: the fix lands in the binary but its activation depends on flight/registry state. The old body is the feature-OFF path, not dead code and not an accident.
5. BinDiff — confirmed modified functions
Diffing adjacent monthly cumulative updates (April vs May — narrow gap to minimize noise; OS build held constant) confirmed the following functions changed:
NetpLogonPutUnicodeString— rewritten (loop →RtlStringCbCopyExW+RtlULongSub)BuildSamLogonResponse— modified (call sites + flow)NetpDcBuildPing— modifiedNlGetLocalPingResponse— modifiedPrimaryQueryHandler— modified; adds a call toEvaluateCurrentStatebefore the vulnerable writes (the feature gate)
Notably NOT in the patched set:
BuildSamLogonResponseEx— unchanged → the bounds-safe builder was already safeI_NetLogonLdapLookupEx— unchanged → filter parsing was already defensive
The absence of BuildSamLogonResponseEx from the patch is strong evidence the bug is in the legacy BuildSamLogonResponse builder, selected by the NtVer bits (§6), not in the ...Ex path.
6. MS-ADTS references and why they matter
6.1 Builder selection by NtVer (MS-ADTS §6.3.3.2)
The spec defines which response structure the DC packs based on the requested NtVer (NETLOGON_NT_VERSION) bits. Decompiled selection (matches spec, identical across builds):
1
2
3
4
5
6
7
if ((NtVer & 0xc) == 0) { // V5EX / V5EX_WITH_IP both clear
if ((NtVer & 0x2) != 0) { // V5 set
BuildSamLogonResponse(...) // legacy, VULNERABLE
}
} else {
BuildSamLogonResponseEx(...) // bounds-safe, NOT vulnerable
}
NtVer = 0x16:0x16 & 0xc = 0x4(non-zero) → routes to...Ex(safe). The public PoC’s default0x16never reaches the bug.NtVer = 0x02:& 0xc == 0and& 0x2 != 0→BuildSamLogonResponse(vulnerable).
This is why the PoC had to be modified NtVer 0x16 → 0x02. Internally this maps to PrimaryQueryHandler parameters param_5 & 0xc / param_5 & 2 selecting the direct NetpLogonPutUnicodeString block over the ...Ex arm.
6.2 Filter fields and validation (MS-ADTS §6.3.3 / §6.3.3.1 / §6.3.3.2)
The LDAP-ping filter is a client-supplied AND of equality tests: DnsDomain, Host, DnsHostName, User, AAC, DomainSid, DomainGuid, NtVer.
Critically, the spec dictates validation-then-substitution for the domain identity:
DnsDomainis used to resolvereqDnsNC— the server looks up which NC it hosts whose DNS name equals the supplied value. No matching hosted NC → invalid-filter response (§6.3.3.3); the builder never runs.- The response string fields are server-sourced:
DnsForestName→ “the DNS name of the forest” (server)DnsDomainName→ “the DNS name of NC reqNCUsed” (server-hosted NC)DnsHostName→ “the DNS name of the server” (server)NetbiosDomainName/NetbiosComputerName→ server namesUserName→u(the only attacker-controlled string echoed back)
Host/DnsHostNamefrom the filter are used only to compute the client’s site (resolve an IP for subnet matching), never packed as response strings.
Why this matters: the spec predicts (and the binary confirms, §9) that the attacker cannot supply the overflowing strings. The overflow magnitude is a function of the server’s own configured FQDN, hence the precondition is “a DC with a long domain name,” and hence MS’s “Exploitation Less Likely.”
7. WinDbg setup and reasoning
7.1 KDNET / boot notes
- ESXi DC; KDNET established (prior fix: vmxnet3 → e1000 adapter; second NIC for network; Secure Boot disabled).
- On reboot,
KDTARGET: Refreshing KD connectionis normal (transport re-handshake). If the target halts at the refresh line until a manual break, an initial-break-on-connect is enabled — disable it so reboots flow hands-off;vertarget/lmconfirm a healthy transport.
7.2 Why “break at the bugcheck” is too late
Catching the crash at CRITICAL_PROCESS_DIED (0xEF) is after LSASS teardown — threads are gone, the netlogon stack is unrecoverable. The correct capture is the __fastfail / cookie-check moment inside LSASS, before teardown.
!analyze -vshowingMANUAL_BREAKIN(0xE2) = you broke in yourself; no crash captured. Not useful.- The right trigger is a breakpoint on the cookie-failure path so you stop in LSASS context with the stack intact:
1
2
bp netlogon!__report_gsfailure ; fires ONLY when a cookie check fails → only the crashing packet
g ; then fire the PoC
__report_gsfailure is preferable to breaking on NlpUtf8ToCutf8 / NetpLogonPutUnicodeString, which fire on every benign ping.
7.3 Getting user-mode symbols from kernel context
After breaking in, switch into the LSASS process context (required for user-mode symbol resolution; PPL on LSASS blocks user-mode debugger attach, but kernel-context inspection works):
1
2
3
4
!process 0 0 lsass.exe ; get the EPROCESS
.process /i /p <EPROCESS> ; invasive switch into lsass address space
g ; let the context switch take effect (breaks again immediately)
.reload /user ; load user-mode symbols for the process
7.4 Reading the stack and buffer
1
2
3
4
5
6
k ; confirm frames: __report_gsfailure / NlGetLocalPingResponse / I_NetLogonLdapLookupEx / ntdsai...
.frame /r 1 ; anchor to NlGetLocalPingResponse (owns local_258 + cookie)
du <buffer_base> ; read packed response as wide string (spot username vs domain bytes)
db <buffer_base> L300 ; raw bytes across the 0x210 buffer extent + past the cookie
?? netlogon!__security_cookie ; canonical cookie
dq <cookie_region> L8 ; on-stack cookie / saved return for comparison
8. Crash capture (live, at __report_gsfailure)
8.1 Stack
Confirmed CLDAP vector and that the failing cookie belongs to NlGetLocalPingResponse’s own frame (+0x3ee, near tail). BuildSamLogonResponse had already returned (not on stack) — i.e., the cursor overran while filling local_258, detected on NlGetLocalPingResponse return.
8.2 Buffer layout (annotated db, buffer base ≈ 0x...d380)
1
2
3
4
5
6
7
8
9
10
d380 ..\.\.V.U.L.N.E. "\\VULNERABLE-DOMA" ← logon-server name (NetpLogonPutUnicodeString call 1, +0xd8)
d3a6 A.A.A. ... A.A. 130 × 'A' (0x41) ← USERNAME (call 2, param_4, cap 0x82=130) ← ONLY attacker-controlled string
d4aa A.B.C.D...N.O. "ABCDEFGHIJKLMNO" ← server NetBIOS domain (call 3, +0x48, cap 0x20)
d4cc 46 29 53 b9 ... GUID
d4ec 1a 'abc...xyz' CUTF-8 label ┐
d500 1a '1111...3333' CUTF-8 label │ ← NlpUtf8ToCutf8 phase: the
d520 04 'vuln' 05 'local' │ packed FQDN strings
c1 6c compression back-pointer │ (forest = vuln.local,
d530 3f 'vulnerable-domaincontroller' │ domain label, hostname)
d54c 6a 6a 6a ... 'j' run = hostname ┘
The label-length bytes (0x1a=26, 0x04=4, 0x05=5, 0x3f=63) and the 0xc1 xx DNS compression back-pointers are the signature of NlpUtf8ToCutf8’s output.
8.3 The overwrite at the cookie boundary
Buffer base+0x210 (the 528-byte extent / cookie region) ≈ 0x...d590:
1
2
d588 78 79 7a c1 87 31 31 31 31 31 31 31 31 31 32 32 "xyz..1111111112 2"
d598 32 32 32 32 32 32 32 32 33 33 33 33 33 33 c1 a2 "22222222333333.."
The bytes sitting on the cookie are server FQDN label data (xyz + c1 87 compression pointer + 1111.../2222.../3333... labels), not the username.
1
2
3
dq 000000b6`0097d290 L8
000000b6`0097d290 00000277`f4d7c6b0 00007ffe`2355dece ; ret → __report_gsfailure
000000b6`0097d2a0 00000000`00000202 ... ; frame data
8.4 Offset conclusion (the load-bearing number)
- Username region ends at ≈
d4a8. - Cookie at ≈
d590. - ⇒ ~0x1E0 (480) bytes of server-controlled writes sit between the furthest attacker-controlled byte and the cookie. In the observed layout this gap is the structural reason attacker data does not reach the saved return address / cookie — i.e., no controllable corruption was seen on this path.
9. Why filter strings can’t carry the overflow (static, I_NetLogonLdapLookupEx)
Filter-field parsing in I_NetLogonLdapLookupEx (0x180004490) is defensive — none of the client strings overflow a fixed buffer at parse time, and none is packed as a response string:
| Field | Destination | Bound | Overflow? | Role |
|---|---|---|---|---|
DnsDomain | heap NetApiBufferAllocate(len+1) | exact alloc | No | NC lookup key (param_4) |
Host | 16-wchar stack buf, else exact heap | ≤16 wchars stack / else heap | No | site/IP resolution |
DnsHostName | 256-wchar stack buf, else exact heap | ≤256 wchars stack / else heap | No | site/IP resolution |
User | FilterString(...0x15...) bounded | bounded | No (here) | echoed later, capped 0x82 |
DomainSid | memcpy w/ < 0x49 + RtlValidSid | size-checked | No | match key |
DomainGuid | memcpy w/ < 0x11 | size-checked | No | match key |
NtVer/AAC | memcpy w/ < 5 | size-checked | No | flags |
param_4 (the parsed DnsDomain) is passed to NlGetLocalPingResponse as a lookup key, matched against NlGlobalServicedDomains via DnsNameCompare_UTF8 / NlEqualDnsNameUtf8. A non-match takes the “we don’t host the named domain” bail — no packing, no overflow.
NlpUtf8ToCutf8 (0x18000a980) itself is budget-checked: it gates each copy on len + 3 <= remaining (*param_5, seeded 0x104 = 260) and returns 0x7a (ERROR_INSUFFICIENT_BUFFER) otherwise. So Phase 2 cannot overflow alone. The bug is purely the shared cursor: Phase 1’s NetpLogonPutUnicodeString calls advance the cursor unchecked-against-buffer-size, then Phase 2’s budget check is oblivious to that advance.
10. Controllability test — negative result (the decisive experiment)
Hypothesis under test: does the attacker-supplied DnsDomain get packed into the response (controllable bytes), or is it only a lookup key that must match a hosted NC (non-controllable)?
Method: keep the breakpoint bp netlogon!__report_gsfailure; vary only the DnsDomain CLI argument; observe whether the crash path is reached.
DC’s real configured domain (the hosted NC):
1
abcdefghijklmnopqrstuvwxyz.11111111112222222222333333.vuln.local
Test commands (same label lengths, distinct fill chars for spotting):
1
2
3
4
5
6
7
# MATCHING name (the DC's real NC):
python poc.py <target_ip> abcdefghijklmnopqrstuvwxyz.11111111112222222222333333.vuln.local
→ HITS netlogon!__report_gsfailure (crash path reached, LSASS overflow)
# NON-MATCHING name (same lengths, Z/Y fills, same .vuln.local suffix):
python poc.py <target_ip> ZZZZZZZZZZZZZZZZZZZZZZZZZZ.YYYYYYYYYYYYYYYYYYYYYYYY.vuln.local
→ does NOT hit the breakpoint (bails at DnsNameCompare_UTF8 / "don't host named domain")
Result: the mismatched domain never reaches __report_gsfailure. Reverting to the matching domain restores the crash.
Interpretation (confirmed): DnsDomain is a validated lookup key, not free input. To reach the builder at all, the supplied value must equal a hosted NC; what gets packed is the server’s canonical reqNCUsed name (which coincides with the arg only because the arg echoes the DC’s real name). This matches MS-ADTS §6.3.3.2 exactly. The attacker cannot inject arbitrary-length or arbitrary-content bytes into the overflow region via DnsDomain.
The negative result is as load-bearing as the crash: it converts “the code looks like it validates” into “validation empirically gates the overflow.” It is the proof of non-controllability and should not be omitted.
11. Trigger preconditions & exploitability verdict
To trigger (DoS):
- Vector: CLDAP rootDSE
netlogonsearch to UDP/389. NtVer = 0x02(selects legacyBuildSamLogonResponse).DnsDomain= the DC’s actual (long) configured NC name (must pass NC match).- Server-side precondition: the DC’s own forest/domain/host FQDN strings, summed (post-CUTF-8-compression), push the shared cursor past the 528-byte buffer.
Minimal buildable triggering config (verified): a DC built with a single 63-char domain label (max DNS label) and a max-length hostname, forest == domain (root DC), default PoC username length (130). This crashed LSASS reliably. Notably, this crossed the threshold even with the 0xc1 xx compression back-pointers shrinking the repeated vuln.local suffix — i.e., forest == domain (with compression) is sufficient; the forest≠domain “avoid compression” trick is not required. This is a tighter minimum than the ~98-char figure ADScanPro reported (see References), and the difference is consistent with config/build variation rather than a contradiction — their figure was measured on a different host/build, and both confirm the same “long domain is the precondition” conclusion.
Exploitability: remote DoS; no controllable primitive found (CLDAP path, tested config).
- The overwriting bytes are server config (DC’s FQDN) — not attacker-shaped under any input tested here.
- The only attacker-controlled string (
User) is capped (130 wchars) and sits ~0x1E0 bytes before the cookie — structurally unable to reach control data in the observed layout. /GScookie + the non-controllable corrupting content ⇒ cleanSTATUS_STACK_BUFFER_OVERRUN/__fastfail→ DoS/reboot.- This is a scoped negative: no controllable corruption was found on the CLDAP path with the configurations tested. It is not a proof that none exists. “RCE possible” in advisories is best read as classification by bug class (stack overflow in LSASS) rather than a demonstrated chain; no public RCE chain is known at the time of writing.
12. PoC assessment
Public PoC verdict: wrong on parameters, right on vector — not malicious, just assembled statically without dynamic verification.
| PoC claim / default | Reality |
|---|---|
NtVer = 0x16 default | Routes to safe ...Ex; needs 0x02 to hit legacy builder |
| Overflow driven by long username | User capped at 0x82/130 wchars; cannot overflow alone |
| Vector = CLDAP/UDP 389 | Correct (confirmed by ntdsai!...UDPIoCompletion stack) |
The real precondition is a long domain (server-side), not a long username. A default-named DC (corp.local, vuln.local) cannot be crashed regardless of username length — which is why vuln.local did not trigger until the DC was rebuilt with a max-length domain label + hostname.
13. Out of scope / open threads
- Exact byte-accounting formula: derived qualitatively (Phase 1 ~396B + capped User + Phase 2 FQDN). A precise closed-form threshold as a function of (forest_len, domain_len, host_len, user_len, compression) was not finalized; the empirical minimum config in §11 is the practical answer.
14. Key learnings
- Verify call chains via xrefs, not symbol-name inference. The PoC’s middle frames were wrong precisely because they were pattern-matched, not traced.
- Decompiler output is a hypothesis, not ground truth. An apparent
param_7 = 0literal was actuallyR12Din the disassembly — same value, but only provable by reading instructions. Verify constants against the disasm. - The negative test is the proof. Mismatched-
DnsDomain→ no breakpoint hit is what actually established non-controllability; the crash alone didn’t. - “Locally safe” composes into “globally unsafe.” Both write phases pass their own checks; the bug is the unaccounted shared cursor between them. The fix (
RtlULongSub) is exactly the missing cross-phase remaining-space computation. - Capture at the fault, not the bugcheck. LSASS teardown destroys the stack; break on
__report_gsfailurein process context to read the intact buffer. - Modern patches ship gated. The vulnerable body is retained as
...Oldbehind a feature flag (EvaluateCurrentState/QueryFeatureOverride) — reachable depending on flight/registry state. - Spec + binary together. MS-ADTS §6.3.3 predicted the validation-then- substitution behavior; the binary and the dynamic test confirmed it. Either alone would have been weaker.
15. References
- Original public PoC —
0xABCD01/CVE-2026-41089(GitHub). The CLDAP sender used as the starting point; this analysis corrects itsNtVerdefault and the long-username assumption. - ADScanPro — GitHub issue #1 on the PoC repo: “PoC does not trigger on a normally-named DC: NtVer routing + User-field cap (binary analysis)” (
github.com/0xABCD01/CVE-2026-41089/issues/1). Independently established theNtVerrouting, the0x82cap, and the long-domain precondition, with lab corroboration (97/98-char threshold on Server 2016). Credit to ADScanPro for that correction. - ADScanPro — patch-diff write-up and checker:
github.com/ADScanPro/CVE-2026-41089-LongLogonand the accompanying blog post. A non-crashing checker that measures whether a DC’s domain is long enough to be at risk; ADScanPro deliberately withheld a weaponized trigger. - MS-ADTS ([MS-ADTS] Active Directory Technical Specification) §6.3.1.1 (NETLOGON_NT_VERSION options), §6.3.3 / §6.3.3.1 / §6.3.3.2 / §6.3.3.3 (LDAP ping, filter validation, DC response, invalid-filter response).
- Microsoft Security Update Guide — CVE-2026-41089 advisory (“Exploitation Less Likely”).
Disclosure note. This document is analysis-grade: it describes the bug mechanism, preconditions, and a DoS-only crash, and does not publish a weaponized exploit. The trigger parameters discussed here (
NtVer=0x02, long-domain precondition) were already public via the sources above prior to this write-up. In keeping with ADScanPro’s stated approach, no weaponized trigger is included.