Root cause
Cached allow survives DNS rebinding
Because a host that once resolved to a private address stays cached as trusted, later resolution to an attacker host still receives the user's default credentials.
AI-assisted change: GitHub Copilot
Provisional finding. Full causal and release verification remains incomplete.
Before the mitigation, WPF attached default credentials to every outgoing request. The Copilot-co-authored zone policy fast-pathed private addresses and cached that answer for the life of the process, so a host that later resolved elsewhere still received credentials. Fixed in 8.0.32, 9.0.21, 10.0.13 and 11.0.0-rc.2.
AI-assisted change
Copilot co-authored an incomplete hardening that added DefaultCredentialsZonePolicy to block default credentials for Internet-zone hosts, but it cached private-address DNS answers for the process lifetime. Earlier code already sent credentials to every host, so this was not the first introduction.
Root cause
Because a host that once resolved to a private address stays cached as trusted, later resolution to an attacker host still receives the user's default credentials.
AI-assisted change: GitHub Copilot
Fix
The repair caches only deny decisions with a short expiration and always re-resolves allow decisions, so a rebound host no longer gets default credentials.
Fix authorship not yet analyzed
Case facts
Cite this finding
Vibe Security Radar, "CVE-2026-96234 in dotnet/wpf," Georgia Tech SSLab, https://vibesecradar.com/cves/CVE-2026-96234/
@@ -0,0 +1,305 @@+// Licensed to the .NET Foundation under one or more agreements.+// The .NET Foundation licenses this file to you under the MIT license.++// Security note+// Mitigation: see DefaultCredentialsZonePolicy / PackageBoundaryGuard+// Kill switches (default off):+// Switch.System.Windows.Net.DoNotApplyZoneCheckForDefaultCredentials+// Switch.System.Windows.Documents.DisableXpsPackageBoundaryEnforcement+// Do NOT remove the gate logic without security review.++//+// Description:+//+// Helper that decides whether default (NTLM/Kerberos/Negotiate) credentials+// should be attached to an outgoing WebRequest, based on the URL Security Zone+// of the target URI.+//+// Historical note:+// On .NET Framework, WPF used to register an ICredentialPolicy with+// System.Net.AuthenticationManager.CredentialPolicy. The framework's auth+// client modules (NTLM/Kerberos/Negotiate/Basic/Digest) would consult that+// policy when responding to a 401 challenge and, for Internet/Untrusted+// zones, suppress the credentials. That mechanism is implemented by+// <see cref="CustomCredentialPolicy"/>, which is preserved in source for+// backward-compatibility purposes (selected via the AppContext switch+// "Switch.System.Windows.Net.DoNotApplyZoneCheckForDefaultCredentials").+//+// On .NET 5+, AuthenticationManager.CredentialPolicy is obsolete (SYSLIB0009)+// and is a runtime no-op: the new HttpClient-based pipeline never consults+// it. Registering a policy therefore does nothing, which silently regressed+// the original safety behavior.+//+// To restore .NET Framework parity, callers can perform the zone check+// inline before enabling UseDefaultCredentials by calling+// ShouldSendDefaultCredentials below. This is the default behavior; the+// AppContext switch above can be set to true to opt out and restore the+// previous (no-op) registration path as a compatibility escape hatch.+//+// Policy:+// Allow default credentials to flow only to Local Machine / Intranet /+// Trusted zones. Block them for Internet / Untrusted zones.+//+// On modern Windows installs without Internet Explorer/Edge zone configuration+// (and in some service / lockdown contexts), IInternetSecurityManager.MapUrlToZone+// can return URLZONE_INTERNET even for loopback (127.0.0.1, ::1) and RFC1918+// private addresses. To preserve the documented intent and avoid silently+// regressing intranet/loopback authentication scenarios, an explicit IP-based+// pre-check is performed for definitively non-routable destinations+// (loopback / RFC1918 / link-local / IPv6 link-local & ULA) before falling+// back to MapUrlToZone. The pre-check can only relax the decision in cases+// where the destination is provably non-public; it never widens trust to a+// public IP, so the Internet-zone leak protection is preserved.+//+//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!+// IMPORTANT: We are creating an instance of IInternetSecurityManager here. This+// is currently also done in the AppSecurityManager at the Framework level and+// in CustomCredentialPolicy. Any modification to either of these classes--+// especially concerning MapUrlToZone--should be considered for both classes.+//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!+//+// An IInternetSecurityManagerSite is not currently needed here, because the+// only method of IInternetSecurityManager that we are calling is MapUrlToZone,+// and that does not prompt the user.+//++using System;+using System.Collections.Concurrent;+using System.Net;+using System.Net.Sockets;+using System.Runtime.InteropServices;++using MS.Internal.PresentationCore;+using MS.Win32;++namespace MS.Internal.AppModel+{+ internal static class DefaultCredentialsZonePolicy+ {+ static DefaultCredentialsZonePolicy()+ {+ _lockObj = new object();+ }++ /// <summary>+ /// Returns true if it is safe to attach default (NTLM/Kerberos/Negotiate)+ /// credentials to a request targeted at <paramref name="uri"/>.+ ///+ /// Restores the .NET Framework behavior that used to be provided by the+ /// now-obsolete <c>AuthenticationManager.CredentialPolicy</c> mechanism@@ -141,12 +141,32 @@ internal static bool ShouldSendDefaultCredentials(Uri uri) } } - // Per-host result cache. The decision for a given hostname is stable for the- // lifetime of the process barring DNS changes; caching avoids a synchronous- // DNS lookup on every outgoing request.- private static readonly ConcurrentDictionary<string, bool> s_localHostCache =+ // Per-host result cache with expiration. Positive ("send credentials")+ // decisions are NEVER cached because a stale "true" is the dangerous+ // direction — it would leak credentials after a DNS rebinding attack.+ // Negative ("do not send") decisions are cached with a short TTL since+ // a stale "false" is safe (fail-closed).+ private static readonly ConcurrentDictionary<string, CachedZoneDecision> s_localHostCache = new(StringComparer.OrdinalIgnoreCase); + // Cache TTL for negative (deny) decisions only. Positive decisions are+ // always re-resolved to prevent DNS rebinding credential leaks.+ private static readonly TimeSpan s_cacheTtl = TimeSpan.FromSeconds(120);++ private readonly struct CachedZoneDecision+ {+ public readonly bool IsLocalOrPrivate;+ public readonly long ExpiresAtTicks;++ public CachedZoneDecision(bool isLocalOrPrivate)+ {+ IsLocalOrPrivate = isLocalOrPrivate;+ ExpiresAtTicks = Environment.TickCount64 + (long)s_cacheTtl.TotalMilliseconds;+ }++ public bool IsExpired => Environment.TickCount64 >= ExpiresAtTicks;+ }+ /// <summary> /// Returns true when every IP address that <paramref name="host"/> resolves /// to is non-routable: IPv4 loopback (127.0.0.0/8), IPv4 link-local@@ -159,7 +179,8 @@ internal static bool ShouldSendDefaultCredentials(Uri uri) /// loopback/Intranet decisions are reliable even on modern Windows installs /// where the zone manager is degraded (no IE/Edge zone configuration). /// DNS failures resolve to "not local/private" and fail closed in the caller.- /// Results are cached per host for the lifetime of the process.+ /// Positive (allow) decisions are always re-resolved to prevent DNS rebinding.+ /// Negative (deny) decisions are cached with a TTL. /// </remarks> private static bool AllResolvedAddressesAreLocalOrPrivate(string host) {@@ -168,41 +189,54 @@ private static bool AllResolvedAddressesAreLocalOrPrivate(string host) return false; } - return s_localHostCache.GetOrAdd(host, static h =>+ // Only serve cached NEGATIVE results (fail-closed is safe to cache).+ // Positive results must always be re-resolved to detect DNS rebinding.+ if (s_localHostCache.TryGetValue(host, out CachedZoneDecision cached)+ && !cached.IsExpired+ && !cached.IsLocalOrPrivate) {- IPAddress[] addresses;- if (IPAddress.TryParse(h, out IPAddress literal))- {- addresses = new[] { literal };- }- else+ return false;+ }++ bool result = ResolveAndCheckLocalOrPrivate(host);+ s_localHostCache[host] = new CachedZoneDecision(result);+ return result;+ }++ private static bool ResolveAndCheckLocalOrPrivate(string host)+ {+ IPAddress[] addresses;+ if (IPAddress.TryParse(host, out IPAddress literal))+ {+ addresses = new[] { literal };+ }+ else+ {+ try {- try- {- addresses = Dns.GetHostAddresses(h);- }- catch- {- // DNS failure: do not relax the policy. Fall through toAI-assisted change 3e8fb1307386c4304c819ac158c9e039e1e38b98a95802f28c3a5496d390b838 · Fix be6196cc85cbaad2ae51eea218739d85ae0bfbcddaf2e8d01edfe066cd83e94a