CVE-2026-92392: curl HTTP/2 upload RCE

Eddy Tsalolikhin, JFrog Security Researcher | 15 Oct, 2026

Recently, JFrog Security has been tracking a HIGH-severity curl (CVE-2026-92392), that was disclosed as part of a new release where at least 24 vulnerabilities were patched. This has garnered a lot of attention due to its ability to cause Remote Code Execution.
In this write-up, we will discuss the bug itself, the exploit itself, its mechanics, and the very specific prerequisites required for it to be anything else but a Denial of Service attack.
The bug Description Is as follows: “Client-side remote code execution via a malicious HTTPS server.”

  • Upgrade to curl 8.23.0. It ships the fix. This is the only complete fix.
  • Workaround if you cannot upgrade: force --http1.1 for uploads, or do not upload to untrusted servers.
  • JFrog Xray users are protected: Xray flags curl and libcurl 8.12.0 to 8.22.0 in scanned artifacts and dependencies.

*Figure 1: The main fix Diff*

When curl uploads over HTTP/2, the request body is streamed as DATA frames. If the server’s flow-control window closes mid-upload, curl parks the unsent body bytes in the easy handle’s request structure and waits for a WINDOW_UPDATE. The stream stays open in nghttp2, with stream_user_data pointing at the Curl_easy handle.
If the server resets such a stream with RST_STREAM(REFUSED_STREAM) or RST_STREAM(HTTP_1_1_REQUIRED), curl marks the transfer for retry and runs multi_done() to clean up the attempt.

multi_done() ran its cleanup steps in the wrong order for this case:

  • Curl_conn_ev_data_done() runs first. It calls req_flush(), which tries to push the parked, unsent body bytes out before the connection event is considered done.
  • Flushing body bytes on a reset stream makes h2_submit() call nghttp2_submit_request() again. This registers a new stream (the “phantom stream”) in nghttp2, with stream_user_data \= data, the same Curl_easy easy handle that is being torn down.
  • Curl_req_done() and the rest of the cleanup then finish, and the Curl_easy easy handle is freed.

nghttp2 now holds a stream whose user data points to a freed heap. The next frame event on that stream (for example, at connection teardown, through cf_h2_shutdown() -> on_begin_headers() / on_stream_frame()) reads data->mid through the dangling pointer.

The ASAN trace confirms the read: **on_stream_frame()** (lib/http2.c:949) dereferences the freed easy during connection pool destroy. **In one sentence**: the transfer’s request object is retired *after* the connection filters are told the transfer is done, and the filter-side flush that runs in between can still push parked upload bytes onto the connection, which re-registers the stream with the easy handle that is being torn down.

Upstream fixed this on two levels:

  • 8b0744af: multi_done() reorder (the diff shown above). Curl_req_done() now runs before the connection event handler, so no request-body flush can happen while the connection is being told the data is done. The phantom stream is never submitted.
  • d9295430: defense in depth. nghttp2’s stream_user_data no longer holds a raw Curl_easy*. It holds an h2_stream_ctx*, and the easy handle is resolved through a lookup in the multi’s stream table. A stale stream ID resolves to nothing instead of dereferencing freed memory.

The reporter’s exploit turns the UAF read into system(cmd) on the client. Victim: stock curl 8.22.0 release build (PIE, ASLR on), no -k, no debug flags.
The Following is the RCE Scenario:

TL;DR: A malicious server makes curl free memory it’s still using, plants its own data there, and hijacks it to run a command. In practice it’s a long shot: the app must use curl to fetch from one site and upload to another in parallel, with both sites attacker-controlled.

The victim runs a parallel upload: the transfer under attack is the upload itself, and a second, never-ending download keeps a connection busy while the attack plays out. Internally, curl tracks every transfer in an easy handle: one control structure that owns the request state for that transfer from start to finish. The upload's easy handle, E_A, is the target of the attack. The server forces curl to retry by resetting streams, so the attack unfolds over three rounds, one connection each.

  • Round 1: The upload and the download arrive together, multiplexed on one connection. The server reads the whole upload body (its exact size matters later), then resets both streams with RST_STREAM(REFUSED_STREAM). curl, told the server refused the requests, retries both.
  • Round 2, the poisoned connection: The download is answered slowly, forever: it keeps this connection alive and driving while the attack plays out. The retried upload is the aimed one. The server grants exactly j * 16 KiB of flow-control window, lets that much body through, and stalls. With body bytes parked mid-send, it resets the stream. multi_done() then runs its cleanup in the wrong order and resurrects the parked request as a phantom stream, S', registered with E_A as its owner.
  • Round 3: The upload's final attempt is accepted in full and answered 200. The transfer completes, and curl frees E_A.

