curl · Report #4125601
OpenSSL `--cert-status` accepts indefinitely stale GOOD OCSP staples
How it was found
Summary
When curl uses its OpenSSL backend with CURLOPT_SSL_VERIFYSTATUS (the
--cert-status tool option), a correctly signed OCSP response can be accepted
forever if its optional nextUpdate field is absent. curl passes -1 as
OpenSSL's maximum permitted age for thisUpdate, explicitly disabling the
only remaining freshness bound:
if(!OCSP_check_validity(thisupd, nextupd, 300L, -1L)) {
An attacker who has a revoked but otherwise unexpired server certificate and
its private key can replay an older, authentic GOOD staple that omitted
nextUpdate. curl accepts the TLS connection even though the application
explicitly requested revocation checking. This is a remote certificate
revocation bypass, not a malformed-input or memory-safety issue.
The attached end-to-end reproducer creates a CA and server certificate whose
validity started 60 days ago, signs four matching OCSP responses, starts a real
OpenSSL TLS server, and invokes the real curl tool. The attack response has a
30-day-old thisUpdate, no nextUpdate, a valid signature, and a matching
certificate ID.
Affected configuration
- curl or libcurl built with the OpenSSL backend and OCSP support
CURLOPT_SSL_VERIFYSTATUS=1, or the curl tool's--cert-status- Any TLS-based protocol using this backend; the reproducer uses HTTPS
The vulnerable code was added by commit
d1cf5d570663dac157740cb5e49d24614f185da7 and first released in curl
7.41.0. It remains present in the official curl 8.23.0 release and in current
master at the time of testing.
Tested directly:
- a fresh download of the official curl 8.23.0
.tar.xz, whose detached GPG signature validates against the release fingerprint published by curl; an ASan+UBSan binary built directly from this archive reproduces the issue and identifies itself as releasecurl 8.23.0, not a development build - official
curl-8_23_0tag, commit3163ec065643ba20709f190111f42c3e1fe95273 - official curl 8.23.0 archive, SHA-256
8dadd7d2536a314f982ad05a651b713f426bf7d02cc2c76cffb9796f2218e6c5 - the archive and tag copies of
lib/vtls/openssl.care byte-identical, SHA-256973be1862430afef1bf15bd92e80eb8733d9fb62b9b32ed1a3063471e9c51331 - OpenSSL 3.0.13 on Linux
- a separate curl 8.23.0 build using GnuTLS 3.8.3 rejected the attack control
OpenSSL-compatible forks share this source path, but only OpenSSL 3.0.13 was tested and is claimed as directly demonstrated.
Reproduction
Requirements: a C compiler, OpenSSL development headers and command-line tool, Python 3 (only to reserve loopback ports), and the curl binary to test.
chmod +x reproduce.sh
CURL_BIN=/path/to/curl ./reproduce.sh
Observed with an ASan+UBSan build from the exact curl-8_23_0 tag:
curl 8.23.0-DEV (Linux) libcurl/8.23.0-DEV OpenSSL/3.0.13
fresh curl_exit=0 expected=0
expired curl_exit=91 expected=reject
revoked curl_exit=91 expected=reject
stale-no-next curl_exit=0 expected=0
VULNERABLE: a 30-day-old GOOD staple without nextUpdate was accepted.
The repository tag identifies itself as 8.23.0-DEV in curlver.h; the
commit is the official annotated 8.23.0 tag, and the relevant file is
byte-identical to the released archive as recorded above.
The same four cases also reproduce with the system curl 8.5.0/OpenSSL 3.0.13. No ASan, UBSan, or LeakSanitizer diagnostic was emitted.
The controls establish that curl reaches and enforces the normal verifier:
| Case | Signed status | thisUpdate | nextUpdate | Result |
|---|---|---|---|---|
| Fresh control | GOOD | -1 hour | +24 hours | accepted (0) |
| Expiry control | GOOD | -30 days | -29 days | rejected (91) |
| Revocation control | REVOKED | -1 hour | +24 hours | rejected (91) |
| Attack | GOOD | -30 days | absent | accepted (0) |
The response signature and certificate ID are verified in every constructed
case. The attack differs from the expiry control only in omission of
nextUpdate. fixture-times.log also confirms that both the expiry control
and attack were signed 30 days ago (producedAt and thisUpdate), inside the
CA and leaf validity interval; this is a replay fixture, not a freshly signed
response carrying an artificial old status time.
Root cause
OCSP_resp_find_status() returns thisupd and optional nextupd. curl then
calls:
OCSP_check_validity(thisupd, nextupd, 300L, -1L)
OpenSSL applies the nextUpdate upper bound only when that field exists. Its
maxsec argument is the maximum age allowed for thisUpdate; a negative
value disables that check. Therefore:
- future
thisUpdatevalues are rejected; - expired present
nextUpdatevalues are rejected; - an absent
nextUpdateplus any pastthisUpdateis accepted, regardless of age.
The curl source comment directly documents that -1 means there is no limit.
OpenSSL's own OCSP_resp_find_status documentation warns about this exact
combination: because some responders omit nextUpdate, an ancient response
would otherwise be considered valid, and callers should use maxsec to limit
its permitted age. curl explicitly disables that safeguard.
Why this is a security issue
RFC 6960 section 3.2 requires an OCSP client, before accepting a signed
response, to confirm that thisUpdate is sufficiently recent. Section
4.2.2.1 says omission of nextUpdate means newer revocation information is
available all the time; it does not mean the response never expires.
The current lightweight OCSP profile, RFC 9919 section 5 (which obsoletes RFC
5019), is stricter: clients must reject a response when nextUpdate is
absent. Its stated reason is specifically to prevent an out-of-date GOOD
response from masking a newer REVOKED response.
OCSP stapling does not use a request nonce, so time is the replay defense.
With both the nextUpdate bound absent and curl's thisUpdate maximum age
disabled, there is no replay lifetime other than the certificate's own
notAfter date. Revocation is intended to protect precisely the situation
where a certificate/private-key pair is no longer trustworthy before that
date.
Suggested fix
The strict, one-line fail-closed fix is included as proposed-fix.patch:
if(!nextupd || !OCSP_check_validity(thisupd, nextupd, 300L, -1L)) {
With that patch, the exact reproducer changes only the attack case:
fresh curl_exit=0 expected=0
expired curl_exit=91 expected=reject
revoked curl_exit=91 expected=reject
stale-no-next curl_exit=91 expected=reject
FAIL-CLOSED: the stale staple without nextUpdate was rejected.
The run is selected with:
STALE_EXPECTED=reject CURL_BIN=/path/to/patched/curl ./reproduce.sh
If curl needs compatibility with general RFC 6960 responders that omit
nextUpdate, an alternative is to retain such responses but pass a finite,
documented maximum age as maxsec. Leaving both freshness bounds disabled is
the vulnerable condition. Strict rejection is preferable for the explicitly
fail-closed --cert-status option and matches RFC 9919.
Duplicate analysis
git log --all -S'OCSP_check_validity'finds only the 2014 introduction.git log --all -S'300L, -1L' -- lib/vtls/openssl.clikewise finds no fix.- Public curl issue/PR searches for
nextUpdate,status age,stale OCSP, andOCSP freshnessfound no curl report for this defect as of 2026-10-18. - Public PR #23311 concerns GnuTLS signature and certificate-ID verification,
not this OpenSSL freshness defect. Its checked fixtures all have a present
next_update; its review comment points to the OpenSSL validity call without identifying the unboundedmaxsec=-1plus missing-nextUpdatecondition. The PR was closed without merge and neither it nor its proposed replacement changeslib/vtls/openssl.c. - CVE-2020-8286 fixed certificate-ID matching in the same function, not
response freshness; the
maxsec=-1call remained unchanged. - The Netty advisory concerns separate code and confirms the bug class; it is not a curl report.
Private reports are not searchable, so only the curl security team can make a definitive duplicate determination.
References
- https://www.rfc-editor.org/rfc/rfc6960.html#section-3.2
- https://www.rfc-editor.org/rfc/rfc6960.html#section-4.2.2.1
- https://www.rfc-editor.org/rfc/rfc9919.html#section-5
- https://docs.openssl.org/3.0/man3/OCSP_resp_find_status/
- https://github.com/curl/curl/pull/23311
- https://curl.se/docs/CVE-2020-8286.html
- https://curl.se/docs/CVE-2024-8096.html
- https://curl.se/docs/CVE-2026-7009.html
- https://github.com/netty/netty/security/advisories/GHSA-jj3c-mwvr-9g52
Impact
An attacker with a compromised, revoked, but unexpired server certificate and
its private key can impersonate the server to applications that explicitly
enable curl's OCSP-stapling verification. If the attacker retained a genuine
pre-revocation GOOD response without nextUpdate, curl accepts it
indefinitely and proceeds with the protected transfer. This can expose or
modify application data according to the protocol and request.
The prerequisite is meaningful but inherent to certificate-revocation threat models: without the private key, the attacker cannot complete the TLS handshake; without revocation checking, the compromised credential would already work. The flaw defeats the control the caller enabled to contain that compromise.
Suggested classification: CWE-299, Improper Check for Certificate Revocation, Moderate/Medium severity.
The closest curl precedent is CVE-2020-8286, also introduced in the OpenSSL
--cert-status verifier and also allowing a breached TLS server to mask a
revoked certificate; curl rated it Medium. CVE-2024-8096 and CVE-2026-7009
are other curl OCSP bypasses rated Medium. Separately, Netty assigned
CVE-2026-93493/Moderate to the same unbounded-age condition for responses
without nextUpdate (with a different downstream failure mode).