curl · Report #3916059
CVE-2026-18924: HTTP/2 server push UAF
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:
| site | state |
|---|---|
h2_duphandle — lib/http2.c:699 | calls curl_easy_duphandle(data); share occurs 0 times in lib/http2.c |
curl_easy_duphandle — lib/easy.c:970 | does not copy ->share |
cpool_get_instance — lib/conncache.c:256 | resolves 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
curl_easy_duphandle()does not copy->share(docs/libcurl/curl_easy_duphandle.md: the clone is "created as ifCURLOPT_SHAREwas set to NULL").push_promise()→h2_duphandle()→curl_easy_duphandle()⇒ pushed handle hasshare == NULL.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 inshare->cpool.cpool_get_instance(data)(lib/conncache.c:256) returns&data->share->cpoolonly whenCURL_SHARE_KEEP_CONNECT(data->share); for the pushed handle it returnsmulti->cpool.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 andbits.in_cpoolstays TRUE.cpool_discard_conn()→Curl_cshutdn_terminate()frees theconnectdata.curl_share_cleanup()→share_destroy()→Curl_cpool_destroy()→cpool_get_first()→Curl_node_elem()reads the freedconnectdata.
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
| file | contents |
|---|---|
repro.c, run.sh | standalone reproducer |
asan-retest-master-2026-08-04.txt | ASan log, master 2112f185c |
asan-standalone.txt | ASan log, 8.21.0 |
asan-recover-master-2026-08-04.txt | ASan log with -fsanitize-recover, showing all three reads |
repro-harness.bin | original fuzzer input (debug build, asserts at conncache.c:179) |
RETEST-2026-08-04.md | retest 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):
| # | access | offset into the freed 776-byte region | site |
|---|---|---|---|
| 1 | READ of size 8 | 8 | Curl_node_elem — lib/llist.c:237 |
| 2 | READ of size 8 | 0 | Curl_node_llist — lib/llist.c:269 |
| 3 | READ of size 8 | 88 | cpool_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.