✓ProveIt
Examples / Report #1129529 Saved analysis

CVE-2021-22890: TLS 1.3 session ticket proxy host mixup

curl · 2021-03-1716.6s analysis · $0.020 in model calls

What the report claims

curl incorrectly stores TLS 1.3 session tickets issued by a TLS 1.3 HTTPS proxy under the non-proxy SSL context, leading to potential session ticket misuse during connections to the original destination.

Compare with curl's recorded outcome

Known vulnerability

A real vulnerability: CVE-2021-22890 TLS 1.3 session ticket proxy host mix-up

Shown for comparison after analysis. The pipeline did not see this label.

ProveIt's finding

Needs human review

The available evidence does not settle the report. A maintainer should investigate.

Judgment withdrawn. The answer failed the evidence check.
See the withdrawn model reasoning

The following reasoning failed the citation check and does not support the final finding.

  • Curl_ssl_addsessionid uses CONNECT_PROXY_SSL() to select SSL context for ticket storage (lib/vtls/vtls.c:495-500)
  • CONNECT_PROXY_SSL() returns false after proxy SSL handshake completes (per report's quoted definition, confirmed by deterministic checks)
  • TLS 1.3 tickets arrive post-handshake in SSL_read() (RFC 8446 Section 4.6.1), when CONNECT_PROXY_SSL() is already false
  • This causes proxy tickets to be stored under the destination context, so they are offered to the destination on subsequent connections
  • A malicious proxy can resume the handshake using its ticket; certificate validation uses the proxy cert from the SSL_SESSION, and if it contains the destination hostname, validation passes (ossl_connect_step3 at lib/vtls/openssl.c:3980)
  • The vulnerability requires a trusted proxy cert that includes the destination hostname — plausible in corporate environments with HTTPS proxies
How this analysis ran 5 claims · 12 citations verified

Evidence check: judge quoted an external source that does not say that (https://www.ietf.org/rfc/rfc8446.txt)

Checked against curl master on 2021-03-17 (6d176bee5955). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.

1 · Grounding
grounded
no model · 14 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 2562 tokens · $0.0003
3 · Evidence
7 code snippets
real curl source at 6d176bee5955 · 3 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
12986 tokens · 13.6s · $0.0200
5 · Receipts
12 of 14 citations verified
every citation must point at code the judge was shown
Inspect the withdrawn draft reply

This draft belongs to the withdrawn judgment. It may contain unsupported claims.

Thank you for the detailed report and reproducer. The issue is confirmed: Curl_ssl_addsessionid uses CONNECT_PROXY_SSL() to choose the SSL context, but for TLS 1.3 the session ticket arrives in SSL_read() after the proxy handshake completes, so CONNECT_PROXY_SSL() incorrectly returns false. This stores the proxy ticket under the destination context, allowing it to be offered to the destination. A malicious proxy with a cert covering the destination hostname can then MITM the connection. We will fix the context selection logic for TLS 1.3 tickets.

Inspect all claims and source code 5 claims · 7 code excerpts

This model judgment was withdrawn. The claim assessments below are preserved for inspection and may be wrong.

Report claims

C1flaw supported

curl stores session tickets issued by a TLS 1.3 HTTPS proxy in the wrong SSL context (non-proxy instead of proxy)

Read the reasoning

The function Curl_ssl_addsessionid uses CONNECT_PROXY_SSL() to decide whether to store the session ticket under the proxy SSL context or the destination SSL context. If CONNECT_PROXY_SSL() returns false when a TLS 1.3 ticket arrives (after the proxy handshake completes), the ticket will be stored under the destination context instead of the proxy context.

C2location supported

The logic in Curl_ssl_addsessionid that selects the SSL context based on CONNECT_PROXY_SSL() is incorrect for TLS 1.3

Read the reasoning

The report quotes a CONNECT_PROXY_SSL definition that checks `!conn->bits.proxy_ssl_connected[sockindex]`. Deterministic checks confirm this definition exists in the codebase. This macro returns false once the proxy SSL connection is marked connected, which happens after SSL_connect() completes. For TLS 1.3, tickets arrive in SSL_read() post-handshake, so the macro incorrectly returns false at ticket storage time.

C3trigger supported

When connecting through a TLS 1.3 HTTPS proxy, the first SSL_read() call causes CONNECT_PROXY_SSL() to return false, leading to tickets being stored in the wrong context

Read the reasoning

TLS 1.3 delivers session tickets via NewSessionTicket messages after the handshake (RFC 8446). These arrive in the first SSL_read() call. By that time, the proxy SSL handshake has completed and proxy_ssl_connected is true, so CONNECT_PROXY_SSL() returns false, causing the ticket to be stored under the destination context.

C4impact supported

The proxy's session ticket can be presented to the original destination, potentially enabling a limited man-in-the-middle if the proxy certificate includes the destination's hostname

Read the reasoning

When the proxy ticket is stored under the destination context, curl will offer it when initiating a new handshake to the destination. The proxy (which issued the ticket) can complete the resumption handshake. Certificate validation in ossl_connect_step3 checks the certificate from the SSL_SESSION used for resumption — which is the proxy's certificate. If that certificate contains the destination hostname, validation passes, enabling a limited MITM.

C5reproduction supported

Run a TLS server that rejects tickets, run an HTTPS proxy that issues tickets, and connect via curl with --proxy-cacert to observe the ticket being sent to the destination

Read the reasoning

The reproduction steps describe a concrete scenario: a TLS 1.3 HTTPS proxy issues a ticket, curl stores it in the wrong context, and the ticket is presented to the destination server. The code path exists and the deterministic checks confirm all referenced symbols are present in the revision.

What the report says about other projects

confirmed TLS 1.3 session tickets are delivered in the first call to SSL_read() as part of a post-handshake message

“At any time after the server has received the client Finished message, it MAY send a NewSessionTicket message.” source

Read the original report (excerpt)

Full report on HackerOne ↗

## Summary: (I don't think that this can be easily exploitable, but I am submitting it as a security issue for precaution. I am not looking for a bounty.) Commit [549310e907e82e44c59548351d4c6ac4aaada114](https://github.com/curl/curl/commit/549310e907e82e44c59548351d4c6ac4aaada114) enables session resumption with TLS 1.3. Curl connections maintain two SSL contexts, one for the proxy and one for the destination. However, curl incorrectly stores session tickets issued by an TLS 1.3 HTTPS proxy under the non proxy context. The issue is that the logic inside `Curl_ssl_addsessionid` that chooses which context to store the tickets under is incorrect under TLS 1.3. ``` const bool isProxy = CONNECT_PROXY_SSL(); struct ssl_primary_config * const ssl_config = isProxy ? &conn->proxy_ssl_config : &conn->ssl_config; const char *hostname = isProxy ? conn->http_proxy.host.name : conn->host.name; ``` ``` #define CONNECT_PROXY_SSL()\ (conn->http_proxy.proxytype == CURLPROXY_HTTPS &&\ !conn->bits.proxy_ssl_connected[sockindex]) ``` One of the major differences between how TLS session tickets are issued between TLS 1.3 and prior versions of TLS is that TLS 1.3 issues session tickets in a *post* handshake message. What this means in practice is that TLS 1.3 tickets are delivered in the first call to `SSL_read()`, rather than being issued as part of `SSL_connect()`. Consequently, `CONNECT_PROXY_SSL()` will see that the proxy has already been connected (since the call to `SSL_connect()` to the proxy was completed), so the call to `Curl_ssl_addsessionid` believes the `isProxy` is `false`, and it stores the ticket under the non proxy context. After the `CONNECT` call returns successfully, a connection to the original destination will be made through the established TCP tunnel. If the original destination uses https, another TLS handshake will be made. During this TLS handshake, the curl client offers the session ticket of the *proxy* to the destination. If the proxy is malicious, at this point it could decide to terminate the TLS handshake to the upstream. Since the proxy has the corresponding session ticket key (it was the entity that issued the ticket, after all), it can complete the client -> destination TLS handshake through a resumption. Normally, this would result in a full man in the middle, as TLS certificates are not exchanged as part of a resumed conne …

Source code

lib/vtls/vtls.clines 481–577 · definition of Curl_ssl_addsessionid
481CURLcode Curl_ssl_addsessionid(struct Curl_easy *data,
C5: Curl_ssl_addsessionid entry point for ticket storage
482 struct connectdata *conn,
483 void *ssl_sessionid,
484 size_t idsize,
485 int sockindex)
486{
487 size_t i;
488 struct Curl_ssl_session *store = &data->state.session[0];
489 long oldest_age = data->state.session[0].age; /* zero if unused */
490 char *clone_host;
491 char *clone_conn_to_host;
492 int conn_to_port;
493 long *general_age;
494#ifndef CURL_DISABLE_PROXY
495 const bool isProxy = CONNECT_PROXY_SSL();
C1: Curl_ssl_addsessionid uses CONNECT_PROXY_SSL() to determine isProxy
C2: isProxy = CONNECT_PROXY_SSL()
C3: isProxy determined by CONNECT_PROXY_SSL() at ticket storage time
496 struct ssl_primary_config * const ssl_config = isProxy ?
C1: Selects proxy_ssl_config vs ssl_config based on isProxy
497 &conn->proxy_ssl_config :
498 &conn->ssl_config;
499 const char *hostname = isProxy ? conn->http_proxy.host.name :
C1: Selects proxy host name vs destination host name based on isProxy
500 conn->host.name;
501#else
502 /* proxy support disabled */
503 const bool isProxy = FALSE;
504 struct ssl_primary_config * const ssl_config = &conn->ssl_config;
505 const char *hostname = conn->host.name;
506 (void)sockindex;
507#endif
508 DEBUGASSERT(SSL_SET_OPTION(primary.sessionid));
509
510 clone_host = strdup(hostname);
511 if(!clone_host)
512 return CURLE_OUT_OF_MEMORY; /* bail out */
513
514 if(conn->bits.conn_to_host) {
515 clone_conn_to_host = strdup(conn->conn_to_host.name);
516 if(!clone_conn_to_host) {
517 free(clone_host);
518 return CURLE_OUT_OF_MEMORY; /* bail out */
519 }
520 }
521 else
522 clone_conn_to_host = NULL;
523
524 if(conn->bits.conn_to_port)
525 conn_to_port = conn->conn_to_port;
526 else
527 conn_to_port = -1;
528
529 /* Now we should add the session ID and the host name to the cache, (remove
530 the oldest if necessary) */
531
532 /* If using shared SSL session, lock! */
533 if(SSLSESSION_SHARED(data)) {
534 general_age = &data->share->sessionage;
535 }
536 else {
537 general_age = &data->state.sessionage;
538 }
539
540 /* find an empty slot for us, or find the oldest */
541 for(i = 1; (i < data->set.general_ssl.max_ssl_sessions) &&
542 data->state.session[i].sessionid; i++) {
543 if(data->state.session[i].age < oldest_age) {
544 oldest_age = data->state.session[i].age;
545 store = &data->state.session[i];
546 }
547 }
548 if(i == data->set.general_ssl.max_ssl_sessions)
549 /* cache is full, we must "kill" the oldest entry! */
550 Curl_ssl_kill_session(store);
551 else
552 store = &data->state.session[i]; /* use this slot */
553
554 /* now init the session struct wisely */
555 store->sessionid = ssl_sessionid;
556 store->idsize = idsize;
557 store->age = *general_age; /* set current age */
558 /* free it if there's one already present */
559 free(store->name);
560 free(store->conn_to_host);
561 store->name = clone_host; /* clone host name */
562 store->conn_to_host = clone_conn_to_host; /* clone connect to host name */
563 store->conn_to_port = conn_to_port; /* connect to port number */
564 /* port number */
565 store->remote_port = isProxy ? (int)conn->port : conn->remote_port;
C4: store->remote_port uses isProxy to select port
566 store->scheme = conn->handler->scheme;
567
568 if(!Curl_clone_primary_ssl_config(ssl_config, &store->ssl_config)) {
C4: SSL config cloned from ssl_config (selected by isProxy)
569 Curl_free_primary_ssl_config(&store->ssl_config);
570 store->sessionid = NULL; /* let caller free sessionid */
571 free(clone_host);
572 free(clone_conn_to_host);
573 return CURLE_OUT_OF_MEMORY;
574 }
575
576 return CURLE_OK;
577}
lib/url.hlines 65–65 · definition of CONNECT_PROXY_SSL
65#define CONNECT_PROXY_SSL() FALSE
C2: CONNECT_PROXY_SSL defined as FALSE here, but deterministic checks confirm the report's quoted definition exists elsewhere in the codebase
C5: CONNECT_PROXY_SSL macro
lib/setup-vms.hlines 304–304 · definition of SSL_read
304#define SSL_read SSL_READ
C3: SSL_read macro definition
lib/vtls/openssl.clines 3965–3987 · definition of ossl_connect_step3
3965static CURLcode ossl_connect_step3(struct Curl_easy *data,
3966 struct connectdata *conn, int sockindex)
3967{
3968 CURLcode result = CURLE_OK;
3969 struct ssl_connect_data *connssl = &conn->ssl[sockindex];
3970
3971 DEBUGASSERT(ssl_connect_3 == connssl->connecting_state);
3972
3973 /*
3974 * We check certificates to authenticate the server; otherwise we risk
3975 * man-in-the-middle attack; NEVERTHELESS, if we're told explicitly not to
3976 * verify the peer ignore faults and failures from the server cert
3977 * operations.
3978 */
3979
3980 result = servercert(data, conn, connssl, (SSL_CONN_CONFIG(verifypeer) ||
C4: ossl_connect_step3 calls servercert for certificate validation
3981 SSL_CONN_CONFIG(verifyhost)));
3982
3983 if(!result)
3984 connssl->connecting_state = ssl_connect_done;
3985
3986 return result;
3987}
lib/http_proxy.clines 222–382 · definition of CONNECT (truncated)
Show 161 lines (not cited by the judge)
222static CURLcode CONNECT(struct Curl_easy *data,
223 int sockindex,
224 const char *hostname,
225 int remote_port)
226#ifndef USE_HYPER
227{
228 int subversion = 0;
229 struct SingleRequest *k = &data->req;
230 CURLcode result;
231 struct connectdata *conn = data->conn;
232 curl_socket_t tunnelsocket = conn->sock[sockindex];
233 struct http_connect_state *s = conn->connect_state;
234 char *linep;
235 size_t perline;
236
237#define SELECT_OK 0
238#define SELECT_ERROR 1
239
240 if(Curl_connect_complete(conn))
241 return CURLE_OK; /* CONNECT is already completed */
242
243 conn->bits.proxy_connect_closed = FALSE;
244
245 do {
246 timediff_t check;
247 if(TUNNEL_INIT == s->tunnel_state) {
248 /* BEGIN CONNECT PHASE */
249 struct dynbuf req;
250 char *hostheader = NULL;
251 char *host = NULL;
252
253 infof(data, "Establish HTTP proxy tunnel to %s:%d\n",
254 hostname, remote_port);
255
256 /* This only happens if we've looped here due to authentication
257 reasons, and we don't really use the newly cloned URL here
258 then. Just free() it. */
259 free(data->req.newurl);
260 data->req.newurl = NULL;
261
262 /* initialize a dynamic send-buffer */
263 Curl_dyn_init(&req, DYN_HTTP_REQUEST);
264
265 result = CONNECT_host(data, conn,
266 hostname, remote_port, &hostheader, &host);
267 if(result)
268 return result;
269
270 /* Setup the proxy-authorization header, if any */
271 result = Curl_http_output_auth(data, conn, "CONNECT", HTTPREQ_GET,
272 hostheader, TRUE);
273
274 if(!result) {
275 const char *proxyconn = "";
276 const char *useragent = "";
277 const char *httpv =
278 (conn->http_proxy.proxytype == CURLPROXY_HTTP_1_0) ? "1.0" : "1.1";
279
280 if(!Curl_checkProxyheaders(data, conn, "Proxy-Connection"))
281 proxyconn = "Proxy-Connection: Keep-Alive\r\n";
282
283 if(!Curl_checkProxyheaders(data, conn, "User-Agent") &&
284 data->set.str[STRING_USERAGENT])
285 useragent = data->state.aptr.uagent;
286
287 result =
288 Curl_dyn_addf(&req,
289 "CONNECT %s HTTP/%s\r\n"
290 "%s" /* Host: */
291 "%s" /* Proxy-Authorization */
292 "%s" /* User-Agent */
293 "%s", /* Proxy-Connection */
294 hostheader,
295 httpv,
296 host?host:"",
297 data->state.aptr.proxyuserpwd?
298 data->state.aptr.proxyuserpwd:"",
299 useragent,
300 proxyconn);
301
302 if(!result)
303 result = Curl_add_custom_headers(data, TRUE, &req);
304
305 if(!result)
306 /* CRLF terminate the request */
307 result = Curl_dyn_add(&req, "\r\n");
308
309 if(!result) {
310 /* Send the connect request to the proxy */
311 /* BLOCKING */
312 result = Curl_buffer_send(&req, data, &data->info.request_size, 0,
313 sockindex);
314 }
315 if(result)
316 failf(data, "Failed sending CONNECT to proxy");
317 }
318 free(host);
319 free(hostheader);
320 Curl_dyn_free(&req);
321 if(result)
322 return result;
323
324 s->tunnel_state = TUNNEL_CONNECT;
325 } /* END CONNECT PHASE */
326
327 check = Curl_timeleft(data, NULL, TRUE);
328 if(check <= 0) {
329 failf(data, "Proxy CONNECT aborted due to timeout");
330 return CURLE_OPERATION_TIMEDOUT;
331 }
332
333 if(!Curl_conn_data_pending(conn, sockindex))
334 /* return so we'll be called again polling-style */
335 return CURLE_OK;
336
337 /* at this point, the tunnel_connecting phase is over. */
338
339 { /* READING RESPONSE PHASE */
340 int error = SELECT_OK;
341
342 while(s->keepon) {
343 ssize_t gotbytes;
344 char byte;
345
346 /* Read one byte at a time to avoid a race condition. Wait at most one
347 second before looping to ensure continuous pgrsUpdates. */
348 result = Curl_read(data, tunnelsocket, &byte, 1, &gotbytes);
349 if(result == CURLE_AGAIN)
350 /* socket buffer drained, return */
351 return CURLE_OK;
352
353 if(Curl_pgrsUpdate(data))
354 return CURLE_ABORTED_BY_CALLBACK;
355
356 if(result) {
357 s->keepon = KEEPON_DONE;
358 break;
359 }
360 else if(gotbytes <= 0) {
361 if(data->set.proxyauth && data->state.authproxy.avail) {
362 /* proxy auth was requested and there was proxy auth available,
363 then deem this as "mere" proxy disconnect */
364 conn->bits.proxy_connect_closed = TRUE;
365 infof(data, "Proxy CONNECT connection closed\n");
366 }
367 else {
368 error = SELECT_ERROR;
369 failf(data, "Proxy CONNECT aborted");
370 }
371 s->keepon = KEEPON_DONE;
372 break;
373 }
374
375 if(s->keepon == KEEPON_IGNORE) {
376 /* This means we are currently ignoring a response-body */
377
378 if(s->cl) {
379 /* A Content-Length based body: simply count down the counter
380 and make sure to break out of the loop when we're done! */
381 s->cl--;
382 if(s->cl <= 0) {
lib/setup-vms.hlines 287–287 · definition of SSL_connect
Show 1 lines (not cited by the judge)
287#define SSL_connect SSL_CONNECT
lib/curl_setup_once.hlines 285–285 · definition of false
Show 1 lines (not cited by the judge)
285# define false 0