HexBrain

curl · Report #4033108

Retry-independent time-condition HTTP/2 UAF

Duplicatestatus
6upvotes
14 Oct 2026disclosed

How it was found

Relationship to the original report

This is a supplemental trigger for HackerOne report #4030333, not a claim of an unrelated vulnerability. It reaches the same stale Curl_easy * condition through normal time-condition handling and shows that a fix limited to the original retry path is incomplete.

Report generated by Daybreak Blue AI.

Summary

A malicious HTTP/2 server can trigger the reported heap use-after-free while curl uploads a request configured with CURLOPT_TIMECONDITION (curl -z). The server stalls the upload using normal HTTP/2 flow control, then returns an ordinary 200 response with a Last-Modified value that fails the client's time condition.

curl locally simulates a 304 response and marks the request done. This path does not abort the buffered upload before request teardown. multi_done() removes the original HTTP/2 stream through CF_CTRL_DATA_DONE, then calls non-aborted Curl_req_done(), which flushes the surviving upload bytes. Because the HTTP/2 filter has already forgotten the original stream, it parses those body bytes as a new request and gives nghttp2 the same Curl_easy * as the new stream's user data.

The easy handle is subsequently freed while the new stream still holds that pointer. A later frame for the stream dereferences it. The attached server uses a single valid WINDOW_UPDATE(stream, 1) frame; no response HPACK block is needed at the point of dereference.

Compared with the original trigger, this path requires none of the following:

  • RST_STREAM;
  • HTTP_1_1_REQUIRED or REFUSED_STREAM;
  • curl's retry or HTTP/1.1 downgrade logic;
  • a second connection or URL; or
  • --parallel / -Z.

The complete reproducer is one upload, one URL, and one HTTP/2 connection.

Security impact

The demonstrated result for this path is a remotely triggered heap use-after-free of struct Curl_easy (CWE-416). AddressSanitizer reports a four-byte read after Curl_close() freed the object.

I have not yet demonstrated code execution through this new time-condition path. Report #4030333 contains a targeted RCE chain for the same underlying stale-object class, which establishes that the bug can be exploitable, but that chain's heap grooming, address discovery, timing, and connection-lifetime work cannot be assumed to transfer unchanged. The supportable statement for this supplemental report is therefore:

This simpler route reaches the same UAF primitive. RCE is known for the original trigger, but RCE through this route remains unproven.

Affected versions

The attached reproducer was dynamically confirmed against an AddressSanitizer build of curl 8.22.0 using the nghttp2 HTTP/2 backend.

This route has the same underlying version boundary as report #4030333. Source history places the completing regression in commit cd9107e0128191b7b5396b9eb81bbd20c2d36755, first released in curl 8.12.0. That change removed the later DATA_DETACH cleanup which previously removed protocol state created after DATA_DONE. Static analysis therefore supports curl 8.12.0 onward where the vulnerable ordering remains and curl uses the native nghttp2 backend.

The PoC payload is intentionally calibrated for the exact curl 8.22.0 command below. Failure of this fixed payload with a different curl version, URL, port length, or added request headers is not evidence that the source is safe; those changes alter the internal request-head length and therefore the body offset that remains queued.

Steps to reproduce

1. Build curl 8.22.0 with ASan and HTTP/2

The existing ASan build from report #4030333 can be reused. A representative release build is:

wget https://curl.se/download/curl-8.22.0.tar.gz
tar xf curl-8.22.0.tar.gz
cd curl-8.22.0

CC=clang \
CFLAGS='-O1 -g -fsanitize=address -fno-omit-frame-pointer' \
LDFLAGS='-fsanitize=address' \
./configure --with-openssl --with-nghttp2 --disable-shared

make -j"$(nproc)"
./src/curl -V

Confirm that the feature list printed by curl -V contains HTTP2.

2. Start the attached server

From the directory containing server.py:

python3 server.py

The server has no third-party Python dependencies. It creates timecondition-payload.bin next to itself and prints the exact client command. It serves one run and exits.

3. Run the exact client command in another terminal

Replace only /path/to/curl-8.22.0 with the location of the ASan build:

ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1 \
  /path/to/curl-8.22.0/src/curl \
  -q --noproxy '*' --http2 -k -v \
  -z 'Tue, 01 Jan 2030 00:00:00 GMT' \
  -T ./timecondition-payload.bin \
  https://127.0.0.1:19278/x

-q prevents a local curl configuration file from adding headers and changing the calibrated offset. --noproxy '*' keeps the loopback request off configured proxies. --http2 makes the required protocol explicit. -k is needed only because the PoC embeds a throwaway self-signed certificate.

Expected result

The server reports:

[server] upload opened on stream 1
[server] sent ordinary 200 + old Last-Modified without END_STREAM
[server] curl opened body-derived stream 3 after DATA_DONE
[server] queued WINDOW_UPDATE(stream=3, increment=1); waiting for cleanup
[server] received GOAWAY: curl reached connection-pool shutdown

curl's verbose log shows the local time-condition completion and unintended request:

< HTTP/2 200
< last-modified: Wed, 01 Jan 2020 00:00:00 GMT
* The requested document is not new enough
* Simulate an HTTP 304 response
* [HTTP/2] [3] OPENED stream for https://127.0.0.1:19278/x
* [HTTP/2] [3] [:method: T]
* [HTTP/2] [3] [:path: /x]

