CVE-2026-7009: OCSP stapling bypass with Apple SecTrust
What the report claims
curl silently bypasses OCSP stapling checks when built with Apple SecTrust and --cert-status is set, allowing connections without a valid OCSP response.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-7009 OCSP stapling bypass with Apple SecTrust
Shown for comparison after analysis. The pipeline did not see this label.
ProveIt's finding
The code supports the reported flaw. A maintainer should review its impact and decide what to do next.
Why this finding?
- The code at lib/vtls/openssl.c lines 4791 and 4809-4815 shows sectrust_verified causes the OCSP status check (verifystatus) to be skipped entirely when Apple SecTrust verifies the certificate chain.
- verifystatus() at lines 2253-2256 is the only place that enforces 'No OCSP response received' failure; it is never reached when sectrust_verified is TRUE.
- ossl_apple_verify at lines 4706-4711 only passes a stapled OCSP response to SecTrust when the server provides one (ocsp_len > 0); otherwise ocsp_len is clamped to 0 and SecTrust uses its default best-effort revocation policy.
- Apple's documentation for SecTrustSetOCSPResponse confirms it only uses the stapled response when provided, otherwise other methods are used.
- This violates the documented guarantee that --cert-status aborts when no OCSP response is received, and matches the vulnerability class of CVE-2024-8096 and CVE-2024-0853.
How this analysis ran 5 claims · 23 citations verified
Checked against curl tested commit bfcc1c6eb4 (bfcc1c6eb4d8). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.
Review a draft reply to the reporter
Edit and verify before sending.
Thank you for the detailed report. The code confirms the flaw: when USE_APPLE_SECTRUST is enabled and SecTrust verifies the certificate (lib/vtls/openssl.c:4791), the sectrust_verified flag causes the OCSP stapling check (verifystatus) to be skipped entirely (lines 4809-4815). Since SecTrust only receives a stapled response when the server provides one (lines 4706-4711), a server without OCSP stapling bypasses the --cert-status enforcement. This violates the documented guarantee and is a valid security issue. We will prepare a fix.
Inspect all claims and source code 5 claims · 3 code excerpts
Report claims
When Apple SecTrust verifies the certificate chain, the code sets sectrust_verified = TRUE and then skips the OCSP status check because of the condition !sectrust_verified && conn_config->verifystatus, causing the verifystatus() function to never be called.
Read the reasoning
The code explicitly sets sectrust_verified = TRUE at line 4791 when SecTrust verification succeeds, then at lines 4809-4815 the OCSP status check (verifystatus) is conditional on !sectrust_verified, causing it to be skipped entirely when SecTrust handled verification.
The flaw occurs in lib/vtls/openssl.c in the Curl_ossl_check_peer_cert() function, specifically around the sectrust_verified flag handling.
Read the reasoning
The flaw is located in lib/vtls/openssl.c in the Curl_ossl_check_peer_cert function, specifically around lines 4734-4791 (sectrust_verified declaration and setting) and lines 4809-4815 (OCSP check bypass).
The trigger is the use of the --cert-status or CURLOPT_SSL_VERIFYSTATUS option together with building curl with USE_APPLE_SECTRUST=ON, which causes the code path that sets sectrust_verified to be taken.
Read the reasoning
The trigger requires USE_APPLE_SECTRUST=ON at build time (lines 4734, 4779), --cert-status / CURLOPT_SSL_VERIFYSTATUS at runtime (conn_config->verifystatus at line 4809), and the native CA store option (ssl_config->native_ca_store at line 4780) which maps to --ca-native.
The impact is that the connection succeeds even when the server provides no OCSP staple, violating the documented guarantee that curl aborts when no OCSP response is received.
Read the reasoning
When sectrust_verified is TRUE, verifystatus() is never called (line 4815), so the 'No OCSP response received' check at line 2253-2255 never executes. Meanwhile, ossl_apple_verify only passes a stapled OCSP response to SecTrust when ocsp_len > 0 (lines 4706-4711); if the server doesn't staple, ocsp_len becomes 0 and SecTrust uses its default revocation policy without a positive OCSP response.
The reproduction involves building curl with Apple SecTrust enabled, running a server that does not provide an OCSP staple, and observing that curl exits with success despite --cert-status being set.
Read the reasoning
The reproduction steps match the code paths: build with USE_APPLE_SECTRUST=ON (line 4734), run with --ca-native (native_ca_store at line 4780) and --cert-status (verifystatus at line 4809), use a server that doesn't staple OCSP (SSL_get_tlsext_status_ocsp_resp returns -1 at line 4706), and observe success instead of CURLE_SSL_INVALIDCERTSTATUS.
What the report says about other projects
confirmed SecTrustSetOCSPResponse only uses a stapled OCSP response when provided; if no data is available, SecTrust falls back to other revocation checking methods.
“This function allows the caller to provide OCSPResponse data (which may be obtained during a TLS/SSL handshake, per RFC3546) as input to a trust evaluation. If this data is available, it can obviate the need to contact an OCSP server for current revocation information.” source
Read the original report (excerpt)
Source code
| 4722 | CURLcode Curl_ossl_check_peer_cert(struct Curl_cfilter *cf, |
| C2: Curl_ossl_check_peer_cert function definition | |
| 4723 | struct Curl_easy *data, |
| 4724 | struct ossl_ctx *octx, |
| 4725 | struct ssl_peer *peer) |
| 4726 | { |
| 4727 | struct connectdata *conn = cf->conn; |
| 4728 | struct ssl_config_data *ssl_config = Curl_ssl_cf_get_config(cf, data); |
| 4729 | struct ssl_primary_config *conn_config = Curl_ssl_cf_get_primary_config(cf); |
| 4730 | CURLcode result = CURLE_OK; |
| 4731 | long ossl_verify; |
| 4732 | X509 *server_cert; |
| 4733 | bool verified = FALSE; |
| 4734 | #if !defined(OPENSSL_NO_OCSP) && defined(USE_APPLE_SECTRUST) |
| C2: sectrust_verified variable declared under USE_APPLE_SECTRUST | |
| C3: #if !defined(OPENSSL_NO_OCSP) && defined(USE_APPLE_SECTRUST) guard for sectrust_verified | |
| C5: USE_APPLE_SECTRUST build flag controls sectrust_verified code path | |
| 4735 | bool sectrust_verified = FALSE; |
| 4736 | #endif |
| 4737 | |
| 4738 | if(data->set.ssl.certinfo && !octx->reused_session) { |
| 4739 | /* asked to gather certificate info. Reused sessions do not have cert |
| 4740 | chains */ |
| 4741 | result = ossl_certchain(data, octx->ssl); |
| 4742 | if(result) |
| 4743 | return result; |
| 4744 | } |
| 4745 | |
| 4746 | server_cert = SSL_get1_peer_certificate(octx->ssl); |
| 4747 | if(!server_cert) { |
| 4748 | /* no verification at all, this maybe acceptable */ |
| 4749 | if(!(conn_config->verifypeer || conn_config->verifyhost)) |
| 4750 | goto out; |
| 4751 | |
| 4752 | failf(data, "SSL: could not get peer certificate"); |
| 4753 | result = CURLE_PEER_FAILED_VERIFICATION; |
| 4754 | goto out; |
| 4755 | } |
| 4756 | |
| 4757 | #ifdef CURLVERBOSE |
| 4758 | result = ossl_infof_cert(cf, data, server_cert); |
| 4759 | if(result) |
| 4760 | goto out; |
| 4761 | infof_certstack(data, octx->ssl); |
| 4762 | #endif |
| 4763 | |
| 4764 | if(conn_config->verifyhost) { |
| 4765 | result = ossl_verifyhost(data, conn, peer, server_cert); |
| 4766 | if(result) |
| 4767 | goto out; |
| 4768 | } |
| 4769 | /* `verifyhost` is either OK or not requested from here on */ |
| 4770 | |
| 4771 | ossl_verify = SSL_get_verify_result(octx->ssl); |
| 4772 | ssl_config->certverifyresult = ossl_verify; |
| 4773 | infof(data, "OpenSSL verify result: %lx", ossl_verify); |
| 4774 | |
| 4775 | verified = (ossl_verify == X509_V_OK); |
| 4776 | if(verified) |
| 4777 | infof(data, "SSL certificate verified via OpenSSL."); |
| 4778 | |
| 4779 | #ifdef USE_APPLE_SECTRUST |
| C2: Apple SecTrust verification block | |
| C3: #ifdef USE_APPLE_SECTRUST guard for SecTrust verification path | |
| 4780 | if(!verified && conn_config->verifypeer && ssl_config->native_ca_store) { |
| C3: Condition: !verified && conn_config->verifypeer && ssl_config->native_ca_store | |
| C5: ssl_config->native_ca_store corresponds to --ca-native runtime flag | |
| 4781 | /* we verify using Apple SecTrust *unless* OpenSSL already verified. |
| 4782 | * This may happen if the application intercepted the OpenSSL callback |
| 4783 | * and installed its own. */ |
| 4784 | result = ossl_apple_verify(cf, data, octx, peer, &verified); |
| 4785 | if(result && (result != CURLE_PEER_FAILED_VERIFICATION)) |
| 4786 | goto out; /* unexpected error */ |
| 4787 | if(verified) { |
| 4788 | infof(data, "SSL certificate verified via Apple SecTrust."); |
| 4789 | ssl_config->certverifyresult = X509_V_OK; |
| 4790 | #ifndef OPENSSL_NO_OCSP |
| 4791 | sectrust_verified = TRUE; |
| C1: sectrust_verified = TRUE set when Apple SecTrust verifies the certificate | |
| 4792 | #endif |
| 4793 | } |
| 4794 | } |
| 4795 | #endif |
| 4796 | |
| 4797 | if(!verified) { |
| 4798 | /* no trust established, report the OpenSSL status */ |
| 4799 | if(conn_config->verifypeer) { |
| 4800 | failf(data, "SSL certificate OpenSSL verify result: %s (%ld)", |
| 4801 | X509_verify_cert_error_string(ossl_verify), ossl_verify); |
| 4802 | result = CURLE_PEER_FAILED_VERIFICATION; |
| 4803 | goto out; |
| 4804 | } |
| 4805 | infof(data, " SSL certificate verification failed, continuing anyway!"); |
| 4806 | } |
| 4807 | |
| 4808 | #ifndef OPENSSL_NO_OCSP |
| 4809 | if(conn_config->verifystatus && |
| C1: verifystatus check guarded by !sectrust_verified condition | |
| C2: OCSP status check with sectrust_verified condition | |
| C3: conn_config->verifystatus corresponds to --cert-status / CURLOPT_SSL_VERIFYSTATUS | |
| C5: conn_config->verifystatus corresponds to --cert-status runtime flag | |
| 4810 | #ifdef USE_APPLE_SECTRUST |
| C1: #ifdef USE_APPLE_SECTRUST block with !sectrust_verified condition | |
| 4811 | !sectrust_verified && /* already verified via apple sectrust, cannot |
| C1: comment: 'already verified via apple sectrust, cannot verifystate via OpenSSL in that case as it does not have the trust anchors' | |
| 4812 | * verifystate via OpenSSL in that case as it |
| 4813 | * does not have the trust anchors */ |
| 4814 | #endif |
| 4815 | !octx->reused_session) { |
| C1: verifystatus() call only reached when !sectrust_verified | |
| C4: verifystatus() never called when sectrust_verified is TRUE | |
| 4816 | /* do not do this after Session ID reuse */ |
| 4817 | result = verifystatus(cf, data, octx); |
| 4818 | if(result) |
| 4819 | goto out; |
| 4820 | } |
| 4821 | #endif |
| 4822 | |
| 4823 | result = ossl_check_issuer(cf, data, server_cert); |
| 4824 | if(result) |
| 4825 | goto out; |
| 4826 | |
| 4827 | result = ossl_check_pinned_key(cf, data, server_cert); |
| 4828 | |
| 4829 | out: |
| 4830 | X509_free(server_cert); |
| 4831 | return result; |
| 4832 | } |
| 2225 | static CURLcode verifystatus(struct Curl_cfilter *cf, |
| 2226 | struct Curl_easy *data, |
| 2227 | struct ossl_ctx *octx) |
| 2228 | { |
| 2229 | int i, ocsp_status; |
| 2230 | #ifdef HAVE_BORINGSSL_LIKE |
| 2231 | const uint8_t *status; |
| 2232 | #else |
| 2233 | unsigned char *status; |
| 2234 | #endif |
| 2235 | const unsigned char *p; |
| 2236 | CURLcode result = CURLE_OK; |
| 2237 | OCSP_RESPONSE *rsp = NULL; |
| 2238 | OCSP_BASICRESP *br = NULL; |
| 2239 | X509_STORE *st = NULL; |
| 2240 | STACK_OF(X509) *ch = NULL; |
| 2241 | X509 *cert; |
| 2242 | OCSP_CERTID *id = NULL; |
| 2243 | int cert_status, crl_reason; |
| 2244 | ASN1_GENERALIZEDTIME *rev, *thisupd, *nextupd; |
| 2245 | int ret; |
| 2246 | long len; |
| 2247 | |
| 2248 | (void)cf; |
| 2249 | DEBUGASSERT(octx); |
| 2250 | |
| 2251 | len = (long)SSL_get_tlsext_status_ocsp_resp(octx->ssl, &status); |
| 2252 | |
| 2253 | if(!status) { |
| C4: verifystatus() fails with 'No OCSP response received' when status is missing | |
| 2254 | failf(data, "No OCSP response received"); |
| C4: failf(data, "No OCSP response received") | |
| 2255 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| C4: result = CURLE_SSL_INVALIDCERTSTATUS | |
| 2256 | goto end; |
| 2257 | } |
| 2258 | p = status; |
| 2259 | rsp = d2i_OCSP_RESPONSE(NULL, &p, len); |
| 2260 | if(!rsp) { |
| 2261 | failf(data, "Invalid OCSP response"); |
| 2262 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2263 | goto end; |
| 2264 | } |
| 2265 | |
| 2266 | ocsp_status = OCSP_response_status(rsp); |
| 2267 | if(ocsp_status != OCSP_RESPONSE_STATUS_SUCCESSFUL) { |
| 2268 | failf(data, "Invalid OCSP response status: %s (%d)", |
| 2269 | OCSP_response_status_str(ocsp_status), ocsp_status); |
| 2270 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2271 | goto end; |
| 2272 | } |
| 2273 | |
| 2274 | br = OCSP_response_get1_basic(rsp); |
| 2275 | if(!br) { |
| 2276 | failf(data, "Invalid OCSP response"); |
| 2277 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2278 | goto end; |
| 2279 | } |
| 2280 | |
| 2281 | ch = SSL_get_peer_cert_chain(octx->ssl); |
| 2282 | if(!ch) { |
| 2283 | failf(data, "Could not get peer certificate chain"); |
| 2284 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2285 | goto end; |
| 2286 | } |
| 2287 | st = SSL_CTX_get_cert_store(octx->ssl_ctx); |
| 2288 | |
| 2289 | if(OCSP_basic_verify(br, ch, st, 0) <= 0) { |
| 2290 | failf(data, "OCSP response verification failed"); |
| 2291 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2292 | goto end; |
| 2293 | } |
| 2294 | |
| 2295 | /* Compute the certificate's ID */ |
| 2296 | cert = SSL_get1_peer_certificate(octx->ssl); |
| 2297 | if(!cert) { |
| 2298 | failf(data, "Error getting peer certificate"); |
| 2299 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2300 | goto end; |
| 2301 | } |
| 2302 | |
| 2303 | for(i = 0; i < (int)sk_X509_num(ch); i++) { |
| 2304 | X509 *issuer = sk_X509_value(ch, (ossl_valsize_t)i); |
| 2305 | if(X509_check_issued(issuer, cert) == X509_V_OK) { |
| 2306 | /* Note to analysis tools: using SHA1 here is fine. The `id` |
| 2307 | * generated is used as a hash lookup key, not as a verifier |
| 2308 | * of the OCSP data itself. This all according to RFC 5019. */ |
| 2309 | id = OCSP_cert_to_id(EVP_sha1(), cert, issuer); |
| 2310 | break; |
| 2311 | } |
| 2312 | } |
| 2313 | X509_free(cert); |
| 2314 | |
| 2315 | if(!id) { |
| 2316 | failf(data, "Error computing OCSP ID"); |
| 2317 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2318 | goto end; |
| 2319 | } |
| 2320 | |
| 2321 | /* Find the single OCSP response corresponding to the certificate ID */ |
| 2322 | ret = OCSP_resp_find_status(br, id, &cert_status, &crl_reason, &rev, |
| 2323 | &thisupd, &nextupd); |
| 2324 | OCSP_CERTID_free(id); |
| 2325 | if(ret != 1) { |
| 2326 | failf(data, "Could not find certificate ID in OCSP response"); |
| 2327 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2328 | goto end; |
| 2329 | } |
| 2330 | |
| 2331 | /* Validate the OCSP response issuing and update times. |
| 2332 | * - `thisupd` is the time the OCSP response was issued |
| 2333 | * - `nextupd` is the time the OCSP response should be updated |
| 2334 | * (valid life time assigned by the OCSP responder) |
| 2335 | * - 3rd param: how many seconds of clock skew we allow between |
| 2336 | * our clock and the instance that issued the OCSP response |
| 2337 | * - 4th param: how many seconds in the past `thisupd` may be, with |
| 2338 | * -1 meaning there is no limit. */ |
| 2339 | if(!OCSP_check_validity(thisupd, nextupd, 300L, -1L)) { |
| 2340 | failf(data, "OCSP response has expired"); |
| 2341 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2342 | goto end; |
| 2343 | } |
| 2344 | |
| 2345 | infof(data, "SSL certificate status: %s (%d)", |
| 2346 | OCSP_cert_status_str(cert_status), cert_status); |
| 2347 | |
| 2348 | switch(cert_status) { |
| 2349 | case V_OCSP_CERTSTATUS_GOOD: |
| 2350 | break; |
| 2351 | |
| 2352 | case V_OCSP_CERTSTATUS_REVOKED: |
| 2353 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2354 | failf(data, "SSL certificate revocation reason: %s (%d)", |
| 2355 | OCSP_crl_reason_str(crl_reason), crl_reason); |
| 2356 | goto end; |
| 2357 | |
| 2358 | case V_OCSP_CERTSTATUS_UNKNOWN: |
| 2359 | default: |
| 2360 | result = CURLE_SSL_INVALIDCERTSTATUS; |
| 2361 | goto end; |
| 2362 | } |
| 2363 | |
| 2364 | end: |
| 2365 | if(br) |
| 2366 | OCSP_BASICRESP_free(br); |
| 2367 | OCSP_RESPONSE_free(rsp); |
| 2368 | |
| 2369 | return result; |
| 2370 | } |
| 4673 | static CURLcode ossl_apple_verify(struct Curl_cfilter *cf, |
| 4674 | struct Curl_easy *data, |
| 4675 | struct ossl_ctx *octx, |
| 4676 | struct ssl_peer *peer, |
| 4677 | bool *pverified) |
| 4678 | { |
| 4679 | struct ssl_primary_config *conn_config = Curl_ssl_cf_get_primary_config(cf); |
| 4680 | struct ossl_certs_ctx chain; |
| 4681 | CURLcode result; |
| 4682 | |
| 4683 | memset(&chain, 0, sizeof(chain)); |
| 4684 | chain.sk = SSL_get_peer_cert_chain(octx->ssl); |
| 4685 | chain.num_certs = chain.sk ? sk_X509_num(chain.sk) : 0; |
| 4686 | |
| 4687 | if(!chain.num_certs && |
| 4688 | (conn_config->verifypeer || conn_config->verifyhost)) { |
| 4689 | if(!octx->reused_session) { |
| 4690 | failf(data, "SSL: could not get peer certificate chain"); |
| 4691 | result = CURLE_PEER_FAILED_VERIFICATION; |
| 4692 | } |
| 4693 | else { |
| 4694 | /* when session was reused, there is no peer cert chain */ |
| 4695 | *pverified = FALSE; |
| 4696 | return CURLE_OK; |
| 4697 | } |
| 4698 | } |
| 4699 | else { |
| 4700 | #ifdef HAVE_BORINGSSL_LIKE |
| 4701 | const uint8_t *ocsp_data = NULL; |
| 4702 | #else |
| 4703 | unsigned char *ocsp_data = NULL; |
| 4704 | #endif |
| 4705 | long ocsp_len = 0; |
| 4706 | if(conn_config->verifystatus && !octx->reused_session) |
| C4: ossl_apple_verify only gets OCSP staple when verifystatus is set and not reused session | |
| C5: Server without OCSP staple causes SSL_get_tlsext_status_ocsp_resp to return -1 | |
| 4707 | ocsp_len = (long)SSL_get_tlsext_status_ocsp_resp(octx->ssl, &ocsp_data); |
| 4708 | |
| 4709 | /* SSL_get_tlsext_status_ocsp_resp() returns the length of the OCSP |
| 4710 | response data or -1 if there is no OCSP response data. */ |
| 4711 | if(ocsp_len < 0) |
| C4: ocsp_len clamped to 0 when SSL_get_tlsext_status_ocsp_resp returns -1 (no staple) | |
| 4712 | ocsp_len = 0; /* no data available */ |
| 4713 | result = Curl_vtls_apple_verify(cf, data, peer, chain.num_certs, |
| 4714 | ossl_chain_get_der, &chain, |
| 4715 | ocsp_data, ocsp_len); |
| 4716 | } |
| 4717 | *pverified = !result; |
| 4718 | return result; |
| 4719 | } |