The server needs to control what lands in E_A’s freed slot. curl gives the server two allocation primitives, both fully server-driven:

  • TLS 1.3 NewSessionTicket: OpenSSL stores the ticket with OPENSSL_memdup(ticket, ticklen), a binary-safe, exact-size allocation. curl sets SSL_OP_NO_TICKET, but OpenSSL still processes (and memdups) unsolicited tickets. Each new ticket frees the previous buffer first, which makes this a controlled malloc/free pair of arbitrary content and size.
  • HPACK blocks on the closed stream: nghttp2 inflates header blocks sent on a closed stream and then ignores them, but the allocations still happen. Literals with incremental indexing stay in the dynamic table (persistent); literals without indexing are freed right away (transient). Dynamic table size updates (0 then 4096) free everything at a chosen moment. This gives precise control over specific glibc size classes (0x50, 0x210).

Timing matters as much as sizes. The server fills the kernel accept queue with dummy connections so that the round 3 attempt’s SYN is dropped and nothing allocates between free(E_A) and the refill. It also holds the poisoned connection’s (round 2) TLS handshake until round 1 is torn down, so the poisoned connection’s first 16 KiB pool chunk lands adjacent to E_A’s old 64 KiB upload buffer.

The server sends a NewSessionTicket carrying a partial fake easy (magic 0xc0dedbad at +0, the phantom stream’s id at +4). OpenSSL frees the previous ticket buffer and memdups this one into E_A’s old slot. The slot is now occupied by attacker-controlled bytes, and S' still points at it.
Controlled Reclaim Achieved.

The exploit needs a libc base. It gets it with a heap over-read smuggled out inside curl’s own upload body:

  • HPACK eviction choreography frees a 0x50-class chunk so that it sits at the head of a poisoned bin.
  • A :status 100 HEADERS on S' makes curl calloc() a 67-byte header store, which lands exactly below the target sendbuf bufq chunk T (the poisoned connection’s outgoing network buffer).
  • A following header value (W1, sized for the 0x210 class) is placed by nghttp2’s rcbuf allocator so that it overflows into T’s header, writing controlled r_offset / w_offset values.
  • chunk_read() (lib/bufq.c) hands out data[r_offset:w_offset] with no dlen check. The server opens the windows and curl uploads T’s memory far past its real end: 0x182f0 bytes starting 0x18300 below the buffer.
  • The server scans the received bytes for glibc main_arena bin pointers (several candidates voting for one page-aligned base) to get the libc base. Fallback: &Curl_nghttp2_free appears in every rcbuf header, giving the libcurl base, and libc follows from the 2 MiB library load-order rule.

Information Disclosure complete.

One TLS flight carries two records:

  • a NewSessionTicket with the final fake easy (2832 bytes, sized to re-occupy the same slot in the same SSL_read burst)
  • a WINDOW_UPDATE(0) on S', an invalid frame that makes nghttp2 call curl’s error path

**And bang! We have Arbitrary Code Execution!**

Due to the uncommon usage and attack scenario of curl outlined in the report, the report was rated high by the curl security team:

During our own investigation, we attempted to exploit the vulnerability under less restrictive conditions to evaluate whether its severity should be elevated from HIGH to CRITICAL. However, we were unable to achieve code execution outside the specific setup described.

To our knowledge, the published proof-of-concept represents the minimum requirements necessary to trigger Remote Code Execution. The exploit is technically very impressive and is at least a plausible usage of curl.

But frankly, in its current state, exploitation of this bug is a “once-in-a-blue-moon scenario”.
Where an application uses curl to transfer data from one site, PUT it to another using parallelism, where both the source site and destination site arguments are attacker-controlled, this is quite a rare scenario.

Xray does not stop at the version match. Its contextual analysis decides whether the vulnerable conditions are actually present in the artifact, and only then reports the CVE as Applicable. For CVE-2026-92392, the scanner correlates three pieces of evidence:

  • The libcurl binary is compiled with nghttp2 (HTTP/2) support,
  • The vulnerable function curl_easy_setopt is called,
  • A script drives the curl tool with an upload flag (-T, -d, -F, --json, or their long forms).

If HTTP/2 support is found but neither usage condition is, Xray returns Undetermined instead of a false positive, since exploitation is still possible through direct CLI use or dynamically loaded libcurl bindings. This lets teams prioritize the artifacts where the exploit path is real.

HTTP/2 is negotiated by ALPN, so any HTTPS upload to an attacker’s server is enough. The single-URL case (curl -T file https://attacker/x) gives a crash. Parallel mode (-Z) gives full RCE.