Skip to content

Let a curl upload's read state own what it points at - #34

Merged
EdmondDantes merged 2 commits into
true-asyncfrom
295-curl-read-state-lifetime
Sep 10, 2026
Merged

EdmondDantes merged 2 commits into
true-asyncfrom
295-curl-read-state-lifetime

Conversation

@EdmondDantes

Copy link
Copy Markdown

Fixes true-async/php-async#295 and true-async/php-async#294.

php#295 — the subscription counted nothing

curl_async_read parks on the stream's zend_async_io_t and keeps a raw pointer
to it. Neither the subscription nor the state took a reference:
zend_async_callbacks_push counts the callback, and the stream's own ref_count
in main/streams/plain_wrapper.c rises only around a parked coroutine. So an
fclose() between two of curl's reads found ref_count at zero, php_stdiop_close
disposed the io, and curl_async_read_state_free() then removed its subscription
through the freed block.

Reproduced under ASAN, USE_ZEND_ALLOC=0 (the io is freed through ZendMM, so the
sanitizer sees it only with the allocator out of the way):

==6146==ERROR: AddressSanitizer: heap-use-after-free
    #0 curl_async_read_state_free ext/curl/curl_async.c:1425
    #1 curl_async_event_stop ext/curl/curl_async.c:337
  freed by thread T0 here:
    #3 libuv_io_event_dispose ext/async/libuv_reactor.c:4727
    #4 php_stdiop_close main/streams/plain_wrapper.c:962
    #6 zif_fclose ext/standard/file.c:779

The subscription now holds a reference of its own, taken where the callback is
added and released where it is removed — the shape the reactor already uses for
an io it names (libuv_reactor.c:6222 and :5285), and safe because
libuv_io_event_dispose is refcount-aware. It is keyed on file.io_cb rather
than on file.io: file.io is assigned outside the LIBCURL_VERSION_NUM >= 0x080B01
guard and file.io_cb inside it, while config.m4 admits libcurl from 7.87.0, so
a build in that range has the pointer without the subscription and must not
release a reference it never took.

The add_callback result is checked at both subscription sites. A failure used to
leave a callback allocated and named by a state that would never hear from it.

php#294 — a CURLFile read state was out of reach from its event

A CURLFile part carries a second read state, hung on the mime callback argument,
and curl_async_event_stop() knew only ch->async_read_state. That state went on
naming an event about to be freed.

The handle owns the mime arguments — ch->to_free->stream is a zend_llist of
mime_data_cb_arg_t * — so stop() walks them and severs the back-link. Freeing
stays with curl_async_free_cb(), which libcurl calls when it releases the part:
the completion of a read that outlives its event releases the request and the
subscription and leaves the state alone, marked by the new CURL_READ_MIME flag,
and every field it releases is cleared, so the free_cb finds nothing to release
twice.

This half has no test. The dangling pointer itself was observed directly, with
a read in flight, by instrumenting the event's destructor:

TASDBG mime-async    st=0x506000024c80
TASDBG dtor-dangling st=0x506000024c80 ev=0x50f000025fc0 this=0x50f000025fc0 pending=1

