Skip to content

CWE-299: Improper Check for Certificate Revocation - Java

Overview

Java checks revocation in two places, and they have opposite defaults. The PKIX certification path API (CertPathValidator, PKIXParameters) performs revocation checking by default, because it is a required step of the PKIX algorithm. JSSE - the TLS stack behind HttpsURLConnection, SSLSocket, and every HTTP client built on them - does not perform it unless it is turned on.

Neither default gives you what it sounds like. The PKIX API turns revocation checking on but supplies no way to fetch a status: the built-in checker runs in a legacy mode where CRL retrieval from the CRL Distribution Point is gated on the com.sun.security.enableCRLDP system property and OCSP on the ocsp.enable security property, and both are off by default. Hand-rolled PKIX code therefore fails closed on every certificate, valid ones included. Verified on JDK 26 against the live badssl.com chain: new PKIXParameters(cacerts) reports isRevocationEnabled() as true, and CertPathValidator.getInstance("PKIX") then throws CertPathValidatorException: Could not determine revocation status. That, not a responder outage, is what usually produces the setRevocationEnabled(false) line below. The fix is to supply your own PKIXRevocationChecker - see Control the policy explicitly with PKIXRevocationChecker under Secure Patterns.

On the JSSE side, a TLS connection using the default trust manager silently accepts a revoked certificate. A custom X509TrustManager makes it worse: replacing the platform trust manager discards revocation checking along with everything else it does.

Common Vulnerable Patterns

Custom trust manager in place of the platform's

// VULNERABLE - replaces every check the platform performs, revocation included
TrustManager[] trustManagers = new TrustManager[] {
    new X509TrustManager() {
        public void checkClientTrusted(X509Certificate[] chain, String authType) { }
        public void checkServerTrusted(X509Certificate[] chain, String authType) {
            // Chain is not empty and the leaf is not expired, so accept it.
            if (chain == null || chain.length == 0) {
                throw new IllegalArgumentException("empty chain");
            }
            chain[0].checkValidity();
        }
        public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
    }
};

SSLContext context = SSLContext.getInstance("TLS");
context.init(null, trustManagers, null);

Why this is vulnerable: checkValidity() compares the notBefore/notAfter dates against the clock. It says nothing about whether the issuer has since revoked the certificate, and nothing about the rest of the chain. A trust manager written this way accepts a certificate that was revoked the day its private key leaked, and it will keep accepting it until the certificate expires on its own.

Assuming a plain TLS connection checks revocation

// VULNERABLE - default JSSE trust manager does not check revocation status
URL url = new URL("https://internal-service.example.com/api/transfer");
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setRequestMethod("POST");

Why this is vulnerable: The default trust manager validates the chain and the notBefore/notAfter dates, and stops there. Hostname verification is not part of it - X509TrustManager never sees the name being connected to. HttpsURLConnection applies it separately, as java.net.http.HttpClient does; a raw SSLSocket or SSLEngine gets no name check at all until SSLParameters.setEndpointIdentificationAlgorithm("HTTPS") is set before the handshake (see CWE-295 Java). Neither step looks at revocation. Nothing in this code is wrong to look at - which is the problem. For a service whose certificate authorises a payment or an administrative action, a revoked certificate is accepted exactly as a live one is.

Disabling revocation because validation fails on every certificate

// VULNERABLE - the missing piece was the fetch mechanism, and the check was removed instead
PKIXParameters params = new PKIXParameters(trustAnchors);
params.setRevocationEnabled(false);  // "Could not determine revocation status"

Why this is vulnerable: revocationEnabled was already true here, so this line only ever removes a check. The exception it silences is not a responder outage: the built-in checker is in legacy mode with no enabled way to fetch a status, so it fails closed on every certificate - the valid ones as well as the revoked ones. Turning revocation off makes the error go away and leaves no revocation checking at all. Give the checker a way to fetch instead: supply a PKIXRevocationChecker (below), or enable com.sun.security.enableCRLDP and ocsp.enable. SOFT_FAIL is for the different case, where a responder that normally answers is temporarily down.

