Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix TLS certificate validation in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Never return true from a certificate validation callback. Leave validation to SslStream and change only the trust anchor: set SocketsHttpHandler.SslOptions.CertificateChainPolicy to CustomRootTrust with the partner's CA in CustomTrustStore, and RevocationMode NoCheck if the private CA has no CRL. SslStream still checks the chain, the dates and the host name, with no callback.

Stack
  • Language C#
  • Runtime .NET 10.0.12 (SDK 10.0.112)
  • Framework ASP.NET Core 10 minimal API with IHttpClientFactory
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks4 cards
Download the project ZIP, 29 KB
On this page

Steps

  1. Find every place that loosens TLS: ServerCertificateCustomValidationCallback on HttpClientHandler (including DangerousAcceptAnyServerCertificateValidator), RemoteCertificateValidationCallback on SslOptions or SslStream, and ServicePointManager.ServerCertificateValidationCallback in older code.
  2. For a public service, delete the callback: SslStream validates against the machine's trust store and the host name by default.
  3. For a private service, configure that named or typed client's primary handler with UseSocketsHttpHandler and set SslOptions.CertificateChainPolicy: TrustMode = X509ChainTrustMode.CustomRootTrust and the partner's CA in CustomTrustStore (CustomTrustStore.ImportFromPemFile reads a whole bundle).
  4. Set RevocationMode explicitly. A new X509ChainPolicy defaults to Online, so a private CA without a CRL or OCSP responder fails with RevocationStatusUnknown; NoCheck matches what SslStream does when no policy is set. Publish a CRL and keep Online if your PKI has one.
  5. Do not add a RemoteCertificateValidationCallback next to the policy. If one is unavoidable, return true only when errors == SslPolicyErrors.None plus extra conditions; never clear an error such as RemoteCertificateNameMismatch.
  6. If the certificate does not name the host you connect to, add the name to its subjectAltName rather than ignoring RemoteCertificateNameMismatch.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerablesrc/Rates/impl/VulnerablePartnerTls.csC#
public static void Configure(SocketsHttpHandler handler, string partnerCaFile)
{
    // Staging failed with "The remote certificate is invalid according to the validation
    // procedure", so the check was replaced by a callback that always says yes. This is
    // the bug: every certificate is accepted, for any host name. On HttpClientHandler the
    // same hole is ServerCertificateCustomValidationCallback returning true, or
    // HttpClientHandler.DangerousAcceptAnyServerCertificateValidator.
    handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => true;
}

With IMPL=vulnerable, 4 of 5 tests fail (4 exploit tests).

The fix: trust only the partner's CA

Fixedsrc/Rates/impl/FixedPartnerTls.csC#
public static void Configure(SocketsHttpHandler handler, string partnerCaFile)
{
    // CustomRootTrust: chains must end at a CA in CustomTrustStore, not the machine's
    // store. SslStream still checks the dates and the host name, and there is no
    // validation callback to get wrong.
    var policy = new X509ChainPolicy
    {
        TrustMode = X509ChainTrustMode.CustomRootTrust,
        // The private CA publishes no CRL or OCSP responder. NoCheck is also what
        // SslStream uses when no CertificateChainPolicy is set.
        RevocationMode = X509RevocationMode.NoCheck,
    };
    policy.CustomTrustStore.ImportFromPemFile(partnerCaFile); // every CA in the bundle
    handler.SslOptions.CertificateChainPolicy = policy;
}

With IMPL=fixed, 5 of 5 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Rates/impl/WrongPartnerTls.csC#
public static void Configure(SocketsHttpHandler handler, string partnerCaFile)
{
    // The partner's CA is not in the machine's trust store, so every call failed with
    // RemoteCertificateChainErrors. Accept the server when its certificate is issued to
    // the partner's host name.
    handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, errors) =>
        errors == SslPolicyErrors.None || certificate?.Subject == "CN=api.partner.test";
}

With IMPL=wrong, 3 of 5 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Rates.Tests/ExploitTests.csC#
/// <summary>Exploit: each server below answers at api.partner.test but must not be trusted.
/// The client has to refuse the TLS handshake before it sends the request carrying the
/// bearer token.</summary>
public sealed class ExploitTests(RatesFixture fixture) : RatesTests(fixture)
{
    [Fact]
    public Task SelfSignedCertificateForThePartnerHostIsRefused() => AssertRefused("selfSigned");

    [Fact]
    public Task CertificateFromTheTrustedCaForAnotherHostIsRefused() => AssertRefused("otherHost");

    [Fact]
    public Task CertificateFromAnUnknownCaNamedLikeTheTrustedCaIsRefused() => AssertRefused("rogueCa");

    [Fact]
    public Task ExpiredCertificateFromTheTrustedCaIsRefused() => AssertRefused("expired");
}

