.NETAPI SecurityTLSDocker

De CA-sleutel was het getal 4: welke certificaten vertrouwt jouw .NET-backend eigenlijk?

Acht CA-sleutels van eMudhra bleken te kraken in milliseconden. Browsers waren snel beschermd, servers niet. Wat dat betekent voor HttpClient in .NET, met code voor een SPKI-denylist, eigen root-trust en revocatie.

JB
Jean-Pierre Broeders
Freelance .NET Developer · 22 oktober 2026 · 11 min lezen
De CA-sleutel was het getal 4: welke certificaten vertrouwt jouw .NET-backend eigenlijk?

Eén van de private keys van een publiek vertrouwde certificate authority was het getal 4. Een andere was 56. Scott Helme beschrijft in Eight Bits of Trust hoe acht intermediate CA's van het Indiase eMudhra sleutels op de P-384-curve hadden die uit 47 nulbytes en één willekeurige byte bestonden. Wie zo'n sleutel wil vinden, hoeft maximaal 256 mogelijkheden te proberen. Dat kost op een laptop minder dan een seconde.

Het stuk haalde op Hacker News bijna 900 punten en ruim 400 reacties, in een week waarin de PHP-advisories de voorpagina al vulden. Inmiddels heeft mitmproxy een --emudhra-optie waarmee je certificaten tekent met een van die gelekte sleutels. Een man-in-the-middle met een geldig ogend certificaat voor elk domein is daarmee één vlag op de command line.

Browsers waren binnen een dag beschermd. Jouw backend waarschijnlijk niet. Daar gaat dit stuk over.

Wat er precies mis was

Volgens Helme gaat het om acht intermediates die in april 2025 zijn aangemaakt: zes voor TLS (twee elk voor DV, OV en EV) en twee voor code signing. Alle acht zijn in mei 2025 ook cross-signed door Sectigo's AAA Certificate Services, een root die in vrijwel elke trust store zit. eMudhra wijt het aan een bug in een script tijdens de key ceremony plus een menselijke fout bij het kiezen van de sleutels. In de HN-discussie gelooft niet iedereen dat verhaal, maar voor ons maakt het weinig uit. De sleutels zijn publiek, volgens een reageerder staan ze al als .key-bestanden op GitHub, en ze waren zeventien maanden lang te herleiden uit de publieke certificaten.

Op 17 oktober kregen Apple, Google, Microsoft, Mozilla en eMudhra de melding. Chrome blokkeerde dezelfde avond via CRLSet, Firefox binnen een paar uur via OneCRL. Mozilla heeft daarna de eMudhra-root uit NSS gehaald.

Dan het deel dat ons raakt. Helme zette een vervalst certificaat voor api.stripe.com op en probeerde het met standaardinstellingen. curl meldde SSL certificate verify ok. Python rondde de handshake af. Node gaf authorized: true. Over Windows schrijft hij dat CryptoAPI de keten accepteerde zolang revocatiecontrole uit stond.

.NET stond niet in zijn lijstje. Het hoeft er ook niet in te staan om de conclusie te trekken.

Waarom je .NET-code hier in dezelfde hoek zit

Browsers hadden twee verdedigingslinies die server-side clients missen. De eerste is Certificate Transparency. Chrome accepteert geen TLS-certificaat dat niet in de CT-logs staat, en geen enkel certificaat onder deze CA's is daar ooit verschenen. Een aanvaller die nu een certificaat tekent, logt dat natuurlijk niet. De tweede is een centraal gepusht blokkadelijstje (CRLSet, OneCRL) dat binnen uren bij elke gebruiker staat.

HttpClient heeft geen van beide. .NET controleert geen CT. Revocatiecontrole staat standaard uit: HttpClientHandler.CheckCertificateRevocationList is false en SocketsHttpHandler.SslOptions.CertificateRevocationCheckMode is NoCheck. En de lijst met vertrouwde roots komt van het besturingssysteem. Op Windows is dat de machine-store, op Linux de OpenSSL-bundel van het ca-certificates-pakket.