Secure Patterns

Enable revocation checking for TLS connections

// SECURE - turn on revocation checking for the default JSSE trust manager
// Pass as JVM flags, or set before this process opens any TLS connection.
System.setProperty("com.sun.net.ssl.checkRevocation", "true");
Security.setProperty("ocsp.enable", "true");
System.setProperty("com.sun.security.enableCRLDP", "true");

Why this works: com.sun.net.ssl.checkRevocation is what makes JSSE ask for revocation status at all; without it the other two settings have nothing to act on. ocsp.enable allows the validator to query the OCSP responder named in the certificate's Authority Information Access extension, and com.sun.security.enableCRLDP allows it to fetch the CRL named in the CRL Distribution Point extension, so a chain can be checked by whichever mechanism its issuer publishes. All three are needed together: with checkRevocation alone, JSSE asks for a status it has no enabled way to fetch, and every connection fails with Could not determine revocation status.

The timing is not negotiable. com.sun.net.ssl.checkRevocation is read once and frozen for the whole process - sun.security.validator.PKIXValidator holds it in a static final field initialised the first time the process validates a TLS certificate. Setting it later changes nothing, and building a fresh SSLContext does not re-read it: verified on JDK 26, where one handshake made before the setProperty call left revoked.badssl.com reachable afterwards through a brand-new SSLContext with a freshly initialised PKIX TrustManagerFactory. Pass it as -Dcom.sun.net.ssl.checkRevocation=true, or set it before any code in the process - including a dependency's - opens a TLS connection.

Control the policy explicitly with PKIXRevocationChecker

import java.security.KeyStore;
import java.security.cert.CertPathValidator;
import java.security.cert.PKIXBuilderParameters;
import java.security.cert.PKIXRevocationChecker;
import java.security.cert.X509CertSelector;
import java.util.EnumSet;
import javax.net.ssl.CertPathTrustManagerParameters;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;

// SECURE - explicit revocation policy, applied to the TLS trust manager
public static SSLContext buildContext(KeyStore trustStore) throws Exception {
    PKIXBuilderParameters pkixParams =
            new PKIXBuilderParameters(trustStore, new X509CertSelector());

    CertPathValidator validator = CertPathValidator.getInstance("PKIX");
    PKIXRevocationChecker checker =
            (PKIXRevocationChecker) validator.getRevocationChecker();

    // Hard-fail: an unreachable responder is a failed validation, not a pass.
    checker.setOptions(EnumSet.of(PKIXRevocationChecker.Option.PREFER_CRLS));
    pkixParams.addCertPathChecker(checker);

    TrustManagerFactory tmf = TrustManagerFactory.getInstance("PKIX");
    tmf.init(new CertPathTrustManagerParameters(pkixParams));

    SSLContext context = SSLContext.getInstance("TLS");
    context.init(null, tmf.getTrustManagers(), null);
    return context;
}

Why this works: a PKIXRevocationChecker you supply yourself runs outside the legacy mode described in the Overview, so it fetches by CRL Distribution Point and OCSP on its own - without com.sun.security.enableCRLDP or ocsp.enable. That is why this pattern needs no properties where the previous one needs three. Verified on JDK 26 with no system or security properties set: badssl.com returns 200 and revoked.badssl.com throws CertPathValidatorException with reason=REVOKED. PKIXRevocationChecker is abstract and has no public constructor, so CertPathValidator.getRevocationChecker() is how you obtain one. CertPathTrustManagerParameters is what carries the parameters into the trust manager JSSE actually uses - a PKIXBuilderParameters object built and then discarded has no effect on the connection. The options are the policy:

  • SOFT_FAIL - treat an unreachable responder as a pass. Include it for public internet clients where responder outages are routine; omit it, as above, for mutual TLS and private PKI, where an unavailable responder should stop the connection.
  • PREFER_CRLS - try CRLs before OCSP. Useful for a private CA that publishes CRLs but runs no responder.
  • NO_FALLBACK - do not try the other mechanism when the preferred one fails. Without it, a hard-fail policy quietly becomes a two-mechanism policy.
  • ONLY_END_ENTITY - check the leaf but not the intermediates. This narrows what you are protected against; a revoked intermediate is precisely the case where a CA has lost control of a signing key.

