curl · Report #4122300
CVE-2026-111953: GnuTLS SecTrust OCSP staple bypass
How it was found
Summary:
When curl is built with GnuTLS and Apple SecTrust, as MacPorts does for curl +gnutls +sectrust, a failed GnuTLS verification is handed to SecTrust, and if SecTrust accepts the chain the GnuTLS result is dropped (lib/vtls/gtls.c:1511-1521 in 8.23.0). SecTrust is called without the stapled OCSP response, so when GnuTLS failed because of the staple (revoked, bad signature, out of date), nothing looks at the staple again: 8.23.0 only checks that one was sent (gtls.c:1436-1451), and master reads the status code but assumes GnuTLS has already verified the signature and the time (gtls.c:1479-1483). As a result --cert-status accepts a correctly signed "revoked" staple on 8.23.0, and a staple with a forged signature or an expired one on 8.23.0 and on current master. The clearest way to see it, on 8.23.0 with a revoked staple: curl --cacert root.pem --cert-status URL fails with (60), the same command with --ca-native added returns HTTP 200, and so do default options, because SecTrust builds use the native store unless a CA file is given (lib/vtls/vtls_config.c:287-288). This is a separate code path from my report #4118610 (OpenSSL with SecTrust, CVE-2026-111484): its fix cd89b5178b passes NULL, 0, FALSE here on purpose ("We check OCSP separately"), and the GnuTLS fix 287df0b324 only added the status code check.
Affected version
8.23.0 accepts revoked, forged and stale staples; master 629646d9b7 accepts forged and stale ones. 8.20.0 accepts forged and stale staples too (tested), and 8.18.0 to 8.20.x have the same code here. The fallback came with eefd03c572 (8.17.0), where it only ran for GNUTLS_CERT_SIGNER_NOT_FOUND, that is for roots GnuTLS does not have; 6aa8fa3fdf (8.18.0, "Do the same for GnuTLS") made it run for every GnuTLS failure, including failures caused by the staple. 8.22.0 rejected every intermediate-signed staple in my tests, good ones included, because of the be6c4ee7fa signature check that 8.21.0 also has (see curl PR 23311). That commit also reasoned that a bad staple cannot pass with peer verification on, because gnutls_certificate_verify_peers2() catches it; this fallback is the case that reasoning did not cover.
MacPorts builds curl +gnutls +sectrust with --with-gnutls --with-ca-bundle=... --with-apple-sectrust. The variant has existed since at least its 8.19.0 port (and +http3 requires +gnutls); the current 8.22.0 port is in the fail-closed window, 8.19.0 and 8.20.0 were affected, and 8.23.0 will be once the port is updated.
Tested on macOS 27.0.1 arm64, Homebrew GnuTLS 3.8.13, curl built from the curl-8_23_0, curl-8_22_0 and curl-8_20_0 tags and from master with -DCURL_USE_GNUTLS=ON -DUSE_APPLE_SECTRUST=ON, without a CA bundle, and for 8.23.0 and master also with one (MacPorts-style):
curl 8.23.0-DEV (Darwin) libcurl/8.23.0-DEV GnuTLS/3.8.13 zlib/1.2.12
curl 8.23.1-DEV (Darwin) libcurl/8.23.1-DEV GnuTLS/3.8.13 zlib/1.2.12 (master 629646d9b7)
Features: alt-svc AppleSecTrust AsynchDNS HSTS HTTPS-proxy IPv6 Largefile libz SSL threadsafe UnixSockets
Steps To Reproduce:
All on 127.0.0.1, scripts in the attached zip under poc/ (same PKI generator as #4118610).
- Build curl 8.23.0 or master with
-DCURL_USE_GNUTLS=ON -DUSE_APPLE_SECTRUST=ON. python3 poc/mkpki2.py poc/pki2creates root, intermediate and server "localhost", staples signed by the intermediate (good, unknown, revoked), one with a forged signature and a stale one.security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db poc/pki2/root.pemopenssl s_server -accept 127.0.0.1:23003 -cert poc/pki2/server.pem -key poc/pki2/server.key -cert_chain poc/pki2/chain.pem -status_file poc/pki2/ocsp-revoked.der -tls1_3 -wwwcurl --cacert poc/pki2/root.pem --cert-status https://localhost:23003/fails with (60): GnuTLS rejects the revoked staple (verify status 34, INVALID and REVOKED).curl -v --cacert poc/pki2/root.pem --ca-native --cert-status https://localhost:23003/logs "SSL certificate verified via Apple SecTrust." and returns HTTP 200 on 8.23.0.curl --cert-status https://localhost:23003/with default options returns 200 as well.- Restart the server with
ocsp-forged-good.derorocsp-stale-good.derand repeat 5 and 6: (60) for GnuTLS alone, 200 with SecTrust, on 8.23.0 and on master. - Clean up:
security delete-certificate -c "Verify Test Root CA" ~/Library/Keychains/login.keychain-db
poc/matrix.sh runs every staple. Keychain trust, no hooks of any kind, TLS 1.3 (TLS 1.2 gives the same). "GnuTLS alone" is --cacert root.pem --cert-status; "with SecTrust" is the same command plus --ca-native, or default options. Both give the results below, and default options give the same with a CA bundle compiled in (MacPorts-style):
| stapled response | 8.23.0 GnuTLS alone | 8.23.0 with SecTrust | master GnuTLS alone | master with SecTrust |
|---|---|---|---|---|
| none | (91) | (91) | (91) | (91) |
| good | 200 | 200 | 200 | 200 |
| revoked | (60) | 200 | (60) | (91) |
| forged signature | (60) | 200 | (60) | 200 |
| stale | (60) | 200 | (60) | 200 |
The "unknown" staple is left out: 8.23.0 accepts it even with GnuTLS alone, which is the separate issue fixed by 287df0b324. Raw output: 01-keychain-trust-no-interposer.txt (every mode, with and without a CA bundle, TLS 1.3 and 1.2), 02-verbose-traces.txt (the pair above with -v, and openssl ocsp checks of each staple), 04-older-releases.txt (8.20.0 and 8.22.0).
Fix
Hand the stapled response to SecTrust on this path and require a positive answer when verifystatus is set, as cd89b5178b does for OpenSSL. With that change on master (fix/gtls-sectrust-ocsp-fix.diff, 03-fix-check.txt) only the good staples pass, with default options and with --cacert plus --ca-native, on TLS 1.2 and 1.3, and nothing changes without --cert-status. Another option is to not let the fallback override a failure where GnuTLS set GNUTLS_CERT_REVOKED, GNUTLS_CERT_INVALID_OCSP_STATUS or GNUTLS_CERT_REVOCATION_DATA_SUPERSEDED; GnuTLS sets them even when it does not know the root (verify status 98, 1048642 and 4162 in 03-fix-check.txt).
AI disclosure: I used an AI assistant (Claude) for code review, test setup and drafting. I reproduced every result myself on curl built from the curl-8_23_0, curl-8_22_0 and curl-8_20_0 tags and from master; the attached logs are the raw output.
Impact
--cert-status is documented to make the transfer fail when the stapled response is invalid or does not confirm the certificate. On GnuTLS builds with Apple SecTrust the staple stops being checked exactly when GnuTLS finds it bad, and this happens with default options. An attacker who holds the key of a revoked certificate and can intercept the connection passes --cert-status on 8.23.0 by stapling the CA's own current "revoked" response, and on 8.18.0 to 8.20.x and current master with an old or self-made "good" one, which is the attack OCSP stapling enforcement exists to stop. The conditions match CVE-2026-7009 and CVE-2026-111484 (both Medium) with GnuTLS in place of OpenSSL, in a configuration MacPorts ships as curl +gnutls +sectrust. libcurl applications that set CURLOPT_SSL_VERIFYSTATUS on such builds are affected the same way.