HexBrain

curl · Report #3916059

CVE-2026-18924: HTTP/2 server push UAF

Validstatus
lowseverity
14upvotes
2 Sept 2026disclosed

How it was found

Summary

With a CURLSH sharing CURL_LOCK_DATA_CONNECT attached to an easy handle, libcurl creates the handle for an HTTP/2 server-pushed stream internally via h2_duphandle() → curl_easy_duphandle() (lib/http2.c). curl_easy_duphandle() does not copy ->share, so that handle has share == NULL. Curl_multi_add_perform() then attaches it to the parent's connection, which is pooled in share->cpool.

When the pushed transfer ends, cpool_get_instance() resolves the pool from the handle: share == NULL yields multi->cpool, not the share->cpool that owns the connection. cpool_remove_conn() does not find the connection, so it is freed while still linked in share->cpool's bundle (bits.in_cpool remains TRUE). A later walk of share->cpool dereferences the freed connectdata.

CWE-416 (use-after-free).

Affected versions

  • curl 8.21.0 (tag curl-8_21_0).
  • master 2112f185c (2026-08-04, libcurl 8.22.0-DEV) — reproduces 3/3.

State of the three sites at 2112f185c:

sitestate
h2_duphandle — lib/http2.c:699calls curl_easy_duphandle(data); share occurs 0 times in lib/http2.c
curl_easy_duphandle — lib/easy.c:970does not copy ->share
cpool_get_instance — lib/conncache.c:256resolves the pool from the handle, not the connection

Reproduction

repro.c (attached) is standalone pure libcurl: an in-process thread serves raw h2c frames over a socketpair via CURLOPT_OPENSOCKETFUNCTION + CURL_SOCKOPT_ALREADY_CONNECTED. No network, no server, no TLS. It creates a CURLSH sharing CURL_LOCK_DATA_CONNECT, runs an HTTP/2 transfer, accepts a PUSH_PROMISE (CURL_PUSH_OK), and the server sends GOAWAY.

Built against a release (ENABLE_DEBUG=OFF) + ASan libcurl:

cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DENABLE_DEBUG=OFF \
  -DBUILD_SHARED_LIBS=OFF -DBUILD_CURL_EXE=OFF -DBUILD_TESTING=OFF \
  -DCURL_USE_OPENSSL=OFF -DUSE_NGHTTP2=ON -DCURL_USE_LIBPSL=OFF -DUSE_LIBIDN2=OFF \
  -DCURL_BROTLI=OFF -DCURL_ZSTD=OFF \
  -DCMAKE_C_FLAGS="-fsanitize=address -g -fno-omit-frame-pointer"
cmake --build build && cmake --install build

cc -fsanitize=address -g repro.c -o repro -I<prefix>/include \
   <prefix>/lib/libcurl.a -lnghttp2 -lz -lpthread -ldl
ASAN_OPTIONS=detect_leaks=0 ./repro

Result: heap-use-after-free, 3/3 runs, on both curl 8.21.0 and master 2112f185c.

In a DEBUGBUILD libcurl the inconsistency aborts earlier at DEBUGASSERT(NULL) lib/conncache.c:179 ("Should have been in the bundle list") instead of reaching the free.

AddressSanitizer output (master 2112f185c, release + ASan)

READ of size 8 into a freed 776-byte connectdata (the struct was 832 bytes in 8.21.0). Full log: asan-retest-master-2026-08-04.txt.

READ of size 8:
  Curl_node_elem            lib/llist.c:237
  cpool_get_first           lib/conncache.c:142
  Curl_cpool_destroy        lib/conncache.c:242
  share_destroy             lib/curl_share.c:41
  curl_share_cleanup        lib/curl_share.c:370

freed by:
  Curl_cshutdn_terminate    lib/cshutdn.c:159
  cpool_discard_conn        lib/conncache.c:226
  Curl_conn_terminate       lib/conncache.c:692
  Curl_cpool_do_locked      lib/conncache.c:833
  multi_done                lib/multi.c:750
  multi_handle_timeout      lib/multi.c:1848
  multi_runsingle           lib/multi.c:2757
  multi_perform             lib/multi.c:2888