Rely on stapled responses where the server provides them

// SECURE - the client already asks for a stapled response; this is what makes it get used
System.setProperty("com.sun.net.ssl.checkRevocation", "true");

// Only on a JVM that terminates TLS: server-side stapling is the one that is off by default
System.setProperty("jdk.tls.server.enableStatusRequestExtension", "true");

Why this works: with stapling, the server presents a recent, CA-signed status response during the handshake, so the client does not make a separate call to the responder on every connection. This removes the latency and the availability dependency that push teams toward SOFT_FAIL in the first place.

A JSSE client already asks for one. jdk.tls.client.enableStatusRequestExtension defaults to true on JDK 9 and later, so the status_request extension is sent without any configuration; the property exists to turn stapling off, not on. What stapling does not do is decide whether the response is looked at: it only changes where the status comes from, and it is checked only when revocation checking is enabled - so com.sun.net.ssl.checkRevocation is the line that changes behaviour here. The server-side counterpart is the one that defaults to false, which is why it appears above with that qualification.

Stapling only covers the servers that actually staple, so this block is an addition to one of the patterns above rather than a replacement for it. On its own, checkRevocation leaves the client with no way to fetch a status from any server that does not staple, and those connections fail with Could not determine revocation status.

Considerations

  • Hard-fail or soft-fail is the decision this CWE actually turns on. Once revocation checking is enabled, the question is what happens when the answer is unavailable. For mutual TLS between your own services, an administrative API, or a private PKI, fail closed: you control the responder, and an outage should be visible. For calls to third-party endpoints over the public internet, hard-fail makes your availability a function of someone else's OCSP responder, and SOFT_FAIL is the defensible choice. Write the decision down next to the code - the default is not obvious to the next reader.
  • Short-lived certificates are the alternative, not a complement. A certificate valid for days rather than years narrows the revocation window to the point where checking matters less. If the service already rotates certificates automatically, the cost of hard-fail revocation checking may not buy much.
  • Freshness and clock skew both produce answers that are not about now. There is no client-side OCSP response cache in JSSE - the one client-side cache is URICertStore, which holds fetched CRLs and re-checks upstream every 30 seconds - so the staleness that matters is the CRL's own nextUpdate window: a certificate revoked an hour ago is still absent from a CRL issued this morning and valid until tomorrow. Where the issuer runs a responder, OCSP is the fresher mechanism, which is worth weighing against PREFER_CRLS. Separately, PKIXRevocationChecker respects the responder's own validity window, so a wrong system clock can make a fresh response look expired or an expired one look fresh.

Testing

Revocation is one of the cases where a re-scan proves nothing: the scanner sees the property set or the checker constructed, not whether a revoked certificate is actually refused.

  • Present a certificate revoked by your test CA and assert the handshake fails with a CertPathValidatorException whose getReason() is BasicReason.REVOKED. A generic handshake failure is not the same evidence - it can equally mean the test CA is not trusted.
  • Revoke an intermediate rather than the leaf and confirm the connection still fails. This is the case ONLY_END_ENTITY silently exempts.
  • Take the OCSP responder offline and assert the configured policy: connection refused under hard-fail, connection allowed under SOFT_FAIL. Run this test in both configurations if you support both environments.
  • Confirm a valid, non-revoked certificate still connects after enabling revocation checking. Turning it on for a private PKI whose CRL Distribution Point is unreachable from the application's network breaks every connection, and that failure looks identical to a certificate problem.

Additional Resources