Skip to content

CWE-295: Improper Certificate Validation - C# / .NET

Overview

Improper certificate validation in C# occurs when HttpClientHandler.ServerCertificateCustomValidationCallback is set to a delegate that returns true unconditionally, or when the legacy ServicePointManager.ServerCertificateValidationCallback is assigned a bypass. These patterns disable TLS certificate chain and hostname verification, so an attacker positioned on the network path can present any certificate and read the traffic the client believes is protected.

This flaw is common in development workarounds (e.g., testing against self-signed certificates) that are never removed before the code reaches production. The word "Dangerous" in HttpClientHandler.DangerousAcceptAnyServerCertificateValidator is a deliberate warning: the property should never appear in production code.

Primary Defence: Remove the custom callback entirely. HttpClient performs correct certificate chain and hostname validation by default against the Windows/system certificate store. For internal CA certificates, install the CA into the OS or application trust store, or use CustomRootTrust on supported .NET versions - do not bypass validation.

Common Vulnerable Patterns

Trust-All Callback

// VULNERABLE - returns true for every certificate regardless of errors
var handler = new HttpClientHandler
{
    ServerCertificateCustomValidationCallback =
        (message, cert, chain, errors) => true
};
var client = new HttpClient(handler);

Why this is vulnerable:

  • The callback ignores errors, chain, and the certificate's subject/issuer. An attacker with network access can present any certificate and the client will accept it, decrypting the TLS session without the application's knowledge.

DangerousAcceptAnyServerCertificateValidator

// VULNERABLE - named "Dangerous" for a reason; never use in production
var handler = new HttpClientHandler
{
    ServerCertificateCustomValidationCallback =
        HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};

Why this is vulnerable:

  • This is identical to the trust-all lambda above. The API was deliberately named with "Dangerous" to make code review easier, but developers sometimes dismiss it.

Legacy ServicePointManager Bypass

// VULNERABLE - .NET Framework pattern; disables validation globally
System.Net.ServicePointManager.ServerCertificateValidationCallback +=
    (sender, certificate, chain, sslPolicyErrors) => true;

// All subsequent HttpWebRequest and WebClient calls skip validation
var client = new WebClient();
var data = client.DownloadString("https://api.example.com/data");

Why this is vulnerable:

  • ServicePointManager.ServerCertificateValidationCallback is a process-wide setting, so on .NET Framework a single line disables validation for every outbound HTTPS call in the application, including calls from libraries you did not write.
  • Its reach is narrower on .NET Core and later than on .NET Framework, and it stayed narrow. HttpClient and SslStream run on SocketsHttpHandler, which does not consult the property; the obsolete HttpWebRequest and WebClient still honour it. Measured on .NET 10.0.12 with the callback installed before any client was created: WebClient.DownloadString() and HttpWebRequest both accept a self-signed listener, while new HttpClient(), new HttpClient(new HttpClientHandler()) and new HttpClient(new SocketsHttpHandler()) all throw AuthenticationException for the same listener. .NET 9 did not map the property onto SocketsHttpHandler.SslOptions, so it has not come back within reach of HttpClient. The finding is real for every HttpWebRequest and WebClient call in the process, but it is not where a modern HttpClient bypass lives - for that, look for the per-handler ServerCertificateCustomValidationCallback.

Secure Patterns

Default HttpClient (No Callback)

using System.Net.Http;

// SECURE - no custom callback - validation uses the Windows/system certificate store
var client = new HttpClient();
var response = await client.GetAsync("https://api.example.com/data");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();

Why this works:

  • The default HttpClientHandler validates the certificate chain against the system trust store and checks the hostname. An invalid certificate causes HttpRequestException with an inner AuthenticationException.

Leaf Certificate Pinning (Narrow Exception)

using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

// SECURE only for a single, fixed endpoint: require normal validation, then pin the leaf certificate
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
    // Pinning is an additional control, not a replacement for chain, expiry, or hostname validation.
    if (errors != SslPolicyErrors.None || cert is null)
        return false;

    // Accept only one known leaf certificate thumbprint for this endpoint
    const string expectedLeafThumbprint =
        "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"; // SHA-256, uppercase, no spaces
    return string.Equals(
        cert.GetCertHashString(System.Security.Cryptography.HashAlgorithmName.SHA256),
        expectedLeafThumbprint,
        StringComparison.OrdinalIgnoreCase);
};

var client = new HttpClient(handler);

Why this works:

  • This keeps the platform's chain, expiry, purpose, and hostname validation enforced, then adds a narrow leaf-certificate pin. Use this only for tightly scoped pinning with rotation procedures. It is not a general internal CA pattern, because a leaf thumbprint changes on normal certificate renewal.

Custom CA via CustomRootTrust (Preferred for Internal PKI on .NET 5+)

using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;