Draai je in een container, dan is het nog iets lastiger. Een container erft de trust store van de host niet. Hij gebruikt de bundel die in de base image zat op het moment dat je de image bouwde. Een HN-reageerder wees daar terecht op. Je kunt je hosts patchen tot je een ons weegt, maar een image van drie maanden geleden vertrouwt wat drie maanden geleden vertrouwd werd.

Er zit nog een addertje onder het gras. De intermediates zijn cross-signed door Sectigo. Een aanvaller kan dus een keten aanbieden die eindigt bij AAA Certificate Services in plaats van bij de eMudhra-root. Een eMudhra-root uit je bundel halen dekt dat pad niet af. Daarvoor heb je revocatie nodig, of een expliciete blokkade op de sleutels zelf.

Stap 1: kijk wat er in je trust store zit

Begin met inventariseren. Dit draait op Windows en Linux, ook in een container:

using System.Security.Cryptography.X509Certificates;

using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);

Console.WriteLine($"Vertrouwde roots: {store.Certificates.Count}");

foreach (X509Certificate2 cert in store.Certificates)
{
    if (cert.Subject.Contains("eMudhra", StringComparison.OrdinalIgnoreCase))
    {
        Console.WriteLine($"{cert.Subject} | geldig tot {cert.NotAfter:yyyy-MM-dd}");
    }
}

Op Linux leest LocalMachine\Root de systeembundel die OpenSSL ook gebruikt. Op een gemiddelde Linux-image zijn dat er ruim honderd. Allemaal partijen die voor elke hostname een certificaat mogen tekenen waar jouw code blind op vertrouwt. De HN-reactie "achteraf gezien waren honderd vertrouwde CA's misschien wat veel" is grappig, maar wel waar.

Voor containers is de fix saai: opnieuw bouwen op een base image met een bijgewerkte bundel. Bij Debian- en Ubuntu-gebaseerde images kun je dat afdwingen zonder op een nieuwe tag te wachten:

