CVE-2025-9086: Out of bounds read for cookie path
What the report claims
Out-of-bounds read in cookie path replacement when a secure cookie with empty path is replaced by a non-secure cookie.
Compare with curl's recorded outcome
Known vulnerability
A real vulnerability: CVE-2025-9086 Out of bounds read for cookie path
Shown for comparison after analysis. The pipeline did not see this label.
ProveIt's finding
The code does what the report says, but curl's own policy may exclude it. A maintainer decides.
curl policy: NULL dereferences and crashesSee the withdrawn model reasoning
The following reasoning failed the citation check and does not support the final finding.
- The code confirms a real out-of-bounds read in replace_existing when an existing secure cookie has an empty spath (from path='/') and a non-secure cookie replaces it.
- However, per curl's project policy under 'NULL dereferences and crashes', crashes or UB triggered by a malicious server with no evidence of data leak, code execution, or privilege escalation are not considered security issues.
- The reporter themselves assessed the impact as negligible with no avenue to leak out-of-bounds memory content.
- The flaw is a real bug but does not meet curl's threshold for a security vulnerability.
How this analysis ran 5 claims · 8 citations verified
Evidence check: the behavior is real; the project's policy section 'NULL dereferences and crashes' may exclude it
Checked against curl tested commit 89490b16c736 (89490b16c736). Test 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 reproduction. The code confirms an out-of-bounds read in replace_existing when a secure cookie with path='/' (sanitized to empty string) is replaced by a non-secure cookie. However, per curl's security policy, crashes or undefined behavior triggered by a malicious server — without evidence of data leakage, code execution, or privilege escalation — are not treated as security vulnerabilities. We'll fix the bug as a correctness issue, but it does not qualify for a security advisory.
Inspect all claims and source code 5 claims · 14 code excerpts
This model judgment was withdrawn. The claim assessments below are preserved for inspection and may be wrong.
Report claims
Out-of-bounds string comparison in replace_existing function when previous cookie path is empty
Read the reasoning
The code confirms that sanitize_cookie_path('/') returns an empty string (1-byte allocation with only NUL). When replace_existing processes a secure cookie with this empty spath against a non-secure replacement, line 977 executes strchr(clist->spath + 1, '/'), which reads starting at the byte after the NUL terminator — an out-of-bounds read.
Function replace_existing in lib/cookie.c around line 977
Read the reasoning
The function exists at the reported location with the quoted code.
Replace a secure cookie with empty path by a non-secure cookie on same domain
Read the reasoning
The trigger condition matches the report: a secure cookie (set via HTTPS) with empty path, then a non-secure cookie (set via HTTP) on the same domain.
Heap buffer overflow due to reading past NUL terminator in strchr
Read the reasoning
ASan confirms a heap-buffer-overflow read at the strchr call. The impact is an out-of-bounds read; the reporter notes no apparent avenue to leak the read data.
Use HTTPS server setting Secure cookie with path '/' then HTTP redirect to overwrite with non-secure cookie
Read the reasoning
The reproduction steps match the code path: HTTPS server sets Secure cookie with path='/', redirect to HTTP sets non-secure cookie, triggering the vulnerable comparison against localhost avoided by using hostname not localhost.
Read the original report (excerpt)
Source code
| 939 | static int |
| C2: replace_existing function definition starts at line 939; vulnerable strchr at line 977 | |
| 940 | replace_existing(struct Curl_easy *data, |
| 941 | struct Cookie *co, |
| 942 | struct CookieInfo *ci, |
| 943 | bool secure, |
| 944 | bool *replacep) |
| 945 | { |
| 946 | bool replace_old = FALSE; |
| 947 | struct Curl_llist_node *replace_n = NULL; |
| 948 | struct Curl_llist_node *n; |
| 949 | size_t myhash = cookiehash(co->domain); |
| 950 | for(n = Curl_llist_head(&ci->cookielist[myhash]); n; n = Curl_node_next(n)) { |
| 951 | struct Cookie *clist = Curl_node_elem(n); |
| 952 | if(!strcmp(clist->name, co->name)) { |
| 953 | /* the names are identical */ |
| 954 | bool matching_domains = FALSE; |
| 955 | |
| 956 | if(clist->domain && co->domain) { |
| 957 | if(curl_strequal(clist->domain, co->domain)) |
| 958 | /* The domains are identical */ |
| 959 | matching_domains = TRUE; |
| 960 | } |
| 961 | else if(!clist->domain && !co->domain) |
| 962 | matching_domains = TRUE; |
| 963 | |
| 964 | if(matching_domains && /* the domains were identical */ |
| C1: Condition at lines 964-966 allows reaching the vulnerable code when existing cookie is secure with empty path and new cookie is non-secure | |
| C3: Condition requires clist->secure && !co->secure && !secure — exactly the HTTPS-then-HTTP redirect scenario | |
| C5: Reproduction requires secure cookie with path '/' (sanitized to empty) then non-secure overwrite | |
| 965 | clist->spath && co->spath && /* both have paths */ |
| 966 | clist->secure && !co->secure && !secure) { |
| 967 | size_t cllen; |
| 968 | const char *sep; |
| 969 | |
| 970 | /* |
| 971 | * A non-secure cookie may not overlay an existing secure cookie. |
| 972 | * For an existing cookie "a" with path "/login", refuse a new |
| 973 | * cookie "a" with for example path "/login/en", while the path |
| 974 | * "/loginhelper" is ok. |
| 975 | */ |
| 976 | |
| 977 | sep = strchr(clist->spath + 1, '/'); |
| C1: strchr(clist->spath + 1, '/') reads past NUL when spath is empty string | |
| C4: strchr(clist->spath + 1, '/') reads past the NUL terminator of an empty spath | |
| 978 | |
| 979 | if(sep) |
| 980 | cllen = sep - clist->spath; |
| 981 | else |
| 982 | cllen = strlen(clist->spath); |
| 983 | |
| 984 | if(curl_strnequal(clist->spath, co->spath, cllen)) { |
| 985 | infof(data, "cookie '%s' for domain '%s' dropped, would " |
| 986 | "overlay an existing cookie", co->name, co->domain); |
| 987 | return CERR_BAD_SECURE; |
| 988 | } |
| 989 | } |
| 990 | } |
| 991 | |
| 992 | if(!replace_n && !strcmp(clist->name, co->name)) { |
| 993 | /* the names are identical */ |
| 994 | |
| 995 | if(clist->domain && co->domain) { |
| 996 | if(curl_strequal(clist->domain, co->domain) && |
| 997 | (clist->tailmatch == co->tailmatch)) |
| 998 | /* The domains are identical */ |
| 999 | replace_old = TRUE; |
| 1000 | } |
| 1001 | else if(!clist->domain && !co->domain) |
| 1002 | replace_old = TRUE; |
| 1003 | |
| 1004 | if(replace_old) { |
| 1005 | /* the domains were identical */ |
| 1006 | |
| 1007 | if(clist->spath && co->spath && |
| 1008 | !curl_strequal(clist->spath, co->spath)) |
| 1009 | replace_old = FALSE; |
| 1010 | else if(!clist->spath != !co->spath) |
| 1011 | replace_old = FALSE; |
| 1012 | } |
| 1013 | |
| 1014 | if(replace_old && !co->livecookie && clist->livecookie) { |
| 1015 | /* |
| 1016 | * Both cookies matched fine, except that the already present cookie |
| 1017 | * is "live", which means it was set from a header, while the new one |
| 1018 | * was read from a file and thus is not "live". "live" cookies are |
| 1019 | * preferred so the new cookie is freed. |
| 1020 | */ |
| 1021 | return CERR_LIVE_WINS; |
| 1022 | } |
| 1023 | if(replace_old) |
| 1024 | replace_n = n; |
| 1025 | } |
| 1026 | } |
| 1027 | if(replace_n) { |
| 1028 | struct Cookie *repl = Curl_node_elem(replace_n); |
| 1029 | |
| 1030 | /* when replacing, creationtime is kept from old */ |
| 1031 | co->creationtime = repl->creationtime; |
| 1032 | |
| 1033 | /* unlink the old */ |
| 1034 | Curl_node_remove(replace_n); |
| 1035 | |
| 1036 | /* free the old cookie */ |
| 1037 | freecookie(repl); |
| 1038 | } |
| 1039 | *replacep = replace_old; |
| 1040 | return CERR_OK; |
| 1041 | } |
| 282 | static char *sanitize_cookie_path(const char *cookie_path) |
| C1: sanitize_cookie_path returns empty string when input is '/' (len becomes 0 at line 302) | |
| 283 | { |
| 284 | size_t len = strlen(cookie_path); |
| 285 | |
| 286 | /* some sites send path attribute within '"'. */ |
| 287 | if(cookie_path[0] == '\"') { |
| 288 | cookie_path++; |
| 289 | len--; |
| 290 | } |
| 291 | if(len && (cookie_path[len - 1] == '\"')) |
| 292 | len--; |
| 293 | |
| 294 | /* RFC6265 5.2.4 The Path Attribute */ |
| 295 | if(cookie_path[0] != '/') |
| 296 | /* Let cookie-path be the default-path. */ |
| 297 | return strdup("/"); |
| 298 | |
| 299 | /* remove trailing slash */ |
| 300 | /* convert /hoge/ to /hoge */ |
| 301 | if(len && cookie_path[len - 1] == '/') |
| 302 | len--; |
| 303 | |
| 304 | return Curl_memdup0(cookie_path, len); |
| C4: Curl_memdup0(cookie_path, 0) allocates 1 byte (NUL only) when len==0 | |
| 305 | } |
Show 6 lines (not cited by the judge)
| 219 | struct Curl_llist_node *Curl_llist_head(struct Curl_llist *list) |
| 220 | { |
| 221 | DEBUGASSERT(list); |
| 222 | DEBUGASSERT(list->_init == LLISTINIT); |
| 223 | return VERIFYNODE(list->_head); |
| 224 | } |
Show 12 lines (not cited by the judge)
| 112 | void *Curl_memdup0(const char *src, size_t length) |
| 113 | { |
| 114 | char *buf = malloc(length + 1); |
| 115 | if(!buf) |
| 116 | return NULL; |
| 117 | if(length) { |
| 118 | DEBUGASSERT(src); /* must never be NULL */ |
| 119 | memcpy(buf, src, length); |
| 120 | } |
| 121 | buf[length] = 0; |
| 122 | return buf; |
| 123 | } |
Show 6 lines (not cited by the judge)
| 246 | void *Curl_node_elem(struct Curl_llist_node *n) |
| 247 | { |
| 248 | DEBUGASSERT(n); |
| 249 | DEBUGASSERT(n->_init == NODEINIT); |
| 250 | return n->_ptr; |
| 251 | } |
Show 6 lines (not cited by the judge)
| 255 | struct Curl_llist_node *Curl_node_next(struct Curl_llist_node *n) |
| 256 | { |
| 257 | DEBUGASSERT(n); |
| 258 | DEBUGASSERT(n->_init == NODEINIT); |
| 259 | return VERIFYNODE(n->_next); |
| 260 | } |
Show 11 lines (not cited by the judge)
| 267 | static size_t cookiehash(const char * const domain) |
| 268 | { |
| 269 | const char *top; |
| 270 | size_t len; |
| 271 | |
| 272 | if(!domain || Curl_host_is_ipnum(domain)) |
| 273 | return 0; |
| 274 | |
| 275 | top = get_top_domain(domain, &len); |
| 276 | return cookie_hash_domain(top, len); |
| 277 | } |
Show 9 lines (not cited by the judge)
| 78 | int curl_strequal(const char *first, const char *second) |
| 79 | { |
| 80 | if(first && second) |
| 81 | /* both pointers point to something then compare them */ |
| 82 | return casecompare(first, second); |
| 83 | |
| 84 | /* if both pointers are NULL then treat them as equal */ |
| 85 | return NULL == first && NULL == second; |
| 86 | } |
Show 9 lines (not cited by the judge)
| 89 | int curl_strnequal(const char *first, const char *second, size_t max) |
| 90 | { |
| 91 | if(first && second) |
| 92 | /* both pointers point to something then compare them */ |
| 93 | return ncasecompare(first, second, max); |
| 94 | |
| 95 | /* if both pointers are NULL then treat them as equal if max is non-zero */ |
| 96 | return NULL == first && NULL == second && max; |
| 97 | } |
Show 2 lines (not cited by the judge)
| 48 | #define curlx_str(x) ((x)->str) |
| 49 | #define curlx_strlen(x) ((x)->len) |
Show 5 lines (not cited by the judge)
| 241 | int curlx_str_casecompare(struct Curl_str *str, const char *check) |
| 242 | { |
| 243 | size_t clen = check ? strlen(check) : 0; |
| 244 | return ((str->len == clen) && curl_strnequal(str->str, check, clen)); |
| 245 | } |
Show 3 lines (not cited by the judge)
| 117 | #define infof(data, ...) \ |
| 118 | do { if(Curl_trc_is_verbose(data)) \ |
| 119 | Curl_infof(data, __VA_ARGS__); } while(0) |
Show 161 lines (not cited by the judge)
| 475 | static int |
| 476 | parse_cookie_header(struct Curl_easy *data, |
| 477 | struct Cookie *co, |
| 478 | struct CookieInfo *ci, |
| 479 | const char *ptr, |
| 480 | const char *domain, /* default domain */ |
| 481 | const char *path, /* full path used when this cookie is |
| 482 | set, used to get default path for |
| 483 | the cookie unless set */ |
| 484 | bool secure) /* TRUE if connection is over secure |
| 485 | origin */ |
| 486 | { |
| 487 | /* This line was read off an HTTP-header */ |
| 488 | time_t now; |
| 489 | size_t linelength = strlen(ptr); |
| 490 | if(linelength > MAX_COOKIE_LINE) |
| 491 | /* discard overly long lines at once */ |
| 492 | return CERR_TOO_LONG; |
| 493 | |
| 494 | now = time(NULL); |
| 495 | do { |
| 496 | struct Curl_str name; |
| 497 | struct Curl_str val; |
| 498 | |
| 499 | /* we have a <name>=<value> pair or a stand-alone word here */ |
| 500 | if(!curlx_str_cspn(&ptr, &name, ";\t\r\n=")) { |
| 501 | bool done = FALSE; |
| 502 | bool sep = FALSE; |
| 503 | curlx_str_trimblanks(&name); |
| 504 | |
| 505 | if(!curlx_str_single(&ptr, '=')) { |
| 506 | sep = TRUE; /* a '=' was used */ |
| 507 | if(!curlx_str_cspn(&ptr, &val, ";\r\n")) { |
| 508 | curlx_str_trimblanks(&val); |
| 509 | |
| 510 | /* Reject cookies with a TAB inside the value */ |
| 511 | if(memchr(curlx_str(&val), '\t', curlx_strlen(&val))) { |
| 512 | infof(data, "cookie contains TAB, dropping"); |
| 513 | return CERR_TAB; |
| 514 | } |
| 515 | } |
| 516 | } |
| 517 | else { |
| 518 | curlx_str_init(&val); |
| 519 | } |
| 520 | |
| 521 | /* |
| 522 | * Check for too long individual name or contents, or too long |
| 523 | * combination of name + contents. Chrome and Firefox support 4095 or |
| 524 | * 4096 bytes combo |
| 525 | */ |
| 526 | if(curlx_strlen(&name) >= (MAX_NAME-1) || |
| 527 | curlx_strlen(&val) >= (MAX_NAME-1) || |
| 528 | ((curlx_strlen(&name) + curlx_strlen(&val)) > MAX_NAME)) { |
| 529 | infof(data, "oversized cookie dropped, name/val %zu + %zu bytes", |
| 530 | curlx_strlen(&name), curlx_strlen(&val)); |
| 531 | return CERR_TOO_BIG; |
| 532 | } |
| 533 | |
| 534 | /* |
| 535 | * Check if we have a reserved prefix set before anything else, as we |
| 536 | * otherwise have to test for the prefix in both the cookie name and |
| 537 | * "the rest". Prefixes must start with '__' and end with a '-', so |
| 538 | * only test for names where that can possibly be true. |
| 539 | */ |
| 540 | if(!strncmp("__Secure-", curlx_str(&name), 9)) |
| 541 | co->prefix_secure = TRUE; |
| 542 | else if(!strncmp("__Host-", curlx_str(&name), 7)) |
| 543 | co->prefix_host = TRUE; |
| 544 | |
| 545 | /* |
| 546 | * Use strstore() below to properly deal with received cookie |
| 547 | * headers that have the same string property set more than once, |
| 548 | * and then we use the last one. |
| 549 | */ |
| 550 | |
| 551 | if(!co->name) { |
| 552 | /* The very first name/value pair is the actual cookie name */ |
| 553 | if(!sep) |
| 554 | /* Bad name/value pair. */ |
| 555 | return CERR_NO_SEP; |
| 556 | |
| 557 | strstore(&co->name, curlx_str(&name), curlx_strlen(&name)); |
| 558 | strstore(&co->value, curlx_str(&val), curlx_strlen(&val)); |
| 559 | done = TRUE; |
| 560 | if(!co->name || !co->value) |
| 561 | return CERR_NO_NAME_VALUE; |
| 562 | |
| 563 | if(invalid_octets(co->value) || invalid_octets(co->name)) { |
| 564 | infof(data, "invalid octets in name/value, cookie dropped"); |
| 565 | return CERR_INVALID_OCTET; |
| 566 | } |
| 567 | } |
| 568 | else if(!curlx_strlen(&val)) { |
| 569 | /* |
| 570 | * this was a "<name>=" with no content, and we must allow |
| 571 | * 'secure' and 'httponly' specified this weirdly |
| 572 | */ |
| 573 | done = TRUE; |
| 574 | /* |
| 575 | * secure cookies are only allowed to be set when the connection is |
| 576 | * using a secure protocol, or when the cookie is being set by |
| 577 | * reading from file |
| 578 | */ |
| 579 | if(curlx_str_casecompare(&name, "secure")) { |
| 580 | if(secure || !ci->running) { |
| 581 | co->secure = TRUE; |
| 582 | } |
| 583 | else { |
| 584 | return CERR_BAD_SECURE; |
| 585 | } |
| 586 | } |
| 587 | else if(curlx_str_casecompare(&name, "httponly")) |
| 588 | co->httponly = TRUE; |
| 589 | else if(sep) |
| 590 | /* there was a '=' so we are not done parsing this field */ |
| 591 | done = FALSE; |
| 592 | } |
| 593 | if(done) |
| 594 | ; |
| 595 | else if(curlx_str_casecompare(&name, "path")) { |
| 596 | strstore(&co->path, curlx_str(&val), curlx_strlen(&val)); |
| 597 | if(!co->path) |
| 598 | return CERR_OUT_OF_MEMORY; |
| 599 | free(co->spath); /* if this is set again */ |
| 600 | co->spath = sanitize_cookie_path(co->path); |
| 601 | if(!co->spath) |
| 602 | return CERR_OUT_OF_MEMORY; |
| 603 | } |
| 604 | else if(curlx_str_casecompare(&name, "domain") && curlx_strlen(&val)) { |
| 605 | bool is_ip; |
| 606 | const char *v = curlx_str(&val); |
| 607 | /* |
| 608 | * Now, we make sure that our host is within the given domain, or |
| 609 | * the given domain is not valid and thus cannot be set. |
| 610 | */ |
| 611 | |
| 612 | if('.' == *v) |
| 613 | curlx_str_nudge(&val, 1); |
| 614 | |
| 615 | #ifndef USE_LIBPSL |
| 616 | /* |
| 617 | * Without PSL we do not know when the incoming cookie is set on a |
| 618 | * TLD or otherwise "protected" suffix. To reduce risk, we require a |
| 619 | * dot OR the exact hostname being "localhost". |
| 620 | */ |
| 621 | if(bad_domain(curlx_str(&val), curlx_strlen(&val))) |
| 622 | domain = ":"; |
| 623 | #endif |
| 624 | |
| 625 | is_ip = Curl_host_is_ipnum(domain ? domain : curlx_str(&val)); |
| 626 | |
| 627 | if(!domain |
| 628 | || (is_ip && !strncmp(curlx_str(&val), domain, |
| 629 | curlx_strlen(&val)) && |
| 630 | (curlx_strlen(&val) == strlen(domain))) |
| 631 | || (!is_ip && cookie_tailmatch(curlx_str(&val), |
| 632 | curlx_strlen(&val), domain))) { |
| 633 | strstore(&co->domain, curlx_str(&val), curlx_strlen(&val)); |
| 634 | if(!co->domain) |
| 635 | return CERR_OUT_OF_MEMORY; |
Show 10 lines (not cited by the judge)
| 344 | static void strstore(char **str, const char *newstr, size_t len) |
| 345 | { |
| 346 | DEBUGASSERT(str); |
| 347 | free(*str); |
| 348 | if(!len) { |
| 349 | len++; |
| 350 | newstr = ""; |
| 351 | } |
| 352 | *str = Curl_memdup0(newstr, len); |
| 353 | } |