CVE-2026-92392: HTTP/2 upload Use-After-Free
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
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.
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
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.
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.
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.
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.
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)
Source code
| 102 | CURLcode 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 | } |
| 2062 | static 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 | |
| 2188 | out: |
| 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 | } |
| 691 | static 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 | } |
Show 4 lines (not cited by the judge)
| 925 | void 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 | } |
Show 10 lines (not cited by the judge)
| 481 | CURLcode 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 | } |