From 921ee4fae40205f20b2bf0cab1e1fd5e0bc14bd2 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Tue, 26 May 2026 14:57:34 +0000 Subject: [PATCH 1/2] #145 tests/curl: regression for AsyncCancellation in curl_multi_select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduces the heap-use-after-free that occurred when a coroutine parked in curl_multi_select() was cancelled mid-select. Fixed in php-src by routing the SUSPEND-failure path through finally: so waker_clean() unsubscribes the resolve callback from the multi event before the user's PHP finally drives libcurl into multi_socket_cb(CURL_POLL_REMOVE) → CALLBACKS_NOTIFY → resume → stale queue entry → UAF on next dequeue. Test asserts the cancel scenario prints "cancelled\nOK\n" with no "still in the queue" warning and ASAN-ZTS clean. --- CHANGELOG.md | 1 + tests/curl/069-multi_select_cancel_uaf.phpt | 79 +++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/curl/069-multi_select_cancel_uaf.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce5e30e..2983e61a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.7.0] - ### Fixed +- **#145 curl_multi_select: heap corruption when AsyncCancellation interrupts the select** — `curl_async_select` early-returned `CURLM_INTERNAL_ERROR` on SUSPEND failure, skipping the `finally:` label that calls `zend_async_waker_clean()`. On cancel-mid-select the coroutine's resolve callback stayed subscribed to the multi event; the user's PHP `finally` then drove `curl_multi_close()` → libcurl → `multi_socket_cb(CURL_POLL_REMOVE)` → `CALLBACKS_NOTIFY` on the multi event → `async_coroutine_resume` re-pushed the still-running coroutine into the scheduler runqueue ("still in the queue" warning), and the next dequeue dereferenced its freed fcall (heap-use-after-free in `zend_call_function`). Fixed in php-src by routing the SUSPEND-failure path through `finally:` so `waker_clean()` unsubscribes before the user's finally can drive libcurl into NOTIFY. Regression test `tests/curl/069-multi_select_cancel_uaf.phpt`. - **curl async read: dangling subscription on stream io.event after curl_close** — `curl_async_read_dispatch` (CURLOPT_INFILE) and `curl_async_read_cb` (CURLFile) subscribed an `io_cb` on the stream's `io->event` but `curl_async_read_state_free` freed the state without calling `del_callback`. After `unset($ch); fclose($fp)` the stream's event still held the callback, fired it on the now-freed state and corrupted `zend_mm` (bin_num=8). Surfaced on musl/macOS where the allocator does not mask the UAF; on glibc it was silent. Fixed by storing `io_cb` on `state->file.io_cb` at subscription time and removing it from `io->event` in `curl_async_read_state_free` before `efree`. Reproduced in Alpine `tests/curl/028-read_file_basic.phpt` and `030-read_file_large.phpt` under DEBUG ZTS — both green after fix; full `ext/async/tests/curl/` suite 66/68 PASS (2 skipped). - **#144 libuv: close-mid-read leaves parked reader hung forever** — `libuv_io_close` ran `uv_read_stop` without notifying the parked `active_req`. Closing a STREAM-type IO handle (PIPE/TCP/TTY) while a coroutine was parked in `fread()` silently dropped the read watcher; the reader hung until the deadlock detector aborted the request, then UAFed on the freed stream when it finally resumed (the resource dtor had `pefree`'d both stream and abstract data while it waited). Typical trigger: `proc_open` + a coroutine reading the child's stdout pipe + another coroutine calling `proc_close`. Fixed by walking event subscribers before tearing the watcher down: `libuv_io_close` now marks the active req `io_closed`, builds an `InputOutputException`, and `ZEND_ASYNC_CALLBACKS_NOTIFY`s the event so every parked reader/writer wakes. ABI bumped to v0.19.0 — `zend_async_io_req_t` and `zend_async_udp_req_t` gained a `bool io_closed` field so consumers (`php_stdiop_read`/`php_stdiop_write` in php-src) can early-return without touching the freed stream. Regression test `tests/exec/025-proc_close_wakes_parked_fread.phpt`. - **OOM bailout no longer surfaces a noisy second warning** — when a coroutine body bailed out because Zend MM hit `memory_limit`, the coroutine `exit` handler ran `ZEND_ASYNC_SHUTDOWN()`. That second pass also hit OOM, was caught, and printed `Warning: A critical error was detected during the initiation of the graceful shutdown mode.` on top of the original `Fatal: Allowed memory size of N bytes exhausted`. Graceful shutdown is doomed when the allocator is still at its limit, so it's now skipped on OOM bailouts via the new `zend_alloc_pop_is_oom()` PHPAPI (php-src). Regression test `Zend/tests/fibers/gh19983.phpt` under `--asan` (`USE_TRACKED_ALLOC=1`). diff --git a/tests/curl/069-multi_select_cancel_uaf.phpt b/tests/curl/069-multi_select_cancel_uaf.phpt new file mode 100644 index 00000000..8ba15781 --- /dev/null +++ b/tests/curl/069-multi_select_cancel_uaf.phpt @@ -0,0 +1,79 @@ +--TEST-- +curl_multi_select: AsyncCancellation must not leave stale waker subscription (#145) +--DESCRIPTION-- +When a coroutine parked in curl_multi_select() is cancelled, the SUSPEND- +failure path used to `return CURLM_INTERNAL_ERROR` directly, skipping the +finally: label that calls zend_async_waker_clean(). The coroutine's +resolve callback stayed subscribed to the multi event. During the user's +finally block, curl_multi_close()/curl_multi_remove_handle() drove libcurl +through multi_socket_cb(CURL_POLL_REMOVE), which fires CALLBACKS_NOTIFY +on the multi event — re-resuming the still-running coroutine and pushing +a stale entry into the scheduler runqueue. The next dequeue executed the +already-finalized coroutine and dereferenced its freed fcall, causing +heap-use-after-free and the runtime warning "Attempt to finalize a +coroutine that is still in the queue". +--EXTENSIONS-- +curl +--FILE-- + strlen($d)); + curl_multi_add_handle($mh, $ch); + $easy[] = $ch; + } + try { + do { + curl_multi_exec($mh, $active); + if ($active) curl_multi_select($mh, 1.0); + } while ($active); + } catch (\Async\AsyncCancellation $e) { + echo "cancelled\n"; + } finally { + foreach ($easy as $ch) { + @curl_multi_remove_handle($mh, $ch); + @curl_close($ch); + } + @curl_multi_close($mh); + } +}); + +$killer = spawn(function () use ($fetcher) { + delay(30); + $fetcher->cancel(); +}); + +await_all([$fetcher, $killer]); +$accept->cancel(); + +echo "OK\n"; +?> +--EXPECT-- +cancelled +OK From 881d5b95904d7c5757280edaf4f0552d9c4fc175 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Tue, 26 May 2026 15:25:32 +0000 Subject: [PATCH 2/2] ci: retrigger after php-src true-async-stable updated with #145 fix