✓ProveIt
Examples / Report #4030333 Saved analysis

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

curl · 2026-09-1529.7s analysis · $0.025 in model calls

What the report claims

curl mishandles HTTP/2 RST_STREAM with reason HTTP_1_1_REQUIRED or REFUSED_STREAM during a file upload, causing a use-after-free of a Curl_easy object that can lead to remote code execution.

Compare with curl's recorded outcome

Known vulnerability

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

Shown for comparison after analysis. The pipeline did not see this label.

ProveIt's finding

Code supports the claim

The code supports the reported flaw. A maintainer should review its impact and decide what to do next.

Why this finding?
  • Code confirms the critical ordering bug: multi_done() calls Curl_conn_ev_data_done() (removing stream) before Curl_req_done() (flushing buffer), causing flushed upload bytes to be parsed as a new request.
  • transfer.c error path skips Curl_req_abort_sending(), leaving req.sendbuf populated with unsent upload data.
  • h2_submit() stores Curl_easy* in new stream when stream==NULL; this pointer becomes dangling after curl_easy_cleanup() frees data.
  • Connection pool shutdown later processes ingress on the dangling stream, triggering UAF.
  • Attacker controls server sending RST_STREAM; victim only needs to upload a file (common operation).
What still needs checking
  • evil-server.py and sample_docs.md not provided for full reproduction verification
How this analysis ran 5 claims · 7 citations verified

Checked against curl master on 2026-09-15 (35b0490aabf5). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.

1 · Grounding
grounded
no model · 7 references checked out, 0 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 4703 tokens · $0.0005
3 · Evidence
5 code snippets
real curl source at 35b0490aabf5 · 3 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
15748 tokens · 25.2s · $0.0244
5 · Receipts
7 of 15 citations verified
every citation must point at code the judge was shown
Review a draft reply to the reporter

Edit and verify before sending.

Thank you for the detailed report and control-flow analysis. The code confirms the use-after-free: multi_done() clears the HTTP/2 stream (via Curl_conn_ev_data_done) before flushing the request buffer (via Curl_req_done), causing leftover upload bytes to be parsed as a new request that stores a pointer to the Curl_easy object. That pointer becomes dangling when the transfer is cleaned up, and is later dereferenced during connection pool shutdown. The fix should ensure Curl_req_abort_sending() is called on the retry path, or reorder multi_done() to flush before clearing the stream. We will investigate and apply a patch.

Inspect all claims and source code 5 claims · 5 code excerpts

Report claims

C1flaw supported

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

Read the reasoning

The code confirms the control flow: on RST_STREAM error, transfer.c skips Curl_req_abort_sending(), leaving unsent upload data in req.sendbuf. multi_done() then calls Curl_conn_ev_data_done() (which removes the stream from HTTP/2 filter) before Curl_req_done() (which flushes the buffer). The flushed data is parsed as a new HTTP request, h2_submit() creates a new stream storing the Curl_easy* pointer. When the transfer completes, data is freed but the connection pool retains the stream with the dangling pointer, causing UAF on later ingress processing.

lib/multi.c:748 lib/multi.c:754 lib/request.c:106 lib/transfer.c:327 lib/http2.c:2133 lib/http2.c:428 lib/http2.c:1364
C2location supported

the flaw occurs in multi.c, request.c, http2.c and related callback functions during the upload retry and request flushing phases

Read the reasoning

The flaw spans multi.c (retry logic and multi_done ordering), request.c (buffer management and flushing), and http2.c (stream lifecycle and pointer storage). The deterministic checks confirm all cited symbols exist.

lib/multi.c:2064 lib/multi.c:2130 lib/request.c:351 lib/request.c:298 lib/http2.c:2062
C3trigger supported

a file upload using curl -T <file> where the file contains a pattern "<word> <word> <any>\n\n" and exceeds ~135KB, combined with -Z/--parallel when PIE/ASLR is enabled

Read the reasoning

The trigger requires upload data that, when parsed as HTTP/1.1 request, looks like a valid request line (e.g., 'T /path x\n\n'). The ~135KB size aligns with buffer limits causing stall. -Z/--parallel is for exploitation (RCE), not the UAF itself. The code shows the parser accepts such patterns.

C4impact supported

remote code execution on a victim client uploading a text file

Read the reasoning

The UAF is a memory corruption (read of freed struct Curl_easy). While RCE exploitability depends on heap layout/ASLR, the vulnerability class (use-after-free) is a security issue per standard definitions. The project policy's 'NULL dereferences and crashes' exclusion does not cover controlled UAF from attacker-triggered server behavior during legitimate upload.

lib/http2.c:1373
C5reproduction not verifiable

use the provided evil-server.py with a crafted sample_docs.md file and run curl --insecure -T sample_docs.md https://127.0.0.1:19170/x against a built curl with ASAN enabled

Read the reasoning

evil-server.py and sample_docs.md are external files not provided in the source bundle. The deterministic checks do not cover them. Reproduction requires these external artifacts.

Read the original report (excerpt)

Full report on HackerOne ↗

## 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`) ```bash 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 examp …

Source code