// SECURE - validate chain errors against one explicitly trusted internal root CA
var handler = new HttpClientHandler();
var internalCa = LoadInternalCACert(); // load from a protected file/store

handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
    // Already valid against the system trust store. This ADDS the internal CA to
    // what the platform trusts; it does not narrow trust to it. Delete this line
    // if the client must accept ONLY certificates under the internal root - see
    // the note below before you do.
    if (errors == SslPolicyErrors.None) return true;

    // A custom chain can only fix trust-chain errors, not hostname or missing cert errors
    if ((errors & ~SslPolicyErrors.RemoteCertificateChainErrors) != 0 || cert is null || chain is null)
        return false;

    using var customChain = new X509Chain();
    customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
    customChain.ChainPolicy.CustomTrustStore.Add(internalCa);

    // The intermediates arrive in the handshake and are on `chain`, not on `cert`.
    // Omit this and a two-tier PKI stops at PartialChain: every legitimate
    // connection is refused, while every rejection test still passes.
    foreach (var element in chain.ChainElements)
        customChain.ChainPolicy.ExtraStore.Add(element.Certificate);

    customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
    customChain.ChainPolicy.ApplicationPolicy.Add(
        new Oid("1.3.6.1.5.5.7.3.1")); // Server Authentication
    // Online only if this PKI publishes reachable CRL/OCSP - see below.
    customChain.ChainPolicy.RevocationMode = X509RevocationMode.Online;

    if (customChain.Build(cert)) return true;

    foreach (var status in customChain.ChainStatus)
        Console.Error.WriteLine($"Internal CA chain rejected: {status.Status}");
    return false;
};

Why this works:

  • What this trusts is the union, not the internal CA alone. errors == SslPolicyErrors.None returns before the custom chain is built, so anything the system trust store already accepts is accepted here too; the rest of the callback adds certificates under the internal root on top of that. That is the right default for a client that also calls public endpoints, and it is what the name "custom CA support" usually means - but it is not pinning, and a mis-issuance by any public CA still reaches this client.
  • To trust only the internal root, drop the errors == SslPolicyErrors.None early return so every certificate goes through CustomRootTrust, and keep the guard on the next line so hostname and missing-certificate errors still fail. Do that only for a handler attached to a client dedicated to internal services: a client that also calls a public API will start refusing it, which is the failure mode described two bullets down.
  • Beyond the trust anchor, the callback only forgives RemoteCertificateChainErrors. Hostname mismatch and missing-certificate errors fail either way, and the chain must be valid for server authentication. Verified against a generated two-tier PKI on .NET 10: the legitimate leaf is accepted, a name mismatch is refused, and a certificate with the same subject issued under a different root is refused.
  • ExtraStore is what makes the accept case work. X509Chain.Build is given only the leaf, so it has to find the issuing CA somewhere; the server sent it during the handshake and it is sitting on the chain argument. Without the copy, the same generated PKI returns PartialChain and the callback rejects every connection - which passes each of the three failure tests above and fails only against a real internal endpoint.
  • Check the revocation mode against your own PKI before shipping. X509RevocationMode.Online is the right default where CRL or OCSP endpoints are published and reachable, and it fails closed where they are not: the same two-tier PKI, with no CRL distribution point on the certificates, returns RevocationStatusUnknown and Build returns false for a perfectly valid certificate. If the internal PKI publishes no revocation data, NoCheck is the honest setting and the gap belongs in the risk record - what is not acceptable is discovering the difference in production. The ChainStatus logging above is what tells the two apart.
  • Two smaller points in the same callback: cert is already an X509Certificate2, so Build(cert) needs no wrapping copy, and the chain is null guard matters because the parameter is declared nullable.
  • For .NET Framework or older .NET versions that do not support CustomRootTrust, install the internal CA into the appropriate OS or application trust store instead of accepting unknown roots in the callback.

Testing

  • A request to a real internal endpoint returns 200. Write this one first: a custom validation callback that refuses everything passes every test below, and the two ways to build one - a missing ExtraStore and an unreachable revocation endpoint - both look like hardening in the diff.
  • https://self-signed.badssl.com/, https://expired.badssl.com/ and https://wrong.host.badssl.com/ each throw HttpRequestException. For the wrong-host case, assert the inner AuthenticationException rather than just the failure: a callback that returns false for the right reason and one that returns false because the chain never built are indistinguishable from the caller.
  • A certificate carrying the expected subject name but issued under a different root is refused. This is the case a CustomRootTrust callback exists to catch, and a callback that only inspects cert.Subject will accept it.
  • After an internal CA rotation, the old and new certificates both validate for the overlap period. Renewals are what turn a leaf-thumbprint pin into an outage, so if you pinned, test the rollover before the rollover.
  • Re-run the scanner that reported the finding and confirm it no longer triggers.

Additional Resources