✓ProveIt
Examples / Report #3294999 Saved analysis

CVE-2025-9086: Out of bounds read for cookie path

curl · 2025-08-1116.5s analysis · $0.025 in model calls

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

Needs a policy review

The code does what the report says, but curl's own policy may exclude it. A maintainer decides.

curl policy: NULL dereferences and crashes
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.

  • 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.

1 · Grounding
grounded
no model · 24 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 6279 tokens · $0.0004
3 · Evidence
14 code snippets
real curl source at 89490b16c736
4 · Judgment
Nemotron-3-Ultra-550b-a55b
17718 tokens · 12.6s · $0.0243
5 · Receipts
8 of 8 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 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

C1flaw supported

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.

C2location supported

Function replace_existing in lib/cookie.c around line 977

Read the reasoning

The function exists at the reported location with the quoted code.

C3trigger supported

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.

C4impact supported

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.

C5reproduction supported

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)

Full report on HackerOne ↗

We are tracking this issue with the public ID `BIGSLEEP-437903454`. Please use this identifier for reference in any future communication. ## **Vulnerability Details** In the cookie support found in `cookie.c`, there's an out-of-bounds string comparison that results from a crafted sequence of cookie operations. When saving a new cookie, we check if it should replace an existing cookie for the same domain. We can see in the snippet below that if the previous cookie path is an empty string `"",` we'll skip over the NUL-terminator at \[0\] and read out-of-bounds when looking for a `/` in the previous cookie path. ```c static int replace_existing(struct Curl_easy *data, struct Cookie *co, struct CookieInfo *ci, bool secure, bool *replacep) { bool replace_old = FALSE; struct Curl_llist_node *replace_n = NULL; struct Curl_llist_node *n; size_t myhash = cookiehash(co->domain); for(n = Curl_llist_head(&ci->cookielist[myhash]); n; n = Curl_node_next(n)) { struct Cookie *clist = Curl_node_elem(n); if(!strcmp(clist->name, co->name)) { /* the names are identical */ bool matching_domains = FALSE; if(clist->domain && co->domain) { if(curl_strequal(clist->domain, co->domain)) /* The domains are identical */ matching_domains = TRUE; } else if(!clist->domain && !co->domain) matching_domains = TRUE; if(matching_domains && /* the domains were identical */ clist->spath && co->spath && /* both have paths */ clist->secure && !co->secure && !secure) { size_t cllen; const char *sep; /* * A non-secure cookie may not overlay an existing secure cookie. * For an existing cookie "a" with path "/login", refuse a new * cookie "a" with for example path "/login/en", while the path * "/loginhelper" is ok. */ sep = strchr(clist->spath + 1, '/'); // ***0*** if(sep) cllen = sep - clist->spath; else cllen = strlen(clist->spath); if(curl_strnequal(clist->spath, co->spath, cllen)) { infof(data, "cookie '%s' for domain '%s' dropped, would " "overlay an existing cookie", co->name, co->domain); return CERR_BAD_SECURE; } // ... ``` If we look at the cookie …

Source code

lib/cookie.clines 939–1041 · definition of replace_existing
C2: replace_existing function definition starts at line 939; vulnerable strchr at line 977
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
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
lib/cookie.clines 282–305 · definition of sanitize_cookie_path
C1: sanitize_cookie_path returns empty string when input is '/' (len becomes 0 at line 302)
C4: Curl_memdup0(cookie_path, 0) allocates 1 byte (NUL only) when len==0
lib/llist.clines 219–224 · definition of Curl_llist_head
Show 6 lines (not cited by the judge)
219struct 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}
lib/strdup.clines 112–123 · definition of Curl_memdup0
Show 12 lines (not cited by the judge)
112void *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}
lib/llist.clines 246–251 · definition of Curl_node_elem
Show 6 lines (not cited by the judge)
246void *Curl_node_elem(struct Curl_llist_node *n)
247{
248 DEBUGASSERT(n);
249 DEBUGASSERT(n->_init == NODEINIT);
250 return n->_ptr;
251}
lib/llist.clines 255–260 · definition of Curl_node_next
Show 6 lines (not cited by the judge)
255struct 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}
lib/cookie.clines 267–277 · definition of cookiehash
Show 11 lines (not cited by the judge)
267static 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}
lib/strequal.clines 78–86 · definition of curl_strequal
Show 9 lines (not cited by the judge)
78int 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}
lib/strequal.clines 89–97 · definition of curl_strnequal
Show 9 lines (not cited by the judge)
89int 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}
lib/curlx/strparse.hlines 48–49 · definition of curlx_str; definition of curlx_strlen
Show 2 lines (not cited by the judge)
48#define curlx_str(x) ((x)->str)
49#define curlx_strlen(x) ((x)->len)
lib/curlx/strparse.clines 241–245 · definition of curlx_str_casecompare
Show 5 lines (not cited by the judge)
241int 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}
lib/curl_trc.hlines 117–119 · definition of infof
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)
lib/cookie.clines 475–635 · definition of parse_cookie_header (truncated)
Show 161 lines (not cited by the judge)
475static int
476parse_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;
lib/cookie.clines 344–353 · definition of strstore
Show 10 lines (not cited by the judge)
344static 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}