curl · Report #4030333
CVE-2026-92392: HTTP/2 upload Use-After-Free
How it was found
Summary:
During a file upload, curl mishandles HTTP/2 RST_STREAM received from the server (with reason HTTP_1_1_REQUIRED or REFUSED_STREAM) leading to a use after free of a struct Curl_easy object. The control flow is a bit complicated, so I've produced an ASCII tree of the control flow to highlight the object lifetime issue in the Appendix below.
Preconditions for this are curl -T <file>, where <file> contains anywhere "<word> <word> <any>\n\n" and exceeds ~135KB. Our PoC exploit also requires -Z/--parallel when PIE/ASLR is enabled, but this is not necessary to trigger the memory corruption, only for exploitation.
As for how the UAF happens, after receiving the reset, multi.c converts the reset into a retry and calls multi_done(). In multi_done(), it calls Curl_conn_ev_data_done() which makes curl forget about the transfer, then it calls Curl_req_done(). Then Curl_req_done() flushes the buffered unsent remainder of the upload body into the connection. Since curl no longer knows about the transfer, it treats those bytes as a new request. If they look like a request line, h2_submit() opens a new stream that stores a pointer to the Curl_easy object. This is the pointer that will be UAFed. After the retry completes, the application frees the Curl_easy object, but the connection still holds the pointer. It is dereferenced when the server sends a frame on that stream which can happen at exit, when the connection pool is destroyed.
Affected version
8.22.0
Steps To Reproduce:
I've attached a malicious server that triggers the bug (evil-server.py)
wget 'https://curl.se/download/curl-8.22.0.tar.gz'
sha1sum curl-8.22.0.tar.gz # should be 284e444b67f676a281906cfc86e9672780555cd4
tar xf curl-8.22.0.tar.gz
mkdir build ; cd build
# build with ASAN enabled
CC=clang CFLAGS="-O2 -g -fsanitize=address"" LDFLAGS="-Wl,-rpath,/opt/curl-release/lib" \
../curl-8.22.0/configure --prefix=/opt/curl-release \
--enable-static --enable-shared --disable-docs --disable-manual \
--with-openssl --with-zlib --with-brotli --with-zstd --with-libidn2 \
--with-nghttp2 --with-libpsl --with-libssh2 --with-gssapi \
--enable-ldap --enable-ldaps --enable-websockets --enable-mqtt --enable-ipfs \
--enable-ntlm --enable-smb --enable-httpsig --enable-httpsrr --enable-ssls-export \
&& make -j"$(nproc)"
# make an example file for uploading that satisfies the conditions for the UAF
cat ../docs/*.md > sample_docs.md
# start the malicious server, see attached evil-server.py
python3 evil-server.py &
# we use --insecure because the server's cert is self signed, not necessary to trigger the bug.
# This should result in an ASAN UAF crash
./src/curl --insecure -T ./sample_docs.md https://127.0.0.1:19170/x
I've also attached a .tar.gz that contains scripts to build a Docker container where you can reproduce the RCE.
It also contains a suggested patch (basically, call Curl_req_abort_sending() when retrying the connection to discard unsent request content).
Appendix
PHASE 1: upload starts
Curl_req_send() request.c:380 request head + first body bytes -> req.sendbuf
└─ req_flush() -> ... -> cf_h2_send() http2.c:2197 no stream yet
└─ h2_submit() http2.c:2062
├─ http2_data_setup() http2.c:361 streams[mid] created
└─ nghttp2_submit_request(..., data) http2.c:2133 stream 1 -> data <- pointer #1 stored
PHASE 2: upload stalls (server can do this by not granting flow-control window)
multistate_performing() -> Curl_sendrecv() multi.c:2029 -> transfer.c:354
└─ sendrecv_ul() -> Curl_req_send_more() transfer.c:340 -> request.c:458
├─ add_from_client() request.c:351 read file -> req.sendbuf (64 KB)
└─ req_flush() -> ... -> cf_h2_send() http2.c:2197
└─ cf_h2_body_send() -> stream->sendbuf
└─ req_body_read_callback() http2.c:1577 window is 0 -> NGHTTP2_ERR_DEFERRED (:1625)
=> stream->sendbuf full, req.sendbuf full, nothing moves <- leftover bytes now parked in req.sendbuf
PHASE 3: server sends HEADERS(200) + RST_STREAM(HTTP_1_1_REQUIRED)
multistate_performing() multi.c:2029
├─ result = Curl_sendrecv() multi.c:2043 -> transfer.c:354
│ └─ sendrecv_dl() transfer.c:220
│ ├─ result = xfer_recv_resp() transfer.c:269
│ │ └─ ... cf_h2_recv() http2.c:1941
│ │ ├─ result = stream_recv() http2.c:1967 nothing yet -> AGAIN
│ │ ├─ h2_progress_ingress() http2.c:1972 -> 1865
│ │ │ └─ h2_process_pending_input() http2.c:487
│ │ │ └─ nghttp2_session_mem_recv() parses RST_STREAM
│ │ │ └─ on_stream_close() http2.c:1298
│ │ │ ├─ stream->closed = TRUE http2.c:1333 error=0xd, reset=TRUE
│ │ │ └─ set_stream_user_data(0) http2.c:1346 stream 1 pointer cleared
│ │ └─ result = stream_recv() http2.c:1976
│ │ └─ result = http2_handle_stream_close() http2.c:1850 -> 1683 = CURLE_HTTP2_STREAM
│ ├─ if(result != CURLE_AGAIN) goto out; transfer.c:272 <- error exit
│ │ ...skipped...
│ │ Curl_req_abort_sending(data) transfer.c:327 never reached -> req.sendbuf NOT emptied
│ └─ out: return result transfer.c:330 = CURLE_HTTP2_STREAM
│
├─ if(result == CURLE_HTTP2_STREAM && Curl_h2_http_1_1_error()) multi.c:2064
│ retry = TRUE; result = CURLE_OK multi.c:2087-2088
├─ follow = FOLLOW_RETRY multi.c:2129
│
│ PHASE 4: wrap-up
├─ multi_done(data, CURLE_OK, premature=FALSE) multi.c:2130
│ ├─ Curl_http_done()
│ ├─ Curl_conn_ev_data_done() multi.c:749 -> cfilters.c:982 --- **for maintainers: should this happen after Curl_req_done?**
│ │ └─ cf_cntrl_all(CF_CTRL_DATA_DONE) cfilters.c:448
│ │ └─ cf_h2_cntrl() http2.c:2642
│ │ └─ http2_data_done() http2.c:416
│ │ ├─ set_stream_user_data(NULL) http2.c:428
│ │ └─ hash_remove(streams, mid) http2.c:447 <- filter forgets this transfer
│ ├─ result = Curl_req_done(aborted=FALSE) multi.c:754 -> request.c:102
│ │ └─ req_flush() request.c:298 req.sendbuf not empty
│ │ └─ req_send_buffer_flush() request.c:243
│ │ └─ xfer_send() request.c:186
│ │ └─ Curl_xfer_send() transfer.c:806
│ │ └─ Curl_conn_send() cfilters.c:1073
│ │ └─ cf_h2_send() http2.c:2197 stream == NULL -> "new request"
│ │ └─ h2_submit() http2.c:2062
│ │ ├─ http2_data_setup() http2.c:361 streams[mid] re-created
│ │ ├─ Curl_h1_req_parse_read() http1.c:262 planted bytes parse as "T /path x"
│ │ └─ stream_id = nghttp2_submit_request(..., data) http2.c:2133 stream 3 -> data <- pointer #2 stored
│ └─ multi_done_locked() multi.c:756 -> 632
│ ├─ Curl_detach_connection() data->conn = NULL, state.done = TRUE
│ └─ Curl_cpool_conn_now_idle() conn back in pool, stream 3 still -> data
└─ result = multi_follow(FOLLOW_RETRY) multi.c:2132 retry on a new HTTP/1.1 connection
PHASE 5: retry finishes, handle freed
del_per_transfer() src/tool_operate.c:240
└─ curl_easy_cleanup() -> Curl_close() url.c:197
└─ free(data) url.c:303 <- stream 3 pointer now dangling
PHASE 6: process exit, connection pool shutdown causes one last read which triggers the UAF
curl_share_cleanup() curl_share.c:370
└─ Curl_cpool_destroy() conncache.c:247
└─ ... Curl_conn_shutdown() cfilters.c:225
└─ cf_h2_shutdown() http2.c:2559
└─ h2_progress_ingress() http2.c:1865
└─ h2_process_pending_input() http2.c:487
└─ nghttp2_session_mem_recv() server's HEADERS for stream 3
└─ on_begin_headers() http2.c:1355
├─ data_s = get_stream_user_data(3) http2.c:1364 returns freed pointer
└─ H2_STREAM_CTX(ctx, data_s) http2.c:1373 reads data_s->mid <- USE AFTER FREE
Pointer #1 is stored in phase 1 and properly cleared in phases 3 and 4. Pointer #2 is stored in phase 4, after the clearing. Nothing clears it again before the free in phase 5.
Thank you for taking the time to read this report.
Impact
Remote code execution on a victim client uploading a text file.