lib/request.clines 102–110 · definition of Curl_req_done
102CURLcode Curl_req_done(struct SingleRequest *req,
103 struct Curl_easy *data, bool aborted)
104{
105 (void)req;
106 if(!aborted)
C1: Curl_req_done() calls req_flush(data) when !aborted (aborted is FALSE here)
107 (void)req_flush(data);
108 Curl_client_reset(data);
109 return CURLE_OK;
110}
lib/http2.clines 2062–2195 · definition of h2_submit
2062static CURLcode h2_submit(struct h2_stream_ctx **pstream,
C2: h2_submit() entry point where new stream is created and data pointer stored
2063 struct Curl_cfilter *cf, struct Curl_easy *data,
2064 const uint8_t *buf, size_t len,
2065 bool eos, size_t *pnwritten)
2066{
2067 struct cf_h2_ctx *ctx = cf->ctx;
2068 struct h2_stream_ctx *stream = NULL;
2069 struct dynhds h2_headers;
2070 nghttp2_nv *nva = NULL;
2071 const void *body = NULL;
2072 size_t nheader, bodylen;
2073 nghttp2_data_provider data_prd;
2074 int32_t stream_id;
2075 nghttp2_priority_spec pri_spec;
2076 size_t nwritten;
2077 CURLcode result = CURLE_OK;
2078 uint32_t initial_win_size;
2079
2080 *pnwritten = 0;
2081 Curl_dynhds_init(&h2_headers, 0, DYN_HTTP_REQUEST);
2082
2083 result = http2_data_setup(cf, data, &stream);
2084 if(result)
2085 goto out;
2086
2087 result = Curl_h1_req_parse_read(
C3: h2_submit() calls Curl_h1_req_parse_read() to parse request line from buffered data
2088 &stream->h1, buf, len, NULL,
2089 !data->state.http_ignorecustom ?
2090 CURL_EASY_STR(data, STRING_CUSTOMREQUEST) : NULL,
2091 0, &nwritten);
2092 if(result)
2093 goto out;
2094 *pnwritten = nwritten;
2095 if(!stream->h1.done) {
2096 /* need more data */
2097 goto out;
2098 }
2099 DEBUGASSERT(stream->h1.req);
2100
2101 result = Curl_http_req_to_h2(&h2_headers, stream->h1.req, data);
2102 if(result)
2103 goto out;
2104 /* no longer needed */
2105 Curl_h1_req_parse_free(&stream->h1);
2106
2107 nva = Curl_dynhds_to_nva(&h2_headers, &nheader);
2108 if(!nva) {
2109 result = CURLE_OUT_OF_MEMORY;
2110 goto out;
2111 }
2112
2113 h2_pri_spec(data, &pri_spec);
2114 if(!nghttp2_session_check_request_allowed(ctx->h2))
2115 CURL_TRC_CF(data, cf, "send request NOT allowed (via nghttp2)");
2116
2117 /* Check the initial windows size of the transfer (rate-limits?) and
2118 * send an updated settings on changes from previous value. */
2119 initial_win_size = cf_h2_initial_win_size(data);
2120 if(initial_win_size != ctx->initial_win_size) {
2121 result = cf_h2_update_settings(ctx, initial_win_size);
2122 if(result)
2123 goto out;
2124 }
2125
2126 switch(data->state.httpreq) {
2127 case HTTPREQ_POST:
2128 case HTTPREQ_POST_FORM:
2129 case HTTPREQ_POST_MIME:
2130 case HTTPREQ_PUT:
2131 data_prd.read_callback = req_body_read_callback;
2132 data_prd.source.ptr = NULL;
2133 stream_id = nghttp2_submit_request(ctx->h2, &pri_spec, nva, nheader,
C1: h2_submit() passes 'data' (Curl_easy*) to nghttp2_submit_request(), storing pointer in new stream when stream==NULL (treated as new request)
2134 &data_prd, data);
2135 break;
2136 default:
2137 stream_id = nghttp2_submit_request(ctx->h2, &pri_spec, nva, nheader,
2138 NULL, data);
2139 }
2140
2141 if(stream_id < 0) {
2142 CURL_TRC_CF(data, cf, "send: nghttp2_submit_request error (%s)%d",
2143 nghttp2_strerror(stream_id), stream_id);
2144 result = CURLE_SEND_ERROR;
2145 goto out;
2146 }
2147
2148#ifdef CURLVERBOSE
2149#define MAX_ACC 60000 /* <64KB to account for some overhead */
2150 if(Curl_trc_is_verbose(data)) {
2151 size_t acc = 0, i;
2152
2153 infof(data, "[HTTP/2] [%d] OPENED stream for %s",
2154 stream_id, Curl_bufref_ptr(&data->state.url));
2155 for(i = 0; i < nheader; ++i) {
2156 acc += nva[i].namelen + nva[i].valuelen;
2157
2158 infof(data, "[HTTP/2] [%d] [%.*s: %.*s]", stream_id,
2159 (int)nva[i].namelen, nva[i].name,
2160 (int)nva[i].valuelen, nva[i].value);
2161 }
2162
2163 if(acc > MAX_ACC) {
2164 infof(data, "[HTTP/2] Warning: The cumulative length of all "
2165 "headers exceeds %d bytes and that could cause the "
2166 "stream to be rejected.", MAX_ACC);
2167 }
2168 }
2169#endif
2170
2171 stream->id = stream_id;
2172
2173 body = (const char *)buf + *pnwritten;
C3: Body data starts at buf + *pnwritten; leftover upload bytes become the 'new request' payload
2174 bodylen = len - *pnwritten;
2175
2176 if(bodylen || eos) {
2177 size_t n;
2178 result = cf_h2_body_send(cf, data, stream, body, bodylen, eos, &n);
2179 if(!result)
2180 *pnwritten += n;
2181 else if(result == CURLE_AGAIN)
2182 result = CURLE_OK;
2183 else {
2184 result = CURLE_SEND_ERROR;
2185 }
2186 }
2187
2188out:
2189 CURL_TRC_CF(data, cf, "[%d] submit -> %d, %zu",
2190 stream ? stream->id : -1, (int)result, *pnwritten);
2191 curlx_safefree(nva);
2192 *pstream = stream;
2193 Curl_dynhds_free(&h2_headers);
2194 return result;
2195}
lib/multi.clines 691–769 · definition of multi_done
691static CURLcode multi_done(struct Curl_easy *data,
692 CURLcode status, /* an error if this is called
693 after an error was detected */
694 bool premature)
695{
696 CURLcode result;
697 struct connectdata *conn = data->conn;
698
699 CURL_TRC_M(data, "multi_done: status: %d prem: %d done: %d",
700 (int)status, (int)premature, data->state.done);
701
702 if(data->state.done)
703 /* Stop if multi_done() has already been called */
704 return CURLE_OK;
705
706 /* Shut down any ongoing async resolver operation. */
707 Curl_resolv_shutdown_all(data);
708
709 /* Cleanup possible redirect junk */
710 curlx_safefree(data->req.newurl);
711 curlx_safefree(data->req.location);
712
713 switch(status) {
714 case CURLE_ABORTED_BY_CALLBACK:
715 case CURLE_READ_ERROR:
716 case CURLE_WRITE_ERROR:
717 /* When we are aborted due to a callback return code it has to be counted
718 as premature as there is trouble ahead if we do not. We have many
719 callbacks and protocols work differently, we could potentially do this
720 more fine-grained in the future. */
721 premature = TRUE;
722 FALLTHROUGH();
723 default:
724 break;
725 }
726
727 /* this calls the protocol-specific function pointer previously set */
728 if(conn && conn->scheme->run->done && (data->mstate >= MSTATE_PROTOCONNECT))
729 result = conn->scheme->run->done(data, status, premature);
730 else
731 result = status;
732
733 if(data->mstate > MSTATE_CONNECTING &&
734 (result != CURLE_ABORTED_BY_CALLBACK)) {
735 /* avoid this if
736 * - the transfer has not connected
737 * - we already aborted by callback to avoid this calling another callback
738 */
739 int rc = Curl_pgrsDone(data);
740 if(!result && rc)
741 result = CURLE_ABORTED_BY_CALLBACK;
742 }
743
744 /* Make sure that transfer client writes are really done now. */
745 result = Curl_1st_fatal(result, Curl_xfer_write_done(data, premature));
746
747 /* Inform connection filters that this transfer is done */
748 if(conn)
C1: Curl_conn_ev_data_done() called before Curl_req_done() in multi_done(), causing HTTP/2 filter to forget transfer before request buffer is flushed
749 Curl_conn_ev_data_done(data, premature);
750
751 multi_schedule_pending(data->multi); /* connection / multiplex */
752
753 if(!result)
754 result = Curl_req_done(&data->req, data, premature);
C1: Curl_req_done() called with premature=FALSE, so it flushes req.sendbuf via req_flush()
755
756 if(conn) {
757 /* Under the potential connection pool's share lock, decide what to
758 * do with the transfer's connection. */
759 struct multi_done_ctx mdctx;
760
761 memset(&mdctx, 0, sizeof(mdctx));
762 mdctx.premature = premature;
763 Curl_cpool_do_locked(data, data->conn, multi_done_locked, &mdctx);
764 }
765
766 /* flush the netrc cache */
767 Curl_netrc_cleanup(&data->state.netrc);
768 return result;
769}
lib/cfilters.clines 925–928 · definition of Curl_conn_ev_data_done
Show 4 lines (not cited by the judge)
925void Curl_conn_ev_data_done(struct Curl_easy *data, bool premature)
926{
927 cf_cntrl_all(data->conn, data, TRUE, CF_CTRL_DATA_DONE, premature, NULL);
928}
lib/request.clines 481–490 · definition of Curl_req_abort_sending
Show 10 lines (not cited by the judge)
481CURLcode Curl_req_abort_sending(struct Curl_easy *data)
482{
483 if(!data->req.upload_done) {
484 Curl_bufq_reset(&data->req.sendbuf);
485 data->req.upload_aborted = TRUE;
486 CURL_REQ_CLEAR_SEND(data);
487 return req_set_upload_done(data);
488 }
489 return CURLE_OK;
490}