FROM mcr.microsoft.com/dotnet/aspnet:10.0
RUN apt-get update \
 && apt-get install -y --only-upgrade ca-certificates \
 && rm -rf /var/lib/apt/lists/*

Chiseled en distroless images hebben geen apt. Daar wacht je op een nieuwe image van Microsoft en bouw je opnieuw. Pin je base images op digest, en dat zou je moeten doen, dan moet iemand die digest ook echt ophogen. Zorg dat Dependabot of Renovate dat voor je doet, anders blijft de oude bundel jarenlang staan.

Stap 2: blokkeer de sleutels, niet de namen

Een update van de bundel helpt tegen de eMudhra-root, maar niet tegen het cross-signed pad. Wat wel helpt: weigeren op basis van de publieke sleutel. Een cross-sign is een nieuw certificaat met dezelfde sleutel erin. Blokkeer je de hash van de SubjectPublicKeyInfo (SPKI), dan maakt het niet uit welke root erboven hangt.

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

public sealed class SpkiDenylist(IEnumerable<string> sha256Base64, ILogger<SpkiDenylist> logger)
{
    private readonly HashSet<string> _blocked = new(sha256Base64, StringComparer.Ordinal);

    public bool Validate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
    {
        // Eerst de normale validatie. Een callback die hier true teruggeeft
        // bij fouten zet TLS-controle in feite uit.
        if (errors != SslPolicyErrors.None || chain is null)
        {
            return false;
        }

        foreach (X509ChainElement element in chain.ChainElements)
        {
            byte[] spki = element.Certificate.PublicKey.ExportSubjectPublicKeyInfo();
            string hash = Convert.ToBase64String(SHA256.HashData(spki));

            if (_blocked.Contains(hash))
            {
                logger.LogWarning("TLS-keten geweigerd: geblokkeerde sleutel in {Subject}", element.Certificate.Subject);
                return false;
            }
        }

        return true;
    }
}

Sinds .NET 8 hang je dat met ConfigureHttpClientDefaults aan elke client die via IHttpClientFactory loopt:

var blocked = builder.Configuration.GetSection("Tls:BlockedSpkiSha256").Get<string[]>() ?? [];

builder.Services.AddSingleton(sp =>
    new SpkiDenylist(blocked, sp.GetRequiredService<ILogger<SpkiDenylist>>()));

builder.Services.ConfigureHttpClientDefaults(http =>
    http.ConfigurePrimaryHttpMessageHandler(sp => new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        SslOptions =
        {
            RemoteCertificateValidationCallback = sp.GetRequiredService<SpkiDenylist>().Validate
        }
    }));

De hashes zelf zet ik hier bewust niet neer. Haal de acht intermediates uit de Mozilla-bug of van crt.sh en reken ze zelf uit:

openssl x509 -in em-ov-tls-ca.pem -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | base64

Het zit in configuratie, dus de volgende keer dat een CA onderuit gaat voeg je een regel toe en herstart je. Geen nieuwe build nodig.

Twee kanttekeningen. Clients die je zelf met new HttpClient() aanmaakt, vallen buiten ConfigureHttpClientDefaults. Zoek die eerst op met een grep op new HttpClient(. En SDK's van derden die hun eigen handler meebrengen, moet je per stuk bekijken. De Azure SDK laat je bijvoorbeeld een eigen transport instellen, andere libraries niet.

Stap 3: smallere trust voor de verbindingen die ertoe doen

De denylist reageert op incidenten. Voor een handvol uitgaande verbindingen wil je liever niet afhangen van 140 roots. Denk aan je payment provider, je identity provider en je eigen backend-naar-backend-verkeer. Sinds .NET 7 kun je per client een eigen X509ChainPolicy meegeven met alleen de roots die jij kiest:

static X509ChainPolicy CreatePaymentsPolicy()
{
    var roots = new X509Certificate2Collection();
    roots.ImportFromPemFile("certs/payments-roots.pem");

    var policy = new X509ChainPolicy
    {
        TrustMode = X509ChainTrustMode.CustomRootTrust,
        RevocationMode = X509RevocationMode.Online,
        RevocationFlag = X509RevocationFlag.ExcludeRoot,
    };
    policy.CustomTrustStore.AddRange(roots);
    return policy;
}

builder.Services.AddHttpClient("payments", c => c.BaseAddress = new Uri("https://api.psp.example/"))
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        SslOptions = new SslClientAuthenticationOptions
        {
            CertificateChainPolicy = CreatePaymentsPolicy()
        }
    });

Ik maak de policy per handler aan, omdat X509ChainPolicy een muteerbaar object is en de factory handlers periodiek vervangt. Zet de revocatiemodus in de policy zelf. Dan is er geen discussie over welke instelling wint als je ook CertificateRevocationCheckMode op de options zet.

Het risico ken je vast: als de provider van CA wisselt, ligt je integratie plat. Leaf-pinning is daarom een slecht idee, want leaf-certificaten roteren elke paar maanden. Op root-niveau is het beter te doen. Neem minimaal twee roots op, de huidige en een die de provider zelf als backup noemt. Ondersteunt een provider dat, vraag dan om hun lijst. Voor je eigen interne verkeer is het nog makkelijker. Draai je op Let's Encrypt, dan vertrouw je ISRG Root X1 en X2 en verder niets.

Stap 4: revocatie, met open ogen

Revocatie aanzetten voor alle clients klinkt als de voor de hand liggende fix. Het is ook de fix die je om drie uur 's nachts wakker belt. Met X509RevocationMode.Online haalt .NET CRL's of OCSP-antwoorden op tijdens het bouwen van de keten. Op Linux worden CRL's lokaal gecachet, maar een eerste verbinding naar een nieuwe CA betekent een extra netwerkrondje. En als die CRL-server niet bereikbaar is, krijg je RevocationStatusUnknown en faalt je request. In een omgeving met strikte egress-regels gebeurt dat vaker dan je denkt.

Mijn vuistregel: revocatie aan voor de paar clients waar een MITM echt pijn doet (geld, identiteit, secrets), met de CRL- en OCSP-hosts van die CA's expliciet in je egress-allowlist. Voor de rest de denylist en een bijgewerkte bundel. En of revocatie je in dit geval helpt, hangt er ook van af of de uitgevende partijen alle varianten van de certificaten intrekken, inclusief de cross-signs. Daar heb jij geen invloed op. Op je denylist wel.

Wat je in je eigen monitoring kunt doen

CT-handhaving zit niet in .NET, maar CT-monitoring kun je wel zelf doen voor de domeinen die je beheert. crt.sh heeft een JSON-uitvoer (https://crt.sh/?q=jouwdomein.nl&output=json). Een dagelijkse job die nieuwe certificaten voor je domeinen ophaalt en alarm slaat bij een onverwachte issuer, vangt misbruik van jouw naam. Het beschermt je niet tegen een vervalst api.stripe.com in je eigen uitgaande verkeer, dus het vervangt de stappen hierboven niet. Wel weet je zo binnen een dag dat iemand op jouw naam certificaten laat uitgeven.

Waar het eigenlijk om gaat

Iedereen lacht om het getal 4, en terecht. Maar de les voor backend-developers gaat over iets anders: de client-instellingen die we nooit aanraken. In de standaardconfiguratie vertrouwt een .NET-service elke root uit de bundel, controleert hij geen revocatie en kijkt hij niet naar CT. En draait hij in een container, dan vertrouwt hij de bundel van de dag dat de image gebouwd werd. Volgens Helme blijven apparaten met bevroren firmware deze certificaten vertrouwen tot ze in juli 2039 verlopen. Een container-image die niemand opnieuw bouwt, is in dat opzicht gewoon een router.

Bouw je images deze week opnieuw. En zet de denylist erin, ook als hij nu leeg is. Bij het volgende incident hoef je dan alleen een configregel toe te voegen.

Veelgestelde vragen over CA-vertrouwen en TLS in .NET

Controleert HttpClient in .NET standaard of een certificaat is ingetrokken? Nee. HttpClientHandler.CheckCertificateRevocationList staat standaard op false en SocketsHttpHandler gebruikt standaard X509RevocationMode.NoCheck. Je moet revocatie zelf aanzetten, per handler of via een eigen X509ChainPolicy, en dan rekening houden met extra netwerkverkeer naar CRL- en OCSP-servers.

Gebruikt een .NET-container de trust store van de host? Nee. Een container gebruikt de CA-bundel die in de image zit, meestal het ca-certificates-pakket van de base image. Een update op de host verandert daar niets aan, dus na een CA-incident moet je images opnieuw bouwen op een bijgewerkte base image of het pakket in je Dockerfile upgraden.

Is certificate pinning in .NET nog een goed idee? Op leaf-niveau zelden, want leaf-certificaten roteren vaak en een gemiste rotatie legt je integratie plat. Op root-niveau met X509ChainTrustMode.CustomRootTrust en minimaal twee roots is het goed werkbaar voor een paar kritieke verbindingen, zoals je payment provider of je eigen interne API's.

Waarom blokkeren op SPKI-hash en niet op de naam van de CA? Een cross-signed certificaat heeft een andere uitgever en soms een andere naam, maar dezelfde publieke sleutel. Een SPKI-hash vangt elke variant van het certificaat met die sleutel, ongeacht welke root erboven hangt, terwijl een blokkade op naam makkelijk te omzeilen is.

Verder lezen


Conclusie: Browsers waren binnen een dag beschermd tegen de gekraakte eMudhra-sleutels, server-side .NET-code alleen als iemand er actief iets aan deed. Bouw je images opnieuw, blokkeer gecompromitteerde sleutels op SPKI-hash via configuratie, en geef je meest gevoelige uitgaande verbindingen een eigen, kleine set vertrouwde roots met revocatie aan.

Bronnen: Eight Bits of Trust (Scott Helme) · Hacker News-discussie · mitmproxy: --emudhra-optie.

Hulp nodig bij een vergelijkbaar vraagstuk?
Neem contact op