HexBrain

curl · Report #4030333

CVE-2026-92392: HTTP/2 upload Use-After-Free

Validstatus
highseverity
9upvotes
14 Oct 2026disclosed

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.

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