CVE-2021-22890: TLS 1.3 session ticket proxy host mixup
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
The available evidence does not settle the report. A maintainer should investigate.
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.
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
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.
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.
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.
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.
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)
Source code
| 481 | CURLcode 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 | } |
| 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 |
| 304 | #define SSL_read SSL_READ |
| C3: SSL_read macro definition |
| 3965 | static 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 | } |
Show 161 lines (not cited by the judge)
| 222 | static 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) { |
Show 1 lines (not cited by the judge)
| 287 | #define SSL_connect SSL_CONNECT |
Show 1 lines (not cited by the judge)
| 285 | # define false 0 |