CVE-2026-41089: Netlogon's WCHAR-count stack overflow
A Netlogon patch diff and live CLDAP trace show how a byte budget became a WCHAR count, overflowing a domain controller's stack response buffer.
- Published
Microsoft fixed CVE-2026-41089 in the May 2026 security updates. The advisory describes a stack-based buffer overflow in Windows Netlogon that can allow an unauthenticated remote attacker to execute code on a domain controller. Microsoft rates it Critical and assigns a CVSS 3.1 base score of 9.8.
The patch replaces a manual Unicode writer used by Netlogon response builders. The
old writer receives what its callers treat as a byte budget, but decrements it once
per WCHAR copied. A budget of 0x82 therefore permits 130 UTF-16 code units, or
260 bytes, before the unconditional two-byte terminator. The fixed writer uses
RtlStringCbCopyExW, which interprets the same value as bytes, and subtracts any
alignment padding with RtlULongSub before copying.
The public attack surface is a CLDAP Netlogon ping on UDP/389. I reproduced the vulnerable call chain and a deterministic stack-cookie failure on an affected Windows Server 2019 domain controller. The crash is configuration-dependent: attacker data fills the response, but long server-controlled DNS names provide the final bytes that cross the caller’s stack boundary.
The vulnerable writer, corrected size semantics, remote call chain, and stack-cookie corruption are proven. The observed result is an unauthenticated LSASS failure and domain-controller restart. Reliable code execution was not demonstrated;
/GSdetects the corruption before the overwritten return state can be used.
Advisory facts
| Field | Value |
|---|---|
| CVE | CVE-2026-41089 |
| Component | Windows Netlogon, netlogon.dll |
| Impact | Remote code execution |
| Weakness | CWE-121, Stack-based Buffer Overflow |
| CVSS | 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) |
| Microsoft severity | Critical |
| Exploitation assessment | Exploitation less likely |
| Publicly disclosed / exploited | No / No at publication |
| Release | May 12, 2026 |
Binary pair
The diff brackets the fix on the Windows Server 2019 build train:
| Vulnerable | Fixed | |
|---|---|---|
| Version | 10.0.17763.8385 |
10.0.17763.8755 |
| Update | Pre-May 2026 baseline | KB5087538 |
| Indexed SHA-256 | cb14187f77c55aac0bfc8c9293937eafb2c3f744cf52e1817818808742780f7f |
044760044666a552c2e378a461ffd76a797dbf523dcad1d86cb8b077429f43cc |
The binaries downloaded from Microsoft’s symbol server have different file hashes because they are re-signed, but their PE timestamp, image size, machine type, version, and PDB identity match the indexed artifacts.
The security-relevant changes form one coherent cluster:
NetpLogonPutUnicodeStringbecomes a status-returning, byte-bounded writer;- the old manual implementation remains as
NetpLogonPutUnicodeStringOldfor the runtime rollback path; BuildSamLogonResponse,NetpDcBuildPing, andPrimaryQueryHandlerselect the hardened writer whenEvaluateCurrentState()is enabled;- hardened callers propagate
ERROR_INVALID_PARAMETERinstead of continuing to serialize a response after a failed copy.
Prerequisites
The remotely reproduced CLDAP path requires the following conditions:
- The target is an affected Windows domain controller. Microsoft’s FAQ limits the attack to a Windows server acting as a domain controller. A member server that merely exposes SMB is not equivalent to the tested Netlogon/CLDAP surface.
- CLDAP on UDP/389 is reachable. The proven route is an LDAP RootDSE Netlogon query, not the initially suspected anonymous SMB mailslot route. It does not require credentials or an established secure channel.
- The query selects the legacy SAM Logon response. The reproduced request used
NtVer = 0x02, an acceptedDnsDomain, and a 130-characterUservalue. The user string reaches the old writer with a nominal0x82budget. - The server’s own names make the serialized response large enough. Short domain and host names reached the vulnerable writer but returned safely. The crash appeared only after the domain controller used a much longer DNS suffix. The query cannot substitute an arbitrary suffix: values that do not match the target’s domain state are rejected.
The exact reproduced configuration was:
| Setting | Reproduced value |
|---|---|
| Hostname | SILVERHAND12345 |
| NetBIOS domain | ARASAKA |
| Server DNS suffix | arasaka-nightcity-corporation-cyberpunk2077-secnet.blackwall-counterintel-research-division.enterprise-security-operations.secure-network-architecture.corp |
| DNS suffix length | 155 characters |
Accepted query DnsDomain |
arasaka-nightcity-corporation-cyberpunk2077-secnet.corp |
Query User |
130 UTF-16 characters |
Query NtVer |
0x00000002 |
| Transport | CLDAP, UDP/389 |
This is a known-working laboratory configuration, not a universal minimum. DNS compression, hostname length, NetBIOS names, and the selected response format all change the final layout. There is no registry value required for the reproduced path. The essential configuration prerequisite is enough legitimate server-side name data to carry the already overlong response past the fixed stack buffer.
Two other paths reached legacy writer code but did not reproduce the stack overflow:
- a UDP/138 Primary Query with
NtVersion = 1executed two old-writer calls, but their sources were the server hostname and NetBIOS domain rather than attacker-controlled text; DsrGetDcNameEx2with flags0x1080passed an attacker-controlled account toNetpDcBuildPing, but that builder used a sufficiently large heap allocation and remained in bounds in the measured request.
Corrected root cause
The first static report focused on alignment padding not being deducted from the remaining budget. The diff does add that check, but dynamic analysis and the preserved legacy loop expose the larger defect: the old function interprets its second argument as a count of UTF-16 elements, while its callers supply byte-sized limits.
BuildSamLogonResponse invokes the writer three times in the vulnerable response:
| Field | Budget | Control |
|---|---|---|
| Server/computer name | 0x24 |
Server-controlled |
| User/account | 0x82 |
Attacker-controlled |
| Domain name | 0x20 |
Server-controlled |
The attacker-controlled 0x82 field is the main response inflator. Instead of
limiting the copy to 130 bytes, the legacy loop allows 130 WCHAR values and then
writes a terminator. Subsequent server and DNS fields continue from the advanced
cursor and can cross the fixed stack response buffer.
Exact vulnerable code
The fixed binary preserves the vulnerable implementation as
NetpLogonPutUnicodeStringOld at 0x180035ae8. This Ghidra excerpt is the exact
legacy algorithm used by the vulnerable response builders; names and types are
decompiler recovery artifacts:
void NetpLogonPutUnicodeStringOld(
wchar_t *source,
uint budget,
ulonglong *cursor)
{
wchar_t value;
wchar_t *input;
longlong delta;
int remaining;
ulonglong count;
wchar_t *aligned;
count = (ulonglong)budget;
input = L"";
if (source != NULL)
input = source;
NetpULongPtrRoundUp(*cursor, 2, (ulonglong *)&aligned);
if (aligned != (wchar_t *)*cursor)
*(byte *)*cursor = 0;
value = *input;
if (value != L'\0') {
delta = (longlong)input - (longlong)aligned;
do {
remaining = (int)count;
count = (ulonglong)(remaining - 1);
if (remaining == 0)
break;
*aligned = value;
aligned = aligned + 1;
value = *(wchar_t *)(delta + (longlong)aligned);
} while (value != L'\0');
}
*aligned = L'\0';
*cursor = (ulonglong)(aligned + 1);
}
There are three relevant failures in this implementation:
budgetis decremented perwchar_t, doubling a caller-supplied byte budget.- A one-byte alignment pad is written but never deducted from the budget.
- The UTF-16 terminator is written unconditionally, even after the budget reaches zero.
The local guard-page model confirms the boundary error independently: with an odd
cursor and a two-byte budget, alignment consumes one byte, leaving too little room for
even a UTF-16 terminator. The legacy model crosses the reserved boundary; the fixed
model returns 0x57 before copying.
Exact fix
The fixed NetpLogonPutUnicodeString at 0x18002116c treats the limit as bytes,
charges the alignment pad to that limit with safe arithmetic, and returns a status to
its caller:
undefined8 NetpLogonPutUnicodeString(
wchar_t *source,
uint budget,
ulonglong *cursor)
{
short *old_cursor;
uint status;
wchar_t *input;
ulonglong remaining;
uint adjusted_budget[4];
longlong written;
short *aligned[2];
written = 0;
remaining = (ulonglong)budget;
if (1 < budget) {
input = L"";
if (source != NULL)
input = source;
adjusted_budget[0] = budget;
NetpULongPtrRoundUp(*cursor, 2, (ulonglong *)aligned);
old_cursor = (short *)*cursor;
if (aligned[0] != old_cursor) {
*(byte *)old_cursor = 0;
status = RtlULongSub(
(uint)remaining,
(int)aligned[0] - (int)old_cursor,
(int *)adjusted_budget);
if ((int)status < 0)
return 0x57;
remaining = (ulonglong)adjusted_budget[0];
}
status = RtlStringCbCopyExW(
aligned[0],
remaining & 0xffffffff,
input,
&written);
if (-1 < (int)status) {
*cursor = written + 2;
return 0;
}
}
return 0x57;
}
The two decisive operations are:
RtlULongSub(budget, aligned - old_cursor, &adjusted_budget);
RtlStringCbCopyExW(aligned, adjusted_budget, source, &written);
RtlULongSub prevents the padding adjustment from wrapping below zero.
RtlStringCbCopyExW receives a byte count, so a 0x82 budget now permits at most
130 bytes including the terminator, not 130 UTF-16 characters plus a terminator.
The fixed builders also fail closed. When the hardened writer returns non-zero, they abort response construction rather than advancing the cursor and appending more data.
Remote call chain
The dynamically confirmed unauthenticated route is:
CLDAP UDP/389
-> ntdsai!LDAP_CONN::SearchRequest
-> ntdsai!LDAP_GetRootDSEAttNetlogon
-> netlogon!I_NetLogonLdapLookupEx
-> netlogon!NlGetLocalPingResponse
-> netlogon!LogonRequestHandler
-> netlogon!BuildSamLogonResponse
-> netlogon!NetpLogonPutUnicodeStringOld
Kernel debugging confirmed the three writer calls and their arguments. The second call
received the attacker-controlled CLDAP account with edx = 0x82. The response then
continued with server-controlled domain and DNS data.
Confirmed corruption
On the long-domain target, the reproduced response had this stack-relative layout:
| Offset from response blob | Observation |
|---|---|
+0x200 |
First corrupted caller-cookie/saved-area bytes |
+0x210 |
Caller /GS cookie slot in the benign layout |
+0x240 |
Netlogon trailer and compression data |
+0x250 |
End of the observed response |
+0x258 |
Caller saved return address |
The response overwrote the caller cookie and saved-register area but stopped eight
bytes before the saved return address. The bytes at the cookie came from constrained
DNS/domain serialization and fixed trailer data. They could not reproduce the random
cookie value, and the caller checked /GS before consuming the saved return state.
The resulting STATUS_STACK_BUFFER_OVERRUN / fail-fast behavior terminated LSASS and
caused the domain controller to restart. This is a strong vulnerability and DoS proof,
but it is not a demonstrated RCE primitive. Extending the domain alone would still
have to bypass or repair the cookie before control flow could reach an overwritten
return address.
Conclusion
CVE-2026-41089 is a size-unit error in Netlogon’s legacy Unicode response writer. A
nominal byte limit is consumed as a count of WCHAR elements, and neither alignment
padding nor the final terminator is fully represented in the cursor budget. A CLDAP
request can use the oversized account field to fill a fixed stack response buffer;
long server-side DNS names then push serialization across the caller’s stack cookie.
The May 2026 update replaces the manual loop with a byte-bounded copy, safely subtracts alignment padding, and propagates copy failures through the response builders. The live lab confirms unauthenticated remote stack corruption and a reliable crash on a long-name domain controller. The patch should be applied regardless of domain naming, because short-name systems still contain the vulnerable writer even when their current response layout does not reach the stack boundary.