[ EN ] Not every critical CVE is a really critical one - CVE-2026-41089

  • utorok, júl 28, 2026
Singel-post cover image

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:

  • NetpLogonPutUnicodeString contains the vulnerable copy.
  • BuildSamLogonResponse calls NetpLogonPutUnicodeString three times without checking return values.
  • NlGetLocalPingResponse owns 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_flag is set from a bit in NtVer.

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_count starts 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 the byte_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"
Python poc_cve_2026_41089.py script executing CLDAP ping on Netlogon DC with 200-character domain and 361 bytes payload including DNS section

The ntver=1 request responds with 130 As.

Connectionless LDAP protocol structure showing LDAPMessage searchRequest with Root baseObject filter containing DnsDomain and User parameters for Netlogon

The ntver=3 request responds with 130 As, plus the DC’s domain name. However, there is still no crash at this length.

Windows Blue Screen of Death crash notification displaying automatic system restart message triggered by stack buffer overflow in Netlogon lsass.exe

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"
Windows exception code c0000409 FAST_FAIL_STACK_COOKIE_CHECK_FAILURE shown in crash dump analysis with stack buffer overflow and LDAP GetRootDSEntry error details

The ntver=1 request still returns the 130-wchar response.

Python proof-of-concept CLDAP ping response showing 22 bytes NetLogon blob with LOGON_SAM_LOGON_RESPONSE opcode and minimal LmNtToken payload

The ntver=3 request crashes the DC.

Windows Registry Editor displaying HKLM CurrentControlSet Policies Microsoft FeatureManagement Overrides key for CVE-2026-41089 feature flag configuration
PowerShell Get-ItemProperty command querying HKLM SYSTEM CurrentControlSet Services Tcpip Parameters Domain and NV Domain registry values

Windows reports the crash and restarts the DC.

Hexadecimal dump of 443 bytes NetLogon response blob showing DNS suffix with UTF-8 encoded domain names GUID fields and null terminators

lsass.exe crash dump analysis

Crash dump analysis confirms that stack memory was overwritten, resulting in the FAST_FAIL_STACK_COOKIE_CHECK_FAILURE error.

PowerShell command using Set-ItemProperty to modify Domain and NV Domain registry values to 110-character DNS suffix with Netlogon service restart

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.

Patched domain controller NetLogon response showing 399 bytes safe blob with LOGON_SAM_LOGON_RESPONSE opcode without buffer overflow vulnerability

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
Proof-of-concept CLDAP ping with extended DNS suffix generating 516 bytes NetLogon blob demonstrating vulnerability reproduction and crash dump collection

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"
PowerShell Set-ItemProperty command modifying feature flag 516136079 to zero forcing Netlogon service restart to revert patched code to vulnerable version

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.

NetpLogonPutUnicodeString function bug analysis showing 326 bytes response blob with 130-wchar overflow in stack buffer vulnerability reproduction test

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.