HexBrain

curl · Report #4125601

OpenSSL `--cert-status` accepts indefinitely stale GOOD OCSP staples

N/Astatus
mediumseverity
2upvotes
26 Oct 2026disclosed

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 release curl 8.23.0, not a development build
  • official curl-8_23_0 tag, commit 3163ec065643ba20709f190111f42c3e1fe95273
  • official curl 8.23.0 archive, SHA-256 8dadd7d2536a314f982ad05a651b713f426bf7d02cc2c76cffb9796f2218e6c5
  • the archive and tag copies of lib/vtls/openssl.c are byte-identical, SHA-256 973be1862430afef1bf15bd92e80eb8733d9fb62b9b32ed1a3063471e9c51331
  • 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:

CaseSigned statusthisUpdatenextUpdateResult
Fresh controlGOOD-1 hour+24 hoursaccepted (0)
Expiry controlGOOD-30 days-29 daysrejected (91)
Revocation controlREVOKED-1 hour+24 hoursrejected (91)
AttackGOOD-30 daysabsentaccepted (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:

  1. future thisUpdate values are rejected;
  2. expired present nextUpdate values are rejected;
  3. an absent nextUpdate plus any past thisUpdate is 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.c likewise finds no fix.
  • Public curl issue/PR searches for nextUpdate, status age, stale OCSP, and OCSP freshness found 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 unbounded maxsec=-1 plus missing-nextUpdate condition. The PR was closed without merge and neither it nor its proposed replacement changes lib/vtls/openssl.c.
  • CVE-2020-8286 fixed certificate-ID matching in the same function, not response freshness; the maxsec=-1 call 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

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).

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original