Pitfalls

  • A callback that returns true accepts every certificate: self-signed, expired, issued by anyone, for any host. HttpClientHandler.DangerousAcceptAnyServerCertificateValidator is the same thing with a warning in its name.
  • Accepting a certificate because certificate.Subject (or Issuer) equals an expected name is not pinning. The impostor certificates here carry exactly the expected subject, and the wrong implementation accepted the self-signed, look-alike-CA and expired ones.
  • Returning true when the only error is RemoteCertificateNameMismatch keeps the chain check but accepts any certificate a trusted CA issued for any host; with the machine's public roots, that is any certificate any public CA has issued.
  • CA5359 is off by default and does not recognise HttpClientHandler.ServerCertificateCustomValidationCallback, so an analyzer-clean build can still ship the bug (checked with SDK 10.0.107).
  • With CertificateChainPolicy set, its RevocationMode supersedes SslClientAuthenticationOptions.CertificateRevocationCheckMode, and new X509ChainPolicy() starts at Online. In this card, removing RevocationMode = NoCheck made the real partner fail with RevocationStatusUnknown.
  • Adding the private CA to the machine or user root store makes every process on the host trust it; CustomRootTrust scopes it to one client.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs tls-certificate-validation/csharp-aspnetcore does this for you).
  2. The exploit tests make the loopback partner server present a self-signed certificate for api.partner.test, a trusted-CA certificate for billing.partner.test, a certificate from an unknown CA named like the trusted one and an expired certificate, and check that GET /rates returns 502 and no request reached the server. The test seam is a ConnectCallback that sends *.partner.test to 127.0.0.1; SslStream still validates api.partner.test.
  3. Against your own service, point the client at a server with a self-signed certificate for the real name and expect RemoteCertificateChainErrors (UntrustedRoot or PartialChain), then at a valid certificate for another name and expect RemoteCertificateNameMismatch.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix TLS certificate validation in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepn/aNo rule exists for any of those names, and neither pack produced a finding on src/. Semgrep CE does not cover certificate validation callbacks in C#.Checked: Semgrep 1.180.0 (CE); searched semgrep-rules at 9bbf021 for ServerCertificateCustomValidationCallback, RemoteCertificateValidationCallback, ServerCertificateValidationCallback and DangerousAcceptAnyServerCertificateValidator; ran p/csharp and p/security-audit on src/
CodeQLn/aNo C# query for certificate validation: there is no CWE-295 or CWE-297 folder in either place, unlike JavaScript, Python and Java.Checked: github/codeql main at b5b8165; folder listing of Security Features and experimental/Security Features
.NET code analysisCA5359Do not disable certificate validationOff by default in .NET 10. With AnalysisModeSecurity=All: 1 warning on VulnerablePartnerTls.cs (16,66), none on WrongPartnerTls.cs or FixedPartnerTls.cs. The probe showed the gap: CA5359 flags a RemoteCertificateValidationCallback lambda that returns true (SslOptions, ServicePointManager) but not HttpClientHandler.ServerCertificateCustomValidationCallback = (...) => true, nor HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, the forms most code uses.Checked: .NET SDK 10.0.107; dotnet build src/Rates/Rates.csproj --no-incremental -p:AnalysisModeSecurity=All, plus a probe project

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC#
Runtime.NET 10.0.12 (SDK 10.0.112)
FrameworkASP.NET Core 10 minimal API with IHttpClientFactory
System.Net.Http.SocketsHttpHandler, System.Net.Security.SslStream, X509ChainPolicy (BCL).NET 10.0.12
Microsoft.Extensions.Http (IHttpClientFactory, UseSocketsHttpHandler; shared framework).NET 10.0.12
CertificateRequest (test PKI only, BCL).NET 10.0.12
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.1 (Microsoft Testing Platform v2)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the tls-certificate-validation/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Rates.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Rates.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Rates.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong dotnet test --project tests/Rates.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs tls-certificate-validation/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 29 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • dotnetSdk 10.0.112
  • dotnetRuntime 10.0.12
  • aspnetMvcTesting 10.0.12
  • xunitV3 4.0.1
Matrix
fixed: 5 of 5 tests pass; vulnerable: 4 of 5 tests fail (4 exploit tests); wrong: 3 of 5 tests fail (3 exploit tests)
Test results
fixed: 5/5 pass; vulnerable: 4/5 fail (4 exploit); wrong: 3/5 fail (3 exploit)
Dependency freshness
Microsoft.AspNetCore.Mvc.Testing 10.0.12 dates from 2026-09-08. xunit.v3 4.0.1 (2026-09-12) is used instead of 4.0.2 (2026-10-09), a bug-fix release under 14 days old. dotnet list package --vulnerable --include-transitive found no vulnerable packages. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
Notes
One app and one loopback partner server serve every test (an xunit collection fixture), and the server switches certificates per test; a host per test took 7 to 27 s on this loaded machine. On macOS the server certificate is round-tripped through PKCS #12 so SslStream can use its key. The wrong implementation refuses the billing.partner.test certificate only because its CN differs.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. SslClientAuthenticationOptions.CertificateChainPolicy
  2. X509ChainPolicy.CustomTrustStore
  3. X509ChainPolicy.RevocationMode (default Online)
  4. SslClientAuthenticationOptions.CertificateRevocationCheckMode (default NoCheck)
  5. HttpClientBuilderExtensions.UseSocketsHttpHandler
  6. HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
  7. CA5359: Do not disable certificate validation
  8. CWE-295: Improper Certificate Validation
  9. CWE-296: Improper Following of a Certificate's Chain of Trust

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.