ev is the event the state names and this the event being destroyed. What no
script produced is a completion landing in the window between that destructor and
the free_cb: ten attempts, including a fifo source (useless for CURLFile — the
part's length comes from stat, which is 0 for a fifo, so the body is sent empty),
a single-thread UV_THREADPOOL_SIZE with eight concurrent uploads, and 160 handle
removals at varying points of the upload. The fix is by construction.

The dead flag

CURL_READ_OWNS_FD guarded two close() calls and was set nowhere: 60fdf5a5fc7
deleted its only assignment along with the raw VCWD_OPEN it belonged to. Every
descriptor a read state holds comes from php_stream_cast() on a stream somebody
else closes, so setting the flag again would close a number another open() may
already have taken. Removed with both branches, in its own commit.

Checks

  • tests/curl/071-upload_stream_closed_mid_read.phpt (php-async, sent separately):
    heap-use-after-free under ASAN on a rebuild of this tree with the first commit
    reverted, green with it.
  • ext/async/tests/curl under ASAN — 68 passed, 0 failed.
  • ext/async/tests — 1194 passed, 0 failed. fuzzy-tests/_generated — 724 passed,
    0 failed.
  • ext/curl/tests under ASAN — 168 passed, 4 failed: curl_writefunction_throws_abort,
    curl_readfunction_throws_abort, curl_headerfunction_throws_abort and
    curl_curlfile_seek. All four fail the same way on a build of this tree without
    these commits.
  • No leaks reported with detect_leaks=1 on either probe.

Edmond added 2 commits September 10, 2026 11:08
A read state subscribes to the stream's IO event and keeps a raw pointer to it.
Nothing counted that subscription: zend_async_callbacks_push() counts the
callback, and the stream's own ref_count rises only around a parked coroutine.
So an fclose() between two of curl's reads found ref_count at zero, disposed the
io, and curl_async_read_state_free() then removed its subscription through the
freed block. The subscription now holds a reference of its own, taken where the
callback is added and released where it is removed, which is the shape the
reactor already uses for an io it names. Keyed on the callback rather than on
the pointer: file.io is assigned outside the LIBCURL_VERSION_NUM guard and
file.io_cb inside it, so a build against libcurl below 8.11.1 has the one
without the other.

A CURLFile part carries a second read state, hung on the mime callback argument,
and curl_async_event_stop() knew only the handle's. That state went on naming an
event about to be freed. The handle owns the mime arguments, so stop() walks
them and severs the back-link. Freeing them stays with curl_async_free_cb(),
which libcurl calls when it releases the part: the completion of a read that
outlives its event releases the request and the subscription and leaves the
state, and each field it releases is cleared, so the free_cb finds nothing to
release twice.

The add_callback result is checked at both sites. A failure used to leave a
callback allocated and named by a state that would never hear from it.

tests/curl/071-upload_stream_closed_mid_read.phpt in php-async covers the first
half: heap-use-after-free under ASAN before this change, clean after. The second
half is not covered by a test. The dangling event pointer was observed directly,
with a read in flight, by instrumenting the event's destructor, but no script
made a completion land in the window between the destructor and the free_cb.

php#295 php#294
CURL_READ_OWNS_FD guarded two close() calls and was set nowhere: the assignment
went out with the raw VCWD_OPEN it belonged to, when CURLFile moved to
php_stream. Every descriptor a read state holds now comes from php_stream_cast()
on a stream somebody else closes, so setting the flag again would close a number
another open() may already have taken.

php#294
@EdmondDantes

Copy link
Copy Markdown
Author

Checked against the libcurl versions that actually matter, not only the one on this machine.

Built from source and linked into three separate PHP builds:

libcurl why ext/async/tests/curl with these commits same suite with them reverted
8.12.1 this machine 68 passed, 0 failed
8.5.0 what .github/workflows/build-linux.yml builds 67 passed, 3 skipped, 0 failed 67 passed, 3 skipped, 0 failed
7.87.0 the minimum ext/curl/config.m4 admits 67 passed, 3 skipped, 0 failed 67 passed, 3 skipped, 0 failed

Identical with and without the change on both older versions, which is what the code
says should happen: of everything these commits touch, only the walk of mime read
states in curl_async_event_stop() compiles below 8.11.1, and there it writes
state->event = NULL for a CURL_READ_FILE state whose every reader lives inside the
LIBCURL_VERSION_NUM >= 0x080B01 guard. The unguarded readers at
curl_async_read_callback_sync() are the CURLOPT_READFUNCTION path, which a mime
state never reaches. Compiling curl_async.c with LIBCURL_VERSION_NUM forced to
0x080500 is clean, no errors and no warnings.

One real find from this: the new test failed on 8.5.0. There the upload read is a
synchronous read(2), so curl_exec pushes the whole body before the closing
coroutine gets a turn and the transfer reads "completed" rather than "aborted". It
now carries a --SKIPIF-- on version_number < 0x080B01 — the parked read it needs
does not exist below that, and neither does the subscription it covers.

ext/curl/tests on the 8.5.0 build: 164 passed, 4 failed — the same four that fail
on 8.12.1 and on a build of this tree without these commits.

@EdmondDantes
EdmondDantes merged commit a512987 into true-async Sep 10, 2026
9 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant