Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
79 changes: 79 additions & 0 deletions tests/curl/069-multi_select_cancel_uaf.phpt
Original file line number Diff line number Diff line change
@@ -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--
<?php

use function Async\spawn;
use function Async\delay;
use function Async\await_all;

$srv = stream_socket_server('tcp://127.0.0.1:0', $errno);
$addr = stream_socket_get_name($srv, false);

$accept = spawn(function () use ($srv) {
while (true) {
$c = @stream_socket_accept($srv, 30);
if (!$c) return;
while (($l = @fgets($c)) !== false && $l !== "\r\n") {}
@fwrite($c, "HTTP/1.1 200 OK\r\nContent-Length: 1048576\r\n\r\n");
for ($i = 0; $i < 1000; $i++) {
delay(50);
if (@fwrite($c, str_repeat('x', 64)) === false) return;
}
@fclose($c);
}
});

$fetcher = spawn(function () use ($addr) {
$mh = curl_multi_init();
$easy = [];
foreach ([1, 2] as $_) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://$addr/");
curl_setopt($ch, CURLOPT_WRITEFUNCTION, fn($ch, $d) => 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
Loading