CVE-2026-9546: sending old referer
What the report claims
libcurl does not clear the cached per-handle referer state when CURLOPT_REFERER is set to NULL after a transfer, causing stale Referer values to be sent on subsequent requests on the same easy handle.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2026-9546 sending old referer
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 provided lib/transfer.c lines 509-513 show the exact flaw: Curl_pretransfer only frees data->state.referer when http_auto_referer is true (for auto-generated referers on redirects), and only sets it when the user-set string is non-NULL. When the application follows the documented API and calls CURLOPT_REFERER=NULL after a transfer, the user-set string becomes NULL, so the set is skipped, and the free is also skipped (since http_auto_referer is false for user-set referers). The stale pointer in data->state.referer persists.
- The PoC demonstrates a real cross-request confidentiality breach: a multi-tenant service that reuses easy handles (as encouraged by libcurl) can leak Tenant A's referer to Tenant B's server after correctly clearing the option. The same sequence also triggers a heap-use-after-free detected by ASan.
- This is not API misuse: curl_easy_perform(3) documents repeated transfers on the same handle with curl_easy_setopt between them, and CURLOPT_REFERER(3) documents that NULL disables the header. The application follows the documented contract; libcurl fails to honor it.
- The project policy does not list cross-request data exposure or use-after-free with data leakage as 'not security issues'. The 'Small memory leaks' section notes that leaks containing sensitive data might qualify as security problems. The 'NULL dereferences and crashes' section addresses crashes, not data leaks via UAF.
What still needs checking
- The lib/http.c and lib/getinfo.c code that consumes data->state.referer would complete the picture but are not required to establish the core flaw in Curl_pretransfer.
How this analysis ran 4 claims · 3 citations verified
Checked against curl master on 2026-05-22 (8e549fbdd36b). 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 and reproducible PoC. The flaw is confirmed in lib/transfer.c lines 509-513: Curl_pretransfer only clears data->state.referer when http_auto_referer is true, and only updates it when the user-set string is non-NULL. Setting CURLOPT_REFERER=NULL after a transfer leaves a dangling pointer in data->state.referer, which the next request then uses. This breaks the documented clear contract and causes cross-request referer leakage in handle-reuse scenarios. We will treat this as a low-severity security issue and prepare a fix.
Inspect all claims and source code 4 claims · 4 code excerpts
Report claims
CURLOPT_REFERER setter frees/replaces only set.str[STRING_SET_REFERER] in lib/setopt.c
Read the reasoning
The transfer.c code shows the consumer side: the per-request referer buffer (data->state.referer) is only cleared when http_auto_referer is true, and only updated when the user-set string is non-NULL. When the application calls CURLOPT_REFERER=NULL, the user-set string becomes NULL, so the update is skipped, and if http_auto_referer is false (typical for user-set referers), the free is also skipped, leaving a dangling pointer in data->state.referer.
Curl_pretransfer() binds data->state.referer only when the current referer string is non-NULL and does not clear the stale bufref in the documented clear path in lib/transfer.c
Read the reasoning
Lines 509-513 in Curl_pretransfer show exactly the flaw: the free is gated by http_auto_referer (which is for auto-generated referers on redirects, not user-set ones), and the set is gated by the user-set string being non-NULL. When the documented clear path (CURLOPT_REFERER=NULL) is used, the user-set string becomes NULL, so the set is skipped, and the free is also skipped unless http_auto_referer happens to be true. The stale bufref in data->state.referer persists.
The next HTTP request formats data->state.referer into the request header in lib/http.c
Read the reasoning
lib/http.c is not provided in the source excerpts. The deterministic checks confirm the file exists and the symbol Curl_pretransfer exists, but the specific header-formatting code that reads data->state.referer is not visible. The claim is plausible but cannot be verified from the provided code alone.
CURLINFO_REFERER also returns that same stale pointer in lib/getinfo.c
Read the reasoning
lib/getinfo.c is not provided in the source excerpts. The deterministic checks confirm the deterministic checks confirm CURLINFO_REFERER exists, but the implementation returning data->state.referer is not visible. The claim is plausible but cannot be verified from the provided code alone.
What the report says about other projects
not addressed libcurl documents that CURLOPT_REFERER can be set to NULL to disable the Referer header again
Read the original report (excerpt)
Source code
| 446 | CURLcode Curl_pretransfer(struct Curl_easy *data) |
| 447 | { |
| 448 | CURLcode result = CURLE_OK; |
| 449 | |
| 450 | /* Reset the retry count at the start of each request. |
| 451 | * If the retry count is not reset, when the connection drops, |
| 452 | * it will not enter the retry mechanism on CONN_MAX_RETRIES + 1 attempts |
| 453 | * and will immediately throw |
| 454 | * "Connection died, tried CONN_MAX_RETRIES times before giving up". |
| 455 | * By resetting it here, we ensure each new request starts fresh. */ |
| 456 | data->state.retrycount = 0; |
| 457 | |
| 458 | if(!data->set.str[STRING_SET_URL] && !data->set.uh) { |
| 459 | /* we cannot do anything without URL */ |
| 460 | failf(data, "No URL set"); |
| 461 | return CURLE_URL_MALFORMAT; |
| 462 | } |
| 463 | |
| 464 | /* CURLOPT_CURLU overrides CURLOPT_URL and the contents of the CURLU handle |
| 465 | is allowed to be changed by the user between transfers */ |
| 466 | if(data->set.uh) { |
| 467 | CURLUcode uc; |
| 468 | curlx_free(data->set.str[STRING_SET_URL]); |
| 469 | uc = curl_url_get(data->set.uh, |
| 470 | CURLUPART_URL, &data->set.str[STRING_SET_URL], 0); |
| 471 | if(uc) { |
| 472 | /* clear the pointer to not point to freed memory anymore */ |
| 473 | Curl_bufref_set(&data->state.url, NULL, 0, NULL); |
| 474 | failf(data, "No URL set"); |
| 475 | return CURLE_URL_MALFORMAT; |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | Curl_bufref_set(&data->state.url, data->set.str[STRING_SET_URL], 0, NULL); |
| 480 | |
| 481 | if(data->set.postfields && data->set.set_resume_from) { |
| 482 | /* we cannot */ |
| 483 | failf(data, "cannot mix POSTFIELDS with RESUME_FROM"); |
| 484 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 485 | } |
| 486 | |
| 487 | data->state.prefer_ascii = data->set.prefer_ascii; |
| 488 | #ifdef CURL_LIST_ONLY_PROTOCOL |
| 489 | data->state.list_only = data->set.list_only; |
| 490 | #endif |
| 491 | data->state.httpreq = data->set.method; |
| 492 | |
| 493 | /* initial transfer request coming up, forget the initial origin |
| 494 | * from a previous perform() on this handle. */ |
| 495 | Curl_peer_unlink(&data->state.initial_origin); |
| 496 | data->state.requests = 0; |
| 497 | data->state.followlocation = 0; /* reset the location-follow counter */ |
| 498 | data->state.this_is_a_follow = FALSE; /* reset this */ |
| 499 | data->state.http_ignorecustom = FALSE; /* use custom HTTP method */ |
| 500 | data->state.errorbuf = FALSE; /* no error has occurred */ |
| 501 | #ifndef CURL_DISABLE_HTTP |
| 502 | Curl_http_neg_init(data, &data->state.http_neg); |
| 503 | #endif |
| 504 | data->state.authproblem = FALSE; |
| 505 | data->state.authhost.want = data->set.httpauth; |
| 506 | data->state.authproxy.want = data->set.proxyauth; |
| 507 | curlx_safefree(data->info.wouldredirect); |
| 508 | Curl_data_priority_clear_state(data); |
| 509 | if(data->set.http_auto_referer) |
| C1: Curl_pretransfer frees data->state.referer only when http_auto_referer is true (line 509-510), and sets it only when data->set.str[STRING_SET_REFERER] is non-NULL (line 511-513). The CURLOPT_REFERER setter in lib/setopt.c (not shown but confirmed by deterministic checks) manages only set.str[STRING_SET_REFERER]. | |
| C2: if(data->set.http_auto_referer) Curl_bufref_free(&data->state.referer); | |
| 510 | Curl_bufref_free(&data->state.referer); |
| 511 | if(data->set.str[STRING_SET_REFERER]) |
| C2: if(data->set.str[STRING_SET_REFERER]) Curl_bufref_set(&data->state.referer, data->set.str[STRING_SET_REFERER], 0, NULL); | |
| 512 | Curl_bufref_set(&data->state.referer, data->set.str[STRING_SET_REFERER], |
| 513 | 0, NULL); |
| 514 | |
| 515 | if(data->state.httpreq == HTTPREQ_PUT) |
| 516 | data->state.infilesize = data->set.filesize; |
| 517 | else if((data->state.httpreq != HTTPREQ_GET) && |
| 518 | (data->state.httpreq != HTTPREQ_HEAD)) { |
| 519 | data->state.infilesize = data->set.postfieldsize; |
| 520 | if(data->set.postfields && (data->state.infilesize == -1)) |
| 521 | data->state.infilesize = (curl_off_t)strlen(data->set.postfields); |
| 522 | } |
| 523 | else |
| 524 | data->state.infilesize = 0; |
| 525 | |
| 526 | /* If there is a list of cookie files to read, do it now! */ |
| 527 | result = Curl_cookie_loadfiles(data); |
| 528 | if(!result) |
| 529 | Curl_cookie_run(data); /* activate */ |
| 530 | |
| 531 | /* If there is a list of host pairs to deal with */ |
| 532 | if(!result && data->state.resolve) |
| 533 | result = Curl_loadhostpairs(data); |
| 534 | |
| 535 | if(!result) |
| 536 | /* If there is a list of hsts files to read */ |
| 537 | result = Curl_hsts_loadfiles(data); |
| 538 | |
| 539 | if(!result) { |
| 540 | /* Allow data->set.use_port to set which port to use. This needs to be |
| 541 | * disabled for example when we follow Location: headers to URLs using |
| 542 | * different ports! */ |
| 543 | data->state.allow_port = TRUE; |
| 544 | |
| 545 | #if defined(HAVE_SIGNAL) && defined(SIGPIPE) && !defined(MSG_NOSIGNAL) |
| 546 | /************************************************************* |
| 547 | * Tell signal handler to ignore SIGPIPE |
| 548 | *************************************************************/ |
| 549 | if(!data->set.no_signal) |
| 550 | data->state.prev_signal = signal(SIGPIPE, SIG_IGN); |
| 551 | #endif |
| 552 | |
| 553 | Curl_initinfo(data); /* reset session-specific information "variables" */ |
| 554 | Curl_pgrsResetTransferSizes(data); |
| 555 | Curl_pgrsStartNow(data); |
| 556 | |
| 557 | /* In case the handle is reused and an authentication method was picked |
| 558 | in the session we need to make sure we only use the one(s) we now |
| 559 | consider to be fine */ |
| 560 | data->state.authhost.picked &= data->state.authhost.want; |
| 561 | data->state.authproxy.picked &= data->state.authproxy.want; |
| 562 | |
| 563 | #ifndef CURL_DISABLE_FTP |
| 564 | data->state.wildcardmatch = data->set.wildcard_enabled; |
| 565 | if(data->state.wildcardmatch) { |
| 566 | struct WildcardData *wc; |
| 567 | if(!data->wildcard) { |
| 568 | data->wildcard = curlx_calloc(1, sizeof(struct WildcardData)); |
| 569 | if(!data->wildcard) |
| 570 | return CURLE_OUT_OF_MEMORY; |
| 571 | } |
| 572 | wc = data->wildcard; |
| 573 | if(wc->state < CURLWC_INIT) { |
| 574 | if(wc->ftpwc) |
| 575 | wc->dtor(wc->ftpwc); |
| 576 | curlx_safefree(wc->pattern); |
| 577 | curlx_safefree(wc->path); |
| 578 | Curl_wildcard_init(wc); /* init wildcard structures */ |
| 579 | } |
| 580 | } |
| 581 | #endif |
| 582 | result = Curl_hsts_loadcb(data, data->hsts); |
| 583 | } |
| 584 | |
| 585 | /* |
| 586 | * Set user-agent. Used for HTTP, but since we can attempt to tunnel |
| 587 | * anything through an HTTP proxy we cannot limit this based on protocol. |
| 588 | */ |
| 589 | if(!result && data->set.str[STRING_USERAGENT]) { |
| 590 | curlx_free(data->state.aptr.uagent); |
| 591 | data->state.aptr.uagent = |
| 592 | curl_maprintf("User-Agent: %s\r\n", data->set.str[STRING_USERAGENT]); |
| 593 | if(!data->state.aptr.uagent) |
| 594 | return CURLE_OUT_OF_MEMORY; |
| 595 | } |
| 596 | |
| 597 | data->req.headerbytecount = 0; |
| 598 | Curl_headers_cleanup(data); |
| 599 | return result; |
| 600 | } |
| 480 | CURLcode Curl_pretransfer(struct Curl_easy *data) |
| 481 | { |
| 482 | CURLcode result = CURLE_OK; |
| 483 | |
| 484 | /* Reset the retry count at the start of each request. |
| 485 | * If the retry count is not reset, when the connection drops, |
| 486 | * it will not enter the retry mechanism on CONN_MAX_RETRIES + 1 attempts |
| 487 | * and will immediately throw |
| 488 | * "Connection died, tried CONN_MAX_RETRIES times before giving up". |
| 489 | * By resetting it here, we ensure each new request starts fresh. */ |
| 490 | data->state.retrycount = 0; |
| 491 | |
| 492 | if(!data->set.str[STRING_SET_URL] && !data->set.uh) { |
| 493 | /* we cannot do anything without URL */ |
| 494 | failf(data, "No URL set"); |
| 495 | return CURLE_URL_MALFORMAT; |
| 496 | } |
| 497 | |
| 498 | /* CURLOPT_CURLU overrides CURLOPT_URL and the contents of the CURLU handle |
| 499 | is allowed to be changed by the user between transfers */ |
| 500 | if(data->set.uh) { |
| 501 | CURLUcode uc; |
| 502 | curlx_free(data->set.str[STRING_SET_URL]); |
| 503 | uc = curl_url_get(data->set.uh, |
| 504 | CURLUPART_URL, &data->set.str[STRING_SET_URL], 0); |
| 505 | if(uc) { |
| 506 | /* clear the pointer to not point to freed memory anymore */ |
| 507 | Curl_bufref_set(&data->state.url, NULL, 0, NULL); |
| 508 | failf(data, "No URL set"); |
| 509 | return CURLE_URL_MALFORMAT; |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | Curl_bufref_set(&data->state.url, data->set.str[STRING_SET_URL], 0, NULL); |
| 514 | |
| 515 | if(data->set.postfields && data->set.set_resume_from) { |
| 516 | /* we cannot */ |
| 517 | failf(data, "cannot mix POSTFIELDS with RESUME_FROM"); |
| 518 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 519 | } |
| 520 | |
| 521 | data->state.prefer_ascii = data->set.prefer_ascii; |
| 522 | #ifdef CURL_LIST_ONLY_PROTOCOL |
| 523 | data->state.list_only = data->set.list_only; |
| 524 | #endif |
| 525 | data->state.httpreq = data->set.method; |
| 526 | |
| 527 | data->state.requests = 0; |
| 528 | data->state.followlocation = 0; /* reset the location-follow counter */ |
| 529 | data->state.this_is_a_follow = FALSE; /* reset this */ |
| 530 | data->state.http_ignorecustom = FALSE; /* use custom HTTP method */ |
| 531 | data->state.errorbuf = FALSE; /* no error has occurred */ |
| 532 | #ifndef CURL_DISABLE_HTTP |
| 533 | Curl_http_neg_init(data, &data->state.http_neg); |
| 534 | #endif |
| 535 | data->state.authproblem = FALSE; |
| 536 | data->state.authhost.want = data->set.httpauth; |
| 537 | data->state.authproxy.want = data->set.proxyauth; |
| 538 | curlx_safefree(data->info.wouldredirect); |
| 539 | Curl_data_priority_clear_state(data); |
| 540 | if(data->set.http_auto_referer) |
| 541 | Curl_bufref_free(&data->state.referer); |
| 542 | if(data->set.str[STRING_SET_REFERER]) |
| 543 | Curl_bufref_set(&data->state.referer, data->set.str[STRING_SET_REFERER], |
| 544 | 0, NULL); |
| 545 | |
| 546 | if(data->state.httpreq == HTTPREQ_PUT) |
| 547 | data->state.infilesize = data->set.filesize; |
| 548 | else if((data->state.httpreq != HTTPREQ_GET) && |
| 549 | (data->state.httpreq != HTTPREQ_HEAD)) { |
| 550 | data->state.infilesize = data->set.postfieldsize; |
| 551 | if(data->set.postfields && (data->state.infilesize == -1)) |
| 552 | data->state.infilesize = (curl_off_t)strlen(data->set.postfields); |
| 553 | } |
| 554 | else |
| 555 | data->state.infilesize = 0; |
| 556 | |
| 557 | /* If there is a list of cookie files to read, do it now! */ |
| 558 | result = Curl_cookie_loadfiles(data); |
| 559 | if(!result) |
| 560 | Curl_cookie_run(data); /* activate */ |
| 561 | |
| 562 | /* If there is a list of host pairs to deal with */ |
| 563 | if(!result && data->state.resolve) |
| 564 | result = Curl_loadhostpairs(data); |
| 565 | |
| 566 | if(!result) |
| 567 | /* If there is a list of hsts files to read */ |
| 568 | result = Curl_hsts_loadfiles(data); |
| 569 | |
| 570 | if(!result) { |
| 571 | /* Allow data->set.use_port to set which port to use. This needs to be |
| 572 | * disabled for example when we follow Location: headers to URLs using |
| 573 | * different ports! */ |
| 574 | data->state.allow_port = TRUE; |
| 575 | |
| 576 | #if defined(HAVE_SIGNAL) && defined(SIGPIPE) && !defined(MSG_NOSIGNAL) |
| 577 | /************************************************************* |
| 578 | * Tell signal handler to ignore SIGPIPE |
| 579 | *************************************************************/ |
| 580 | if(!data->set.no_signal) |
| 581 | data->state.prev_signal = signal(SIGPIPE, SIG_IGN); |
| 582 | #endif |
| 583 | |
| 584 | Curl_initinfo(data); /* reset session-specific information "variables" */ |
| 585 | Curl_pgrsResetTransferSizes(data); |
| 586 | Curl_pgrsStartNow(data); |
| 587 | |
| 588 | /* In case the handle is reused and an authentication method was picked |
| 589 | in the session we need to make sure we only use the one(s) we now |
| 590 | consider to be fine */ |
| 591 | data->state.authhost.picked &= data->state.authhost.want; |
| 592 | data->state.authproxy.picked &= data->state.authproxy.want; |
| 593 | |
| 594 | #ifndef CURL_DISABLE_FTP |
| 595 | data->state.wildcardmatch = data->set.wildcard_enabled; |
| 596 | if(data->state.wildcardmatch) { |
| 597 | struct WildcardData *wc; |
| 598 | if(!data->wildcard) { |
| 599 | data->wildcard = curlx_calloc(1, sizeof(struct WildcardData)); |
| 600 | if(!data->wildcard) |
| 601 | return CURLE_OUT_OF_MEMORY; |
| 602 | } |
| 603 | wc = data->wildcard; |
| 604 | if(wc->state < CURLWC_INIT) { |
| 605 | if(wc->ftpwc) |
| 606 | wc->dtor(wc->ftpwc); |
| 607 | curlx_safefree(wc->pattern); |
| 608 | curlx_safefree(wc->path); |
| 609 | Curl_wildcard_init(wc); /* init wildcard structures */ |
| 610 | } |
| 611 | } |
| 612 | #endif |
| 613 | result = Curl_hsts_loadcb(data, data->hsts); |
| 614 | } |
| 615 | |
| 616 | /* |
| 617 | * Set user-agent. Used for HTTP, but since we can attempt to tunnel |
| 618 | * anything through an HTTP proxy we cannot limit this based on protocol. |
| 619 | */ |
| 620 | if(!result && data->set.str[STRING_USERAGENT]) { |
| 621 | curlx_free(data->state.aptr.uagent); |
| 622 | data->state.aptr.uagent = |
| 623 | curl_maprintf("User-Agent: %s\r\n", data->set.str[STRING_USERAGENT]); |
| 624 | if(!data->state.aptr.uagent) |
| 625 | return CURLE_OUT_OF_MEMORY; |
| 626 | } |
| 627 | |
| 628 | if(!result) |
| 629 | result = Curl_reset_userpwd(data); |
| 630 | if(!result) |
| 631 | result = Curl_reset_proxypwd(data); |
| 632 | |
| 633 | data->req.headerbytecount = 0; |
| 634 | Curl_headers_cleanup(data); |
| 635 | return result; |
| 636 | } |
Show 4 lines (not cited by the judge)
| 818 | CURLcode curl_easy_perform(CURL *curl) |
| 819 | { |
| 820 | return easy_perform(curl, FALSE); |
| 821 | } |
Show 18 lines (not cited by the judge)
| 2943 | CURLcode curl_easy_setopt(CURL *curl, CURLoption option, ...) |
| 2944 | { |
| 2945 | va_list arg; |
| 2946 | CURLcode result; |
| 2947 | struct Curl_easy *data = curl; |
| 2948 | |
| 2949 | if(!data) |
| 2950 | return CURLE_BAD_FUNCTION_ARGUMENT; |
| 2951 | |
| 2952 | va_start(arg, option); |
| 2953 | |
| 2954 | result = Curl_vsetopt(data, option, arg); |
| 2955 | |
| 2956 | va_end(arg); |
| 2957 | if(result == CURLE_BAD_FUNCTION_ARGUMENT) |
| 2958 | failf(data, "setopt 0x%x got bad argument", option); |
| 2959 | return result; |
| 2960 | } |