You have probably heard of the critical Windows Netlogon remote code execution vulnerability, tracked as CVE-2026-41089 with a 9.8 CVSS score. Microsoft’s advisory describes it as a stack-based buffer overflow in Windows Netlogon that allows an unauthorized attacker to execute code over a network. After a while, media reports claimed it was already being exploited in the wild.
Our analysis confirms the vulnerability is real, but whether it is exploitable in practice is what we tried to answer.
Netlogon intro
Netlogon runs inside lsass.exe and implements DC discovery, among its other responsibilities. The part relevant here is DC Locator - the protocol clients use to find a domain controller. It runs over CLDAP (connectionless LDAP) on UDP 389. A client has to find a DC before it can authenticate to one, so this happens before Kerberos gets involved, and DC discovery itself requires no authentication by design.
When a client asks about a username that does not exist, the DC responds with the same format, just a different opcode (LOGON_SAM_USER_UNKNOWN = 21 vs LOGON_SAM_LOGON_RESPONSE = 19). Either way, the username from the request gets copied straight into the response, and that is where the vulnerability is located.
Patch diff
Binary diffing the 10.0.17763.8385 (vulnerable) and 10.0.17763.8755 (patched) versions flags six changed functions.
However, only three of them matter:
NetpLogonPutUnicodeStringcontains the vulnerable copy.BuildSamLogonResponsecallsNetpLogonPutUnicodeStringthree times without checking return values.NlGetLocalPingResponseowns the buffer that overflows.
BuildSamLogonResponse
When we send a CLDAP ping with a 200-char User field, it reaches BuildSamLogonResponse through I_NetLogonLdapLookupEx -> NlGetLocalPingResponse -> LogonRequestHandler -> BuildSamLogonResponse with no length check on the way. BuildSamLogonResponse builds the entire response and writes it into Src[528], the 528-byte stack buffer declared in NlGetLocalPingResponse.
The code below is from the vulnerable build (netlogon_10.0.17763.8385.dll):
__int64 __fastcall BuildSamLogonResponse(
// domain object with DC name, DNS names, etc.
__int64 domain_obj,
char is_alias,
// 19 = user found, 21 = unknown user
unsigned __int16 opcode,
// attacker-supplied User= string
__int64 username,
__int64 sender_name,
__int64 transport,
int dns_section_flag,
u_long dc_ip_addr,
// pointer into Src[528] on the caller's stack
unsigned __int16 *response_buf,
__int64 msg_size_out)
{
int nt_version_out;
unsigned __int64 tail_cursor;
unsigned int status;
unsigned int names_packed;
nt_version_out = 0;
*response_buf = opcode;
write_cursor = (u_long *)(response_buf + 1);
// no return-value checks - a failed copy does not stop the next write
// UnicodeLogonServer, 36 bytes limit
NetpLogonPutUnicodeString(domain_obj + 216, 36LL, &write_cursor);
// UnicodeUserName, 130 bytes limit
NetpLogonPutUnicodeString(username, 130LL, &write_cursor);
// UnicodeDomainName, 32 bytes limit
NetpLogonPutUnicodeString(domain_obj + 72, 32LL, &write_cursor);
// NtVer=0x01 - additional info (GUID and DNS section) is never written
if (!dns_section_flag)
{
tail_cursor = (unsigned __int64)write_cursor;
goto tail;
}
char zero_byte[8];
zero_byte[0] = 0;
// DomainGuid (16 bytes)
NetpLogonPutBytes(domain_obj + 192, 16LL, &write_cursor);
// DomainGuid_null (16 bytes)
NetpLogonPutBytes(&NlGlobalZeroGuid, 16LL, &write_cursor);
if (dc_ip_addr)
{
// shared across all three calls below
utf8_string_size = 260;
names_packed = 0;
// DnsForestName
status = NlpUtf8ToCutf8(..., &write_cursor, &utf8_string_size, &names_packed, ...);
if (!status && names_packed < 3)
// DomainName
status = NlpUtf8ToCutf8(..., &write_cursor, &utf8_string_size, &names_packed, ...);
if (!status && names_packed < 3)
// HostName
status = NlpUtf8ToCutf8(..., &write_cursor, &utf8_string_size, &names_packed, ...);
if (status) return status;
}
else
{
NetpLogonPutBytes(zero_byte, 1LL, &write_cursor);
NetpLogonPutBytes(zero_byte, 1LL, &write_cursor);
NetpLogonPutBytes(zero_byte, 1LL, &write_cursor);
}
// DcIpAddress (4 bytes)
*write_cursor = ntohl(dc_ip_addr);
// Flags (4 bytes)
write_cursor[1] = NlGetDomainFlags(domain_obj);
nt_version_out = 2;
tail_cursor = (unsigned __int64)(write_cursor + 2);
tail:
// NtVersion(4) + LmNtToken(2) + Lm20Token(2)
*(_DWORD *)tail_cursor = nt_version_out | 1;
// ...
}
Three strings go into the buffer - server name (36-byte limit), username (130-byte limit), and domain name (32-byte limit). The
dns_section_flagis set from a bit inNtVer.
The bug in NetpLogonPutUnicodeString
BuildSamLogonResponse calls NetpLogonPutUnicodeString(username, 130LL, &write_cursor) expecting at most 130 bytes to be written. Here is what the function does:
_WORD *__fastcall NetpLogonPutUnicodeString(
__int64 src_str,
__int64 byte_limit,
unsigned __int64 *write_cursor_ptr)
{
char *src_ptr;
int iter_count;
char *dst_ptr;
__int16 cur_wchar;
signed __int64 src_minus_dst;
char *aligned_dst;
_WORD *result;
NetpULongPtrRoundUp(*write_cursor_ptr, 2LL, (__int64 *)&aligned_dst);
dst_ptr = aligned_dst;
if (aligned_dst != (char *)*write_cursor_ptr)
*(_BYTE *)*write_cursor_ptr = 0;
cur_wchar = *(_WORD *)src_ptr;
if (*(_WORD *)src_ptr)
{
src_minus_dst = src_ptr - dst_ptr;
do
{
// BUG: counts iterations, not bytes
// 130 iterations * 2 bytes = 260 bytes written
if (!iter_count--) break;
*(_WORD *)dst_ptr = cur_wchar;
dst_ptr += 2;
cur_wchar = *(_WORD *)&dst_ptr[src_minus_dst];
}
while (cur_wchar);
}
result = (_WORD *)(dst_ptr + 2);
// null terminator (+2 bytes)
*(_WORD *)dst_ptr = 0;
*write_cursor_ptr = (unsigned __int64)(dst_ptr + 2);
return result;
}
iter_countstarts at 130 and counts down once per iteration, but each iteration writes a 2-byte wchar. The loop runs 130 times and writes 260 bytes into a 130-byte buffer - double the intended write, with no checks that it stays within thebyte_limit.
With NtVer = 0x01, the function skips the GUID and DNS section and jumps straight to a shorter version. The DC returns 130 wchars where at most 65 were expected. Even with the doubled username, the total response still comes to 326 bytes, well short of the 528 bytes the Src[528] buffer holds.
With NtVer = 0x03, the DNS section is added too - domain GUID, null GUID, the DC’s own DNS names, then IP and flags. In our lab, the domain name was 62 characters long, which brought the total response to 443 bytes - still within the 528-byte buffer.
None of this crashes a standard DC through username length alone. The iter_count is hardcoded to 130, so the overflow is fixed at 130 extra bytes once the User value reaches that length, no matter how long a username we send. A crash requires the DC’s own DNS suffix to be pushed past the AD limit, which requires administrator privileges.
The fix
The manual iteration loop in NetpLogonPutUnicodeString was replaced with RtlStringCbCopyExW, and additional checks were added to confirm the string still fits in the buffer.
-_WORD *__fastcall NetpLogonPutUnicodeString(
- __int64 src_str,
- __int64 byte_limit,
+__int64 __fastcall NetpLogonPutUnicodeString(
+ const WCHAR *src_str,
+ unsigned int byte_limit,
unsigned __int64 *write_cursor_ptr)
{
- char *src_ptr;
- int iter_count;
- char *dst_ptr;
- __int16 cur_wchar;
- signed __int64 src_minus_dst;
- char *aligned_dst;
- _WORD *result;
+ const WCHAR *src_ptr;
+ unsigned int remaining;
+ unsigned int byte_limit_tmp;
+ _BYTE *raw_dst;
+ __int64 aligned_dst;
+ __int64 aligned_dst_buf[3];
+ __int64 end_ptr;
+
+ byte_limit_tmp = byte_limit;
+ end_ptr = 0LL;
+ // reject limits under 2 bytes
+ if (byte_limit < 2)
+ return 87LL;
+ // NULL src_str falls back to `base` instead of crashing
+ src_ptr = &base;
+ if (src_str)
+ src_ptr = src_str;
- NetpULongPtrRoundUp(*write_cursor_ptr, 2LL, (__int64 *)&aligned_dst);
- dst_ptr = aligned_dst;
- if (aligned_dst != (char *)*write_cursor_ptr)
- *(_BYTE *)*write_cursor_ptr = 0;
-
- cur_wchar = *(_WORD *)src_ptr;
- if (*(_WORD *)src_ptr)
- {
- src_minus_dst = src_ptr - dst_ptr;
- do
- {
- // BUG: counts iterations, not bytes
- // 130 iterations * 2 bytes = 260 bytes written
- if (!iter_count--) break;
-
- // write one wchar (2 bytes) to destination
- *(_WORD *)dst_ptr = cur_wchar;
- dst_ptr += 2;
- cur_wchar = *(_WORD *)&dst_ptr[src_minus_dst];
- }
- while (cur_wchar);
- }
-
- result = (_WORD *)(dst_ptr + 2);
- // null terminator (+2 bytes)
- *(_WORD *)dst_ptr = 0;
- *write_cursor_ptr = (unsigned __int64)(dst_ptr + 2);
- return result;
+ NetpULongPtrRoundUp(*write_cursor_ptr, 2LL, aligned_dst_buf);
+ raw_dst = (_BYTE *)*write_cursor_ptr;
+ aligned_dst = aligned_dst_buf[0];
+ if (aligned_dst_buf[0] != *write_cursor_ptr)
+ {
+ *raw_dst = 0;
+ if ((int)RtlULongSub(remaining, (unsigned int)(aligned_dst - (_DWORD)raw_dst), &byte_limit_tmp) < 0)
+ return 87LL;
+ remaining = byte_limit_tmp;
+ }
+ if ((int)RtlStringCbCopyExW(aligned_dst, remaining, src_ptr, &end_ptr) >= 0)
+ {
+ *write_cursor_ptr = end_ptr + 2;
+ return 0LL;
+ }
+ return 87LL;
}
In BuildSamLogonResponse, a feature flag was added. The original NetpLogonPutUnicodeString still exists in the binary - it was renamed to NetpLogonPutUnicodeStringOld and remains accessible if the feature flag is enabled. Additionally, return-value checks were added to exit early if a copy fails.
+if (!EvaluateCurrentState()) {
+ // feature flag = 0, run old vulnerable function
+ NetpLogonPutUnicodeStringOld(domain_obj + 216, 36LL, &write_cursor);
+ NetpLogonPutUnicodeStringOld(username, 130LL, &write_cursor);
+ NetpLogonPutUnicodeStringOld(domain_obj + 72, 32LL, &write_cursor);
+ goto dns_section;
+}
+
- // UnicodeLogonServer, 36 bytes limit
- NetpLogonPutUnicodeString(domain_obj + 216, 36LL, &write_cursor);
- // UnicodeUserName, 130 bytes limit
- NetpLogonPutUnicodeString(username, 130LL, &write_cursor);
- // UnicodeDomainName, 32 bytes limit
- NetpLogonPutUnicodeString(domain_obj + 72, 32LL, &write_cursor);
+
+copy_result = NetpLogonPutUnicodeString(domain_obj + 216, 36LL, &write_cursor);
+if (!(_DWORD)copy_result) {
+ copy_result = NetpLogonPutUnicodeString(username, 130LL, &write_cursor);
+ if (!(_DWORD)copy_result) {
+ copy_result = NetpLogonPutUnicodeString(domain_obj + 72, 32LL, &write_cursor);
+ if (!(_DWORD)copy_result) {
+ goto dns_section;
+ }
+ }
+}
Live test
The lab domain uses a 62-character DNS name, close to Active Directory’s 64-character limit.
Crash dump collection
The test is expected to crash lsass.exe, so dump collection needs to be turned on beforehand:
# https://learn.microsoft.com/en-us/troubleshoot/windows-server/performance/memory-dump-file-options#registry-values-for-startup-and-recovery
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl" -Name CrashDumpEnabled -PropertyType DWord -Value 1
mkdir C:\CrashDumps
New-Item -Path "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\lsass.exe" | Out-Null
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\lsass.exe" -Name DumpFolder -Value "C:\CrashDumps" -PropertyType ExpandString
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\lsass.exe" -Name DumpType -Value 2 -PropertyType DWord
Running PoC against our 62-char domain
Confirming the current suffix on the DC:
Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters" -Name Domain,"NV Domain"
The ntver=1 request responds with 130 As.
The ntver=3 request responds with 130 As, plus the DC’s domain name. However, there is still no crash at this length.
Long DNS suffix
Active Directory domain names are restricted to 64 characters, so we cannot create a longer domain name directly. To abuse the overflow, we (as an administrator) have to manually change the DNS suffix to a length that would overflow the buffer. We can demonstrate this by changing these registry values:
HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Domain
HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\NV Domain
These registry values are not checked against the 64-character limit. A Netlogon service restart is enough to pick up the change - there is no need to reboot the DC:
Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters" -Name Domain,"NV Domain"
$longsuffix = "x" * 110
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters" -Name "Domain" -Value $longsuffix
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters" -Name "NV Domain" -Value $longsuffix
Restart-Service Netlogon -Force
Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters" -Name Domain,"NV Domain"
The ntver=1 request still returns the 130-wchar response.
The ntver=3 request crashes the DC.
Windows reports the crash and restarts the DC.
lsass.exe crash dump analysis
Crash dump analysis confirms that stack memory was overwritten, resulting in the FAST_FAIL_STACK_COOKIE_CHECK_FAILURE error.
Running PoC against patched DC
Running the same test against the patched DC does nothing, even with the long DNS suffix set. We can see that no Netlogon response was returned - that is what the new checks in BuildSamLogonResponse do, exiting early on a failed copy.
The feature flag
Whether the safe (NetpLogonPutUnicodeString) or vulnerable (NetpLogonPutUnicodeStringOld) code is run depends on EvaluateCurrentState(), which reads a registry value under HKLM\System\CurrentControlSet\Policies\Microsoft\FeatureManagement\Overrides.
The value name is 516136079 - it is derived at runtime from the hardcoded constant inside the EvaluateCurrentStateFromRegistry() function:
// EvaluateCurrentStateFromRegistry()
QueryFeatureOverride(__ROR4__(_byteswap_ulong(a1 ^ 0x74161A4E) ^ 0x8FB23D4F, 255) ^ 0x833EA8FF,...);
Running that returns the value 516136079:
def byteswap32(v):
return ((v & 0xFF) << 24) | (((v >> 8) & 0xFF) << 16) | (((v >> 16) & 0xFF) << 8) | ((v >> 24) & 0xFF)
def ror32(v, n):
n %= 32
return ((v >> n) | (v << (32 - n))) & 0xFFFFFFFF
a1 = 61953679
step1 = a1 ^ 0x74161A4E
step2 = byteswap32(step1)
step3 = step2 ^ 0x8FB23D4F
step4 = ror32(step3, 255)
result = step4 ^ 0x833EA8FF
print(result) # 516136079
Setting the value to 0 forces the vulnerable path on a fully patched DC:
reg add "HKLM\System\CurrentControlSet\Policies\Microsoft\FeatureManagement\Overrides" /v 516136079 /t REG_DWORD /d 0 /f
The DC needs a reboot to pick up the change - this registry value is read only once, at lsass startup.
Live test with feature flag enabled
The DNS suffix was reverted to the 62-char domain before the test.
Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters" -Name Domain,"NV Domain"
Running the PoC with ntver=3 against the patched DC with the feature set up, we again get 130 As, the same output as when running the PoC against the unpatched DC. To crash the DC, a user with administrator privileges would again need to set the DNS suffix to a long one.
Interesting facts and conclusion
As of July 2026, CVE-2026-41089 does not appear in CISA’s Known Exploited Vulnerabilities catalog, despite a CCB Belgium advisory (CCB’s X alert) claiming active exploitation during the May 2026 Patch Tuesday. CCB’s only stated source was “trusted partners,” with no further detail given.
BleepingComputer reported that Microsoft disputes the claim - Microsoft has no evidence of active exploitation, while still recommending customers install the latest updates.
Based on our own analysis, this vulnerability seems hard to weaponize in practice - reaching a crash requires administrative privileges the attacker would already need to have obtained. It may be used as a backdoor mechanism; however, during testing we found Active Directory Domain Services unstable on the DC when a long DNS suffix was set in the registry.