The tested ASan client then aborts with status 134:

ERROR: AddressSanitizer: heap-use-after-free
READ of size 4
    #0 on_stream_frame        lib/http2.c:946
    #1 on_frame_recv          lib/http2.c:1226
    #2 session_on_stream_window_update_received
    #3 nghttp2_session_mem_recv
    #4 h2_process_pending_input
    #5 h2_progress_ingress
    #6 cf_h2_shutdown

freed by thread T0 here:
    #0 free
    #1 Curl_close             lib/url.c:303
    #2 curl_easy_cleanup

Root cause

1. The server controls the time-condition result

http_header_l() parses Last-Modified into data->req.timeofdoc when a time condition is configured:

const char *v = (!k->http_bodyless &&
                 (data->set.timecondition || data->set.get_filetime)) ?
  HD_VAL(hd, hdlen, "Last-Modified:") : NULL;

The PoC sends a 2020 Last-Modified value while the client requests -z 'Jan 1 2030'.

2. curl marks the request done without aborting its queued upload

In http_firstwrite(), a failed time condition takes this successful early-completion path:

if(data->set.timecondition && !data->state.range &&
   !Curl_meets_timecondition(data, k->timeofdoc)) {
  k->done = TRUE;
  data->info.httpcode = 304;
  streamclose(conn);
  return CURLE_OK;
}

For a multiplexed HTTP/2 connection, streamclose() deliberately does not close the connection. It also does not abort req.sendbuf.

sendrecv_dl() then exits immediately because data->req.done is true:

result = Curl_xfer_write_resp(data, buf, blen, is_eos);
if(result || data->req.done)
  goto out;

That jump bypasses the later Curl_req_abort_sending(data) block.

3. Teardown destroys protocol state before flushing request state

Normal completion reaches multi_done(data, result, FALSE). Its relevant ordering is:

Curl_conn_ev_data_done(data, premature)
    -> HTTP/2 DATA_DONE removes the original stream

Curl_req_done(&data->req, data, premature=false)
    -> req_flush() sends the queued upload bytes

This violates a useful teardown invariant: after the protocol filter receives DATA_DONE, later generic request cleanup must not be able to create new protocol state for that transfer.

4. The flush creates a new stream holding the soon-to-be-freed handle

With the original HTTP/2 stream removed, req_flush() re-enters h2_submit(). The queued body begins with the PoC's planted bytes:

T /x HTTP/1.1\n\n

Curl_h1_req_parse_read() accepts them as a request head, and nghttp2_submit_request() stores data as the new stream's user-data pointer:

stream_id = nghttp2_submit_request(ctx->h2, &pri_spec, nva, nheader,
                                   &data_prd, data);

There is no subsequent DATA_DONE/DETACH cleanup for this newly created stream. curl detaches the transfer, leaves the HTTP/2 connection in its pool, and later frees the easy handle.

5. One WINDOW_UPDATE dereferences the stale pointer

During connection-pool shutdown, nghttp2 dispatches the queued frame. on_frame_recv() retrieves the stale stream user data and passes it to on_stream_frame():

data_s = nghttp2_session_get_stream_user_data(session, stream_id);
return on_stream_frame(cf, data_s, frame) ? NGHTTP2_ERR_CALLBACK_FAILURE : 0;

on_stream_frame() dereferences the object before switching on the frame type:

struct h2_stream_ctx *stream = H2_STREAM_CTX(ctx, data);

H2_STREAM_CTX reads data->mid, producing the ASan four-byte read from the freed Curl_easy allocation.

Why the retry-only patch is incomplete

The patch proposed with report #4030333 discards queued upload bytes when curl chooses FOLLOW_RETRY. This time-condition path never enters FOLLOW_RETRY, so that condition cannot protect it.

I tested the PoC against an exact curl 8.22.0 ASan build containing the proposed retry-only abort. The same stream resurrection and on_stream_frame() heap use-after-free occurred, with exit status 134.

The fix needs to protect the global lifetime invariant, not only one retry branch. Possible designs include aborting unfinished request sending before DATA_DONE on every applicable early-completion path, changing teardown ordering so the later flush cannot recreate protocol state, or restoring an equivalent final detach cleanup. The precise change should be regression-tested across HTTP/1, HTTP/2, and HTTP/3 request completion semantics.

Reproducer constraints

The minimal PoC intentionally trades generality for clarity:

  • it targets curl 8.22.0 with the nghttp2 backend;
  • the victim opts into a time condition with CURLOPT_TIMECONDITION / curl -z;
  • enough upload data must remain buffered when the response arrives;
  • body bytes at the reachable queued offset must parse as a request head; and
  • the old multiplexed connection must remain alive until the stale stream receives a frame.

The generated 131,000-byte fixture places T /x HTTP/1.1\n\n at byte offset 130,920, which is 2 * 65,536 - 152 for the exact request above. This is deterministic test calibration, not a claim that arbitrary upload contents always trigger the bug. A real exploit would need knowledge or control of suitable bytes, or an earlier observation phase like the original report's targeted chain.

The PoC is crash-only. It contains no heap grooming, address leak, allocator manipulation, or code-execution payload.

Impact

Summary:

Currently, just a crash. RCE may be possible given the sibling report.

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