previously allocated by:
  allocate_conn             lib/url.c:1182
  url_create_needle         lib/url.c:2010
  url_find_or_create_conn   lib/url.c:2250
  Curl_connect              lib/url.c:2456

verifynode() (lib/llist.c:36) is DEBUGBUILD-only; in a release build the read lands at Curl_node_elem().

Root cause

  1. curl_easy_duphandle() does not copy ->share (docs/libcurl/curl_easy_duphandle.md: the clone is "created as if CURLOPT_SHARE was set to NULL").
  2. push_promise() → h2_duphandle() → curl_easy_duphandle() ⇒ pushed handle has share == NULL.
  3. push_promise() → Curl_multi_add_perform(data->multi, newhandle, cf->conn) → Curl_attach_connection(newhandle, cf->conn) ⇒ the pushed handle holds the parent's connection, which is in share->cpool.
  4. cpool_get_instance(data) (lib/conncache.c:256) returns &data->share->cpool only when CURL_SHARE_KEEP_CONNECT(data->share); for the pushed handle it returns multi->cpool.
  5. multi_done() → Curl_cpool_do_locked() → Curl_conn_terminate() → cpool_remove_conn(multi->cpool, conn): the connection is not in that pool, so it is not unlinked and bits.in_cpool stays TRUE.
  6. cpool_discard_conn() → Curl_cshutdn_terminate() frees the connectdata.
  7. curl_share_cleanup() → share_destroy() → Curl_cpool_destroy() → cpool_get_first() → Curl_node_elem() reads the freed connectdata.

Trigger

Application setup, all before the transfer starts:

CURLSH *sh = curl_share_init();
curl_share_setopt(sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
curl_easy_setopt(easy, CURLOPT_SHARE, sh);
curl_multi_setopt(multi, CURLMOPT_PUSHFUNCTION, push_cb);  /* returns CURL_PUSH_OK */

The push callback returns CURL_PUSH_OK and calls no libcurl function. No option or share is modified after the transfer starts. Single-threaded. The pushed-stream handle is created by libcurl, not by the application.

Server side: send PUSH_PROMISE, then end the pushed stream before it completes. The attached reproducer uses GOAWAY. RST_STREAM, connection close, CURLOPT_TIMEOUT_MS expiry, and curl_multi_remove_handle() of the pushed handle all reach multi_done() → Curl_conn_terminate() with the share-less handle.

Suggested fix

Propagate the parent's share to the pushed handle in h2_duphandle()/push_promise() before Curl_multi_add_perform() (newhandle->share = data->share; plus refcount/lock handling), or terminate a connection against the cpool that owns it rather than cpool_get_instance(data).

Attachments

filecontents
repro.c, run.shstandalone reproducer
asan-retest-master-2026-08-04.txtASan log, master 2112f185c
asan-standalone.txtASan log, 8.21.0
asan-recover-master-2026-08-04.txtASan log with -fsanitize-recover, showing all three reads
repro-harness.binoriginal fuzzer input (debug build, asserts at conncache.c:179)
RETEST-2026-08-04.mdretest method and build details

Impact

The freed connectdata stays linked in share->cpool's bundle with bits.in_cpool set, so it remains reachable from any subsequent operation on that pool — not only from curl_share_cleanup().

Measured on master 2112f185c with a release + ASan libcurl built with -fsanitize-recover=address and run under ASAN_OPTIONS=halt_on_error=0, so execution continues past the first error (asan-recover-master-2026-08-04.txt):

#accessoffset into the freed 776-byte regionsite
1READ of size 88Curl_node_elem — lib/llist.c:237
2READ of size 80Curl_node_llist — lib/llist.c:269
3READ of size 888cpool_remove_conn — lib/conncache.c:169

Three reads of freed heap memory at three distinct offsets and three distinct sites. No write to the freed region was observed in that run.

The connection object is heap-allocated by allocate_conn() (lib/url.c:1182) and freed by Curl_cshutdn_terminate() (lib/cshutdn.c:159); the interval between the free and the reads contains further libcurl allocation activity.

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