Skip to content

Commit 64c39ca

Browse files
committed
Fix Io\Poll memory-safety issues
Several memory-safety issues in the new Io\Poll API, found by review and confirmed under valgrind: - Watcher kept a raw pointer to its Context's php_poll_ctx with no reference, so dropping the Context while holding a Watcher left remove()/modify() dereferencing freed memory (use-after-free). The Context now neutralizes its watchers (active=false, poll_ctx=NULL) before it is destroyed, so those calls throw InactiveWatcherException. - StreamPollHandle took a reference on the stream resource in the constructor but never released it, leaking the descriptor for the rest of the request. Store the zend_resource and release it in the handle cleanup; the php_stream may already be freed by then (e.g. the user closed it), so the cleanup must not dereference it. - Watcher and Context had no get_gc handler, so reference cycles through Watcher::$data were uncollectable. Add get_gc for both. - Context, Watcher and StreamPollHandle were cloneable through the default handler, which shallow-copied the backing php_poll_ctx and the watcher map by pointer and double-freed them on destruction. Mark all three uncloneable. - Calling __construct() a second time on a Context or StreamPollHandle replaced the backing context or handle data without releasing the first, leaking it. Throw if the object is already constructed. - The add(), modify(), remove() and wait() entry points accepted a NULL ctx and forwarded it to php_poll_set_error(), which dereferenced it. The userland layer already gates on an active context before reaching the C API, so assert a non-NULL ctx in those entry points instead. Closes GH-22316
1 parent 92128ac commit 64c39ca

8 files changed

Lines changed: 238 additions & 7 deletions

ext/standard/io_poll.c

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ typedef struct {
6464
/* Stream poll handle specific data */
6565
typedef struct {
6666
php_stream *stream;
67+
zend_resource *res;
6768
} php_stream_poll_handle_data;
6869

6970
/* Accessor macros */
@@ -250,7 +251,9 @@ static void php_stream_poll_handle_cleanup(php_poll_handle_object *handle)
250251
{
251252
php_stream_poll_handle_data *data = (php_stream_poll_handle_data *) handle->handle_data;
252253
if (data) {
253-
/* Don't close the stream - user still owns it */
254+
if (data->res) {
255+
zend_list_delete(data->res);
256+
}
254257
efree(data);
255258
handle->handle_data = NULL;
256259
}
@@ -331,6 +334,15 @@ static void php_io_poll_context_free_object(zend_object *obj)
331334
{
332335
php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZOBJ(obj);
333336

337+
if (intern->watchers) {
338+
zval *zv;
339+
ZEND_HASH_FOREACH_VAL(intern->watchers, zv) {
340+
php_io_poll_watcher_object *watcher = PHP_POLL_WATCHER_OBJ_FROM_ZOBJ(Z_OBJ_P(zv));
341+
watcher->active = false;
342+
watcher->poll_ctx = NULL;
343+
} ZEND_HASH_FOREACH_END();
344+
}
345+
334346
if (intern->ctx) {
335347
php_poll_destroy(intern->ctx);
336348
}
@@ -343,6 +355,36 @@ static void php_io_poll_context_free_object(zend_object *obj)
343355
zend_object_std_dtor(&intern->std);
344356
}
345357

358+
static HashTable *php_io_poll_watcher_get_gc(zend_object *obj, zval **table, int *n)
359+
{
360+
php_io_poll_watcher_object *intern = PHP_POLL_WATCHER_OBJ_FROM_ZOBJ(obj);
361+
zend_get_gc_buffer *gc_buffer = zend_get_gc_buffer_create();
362+
363+
zend_get_gc_buffer_add_zval(gc_buffer, &intern->data);
364+
if (intern->handle) {
365+
zend_get_gc_buffer_add_obj(gc_buffer, &intern->handle->std);
366+
}
367+
368+
zend_get_gc_buffer_use(gc_buffer, table, n);
369+
return NULL;
370+
}
371+
372+
static HashTable *php_io_poll_context_get_gc(zend_object *obj, zval **table, int *n)
373+
{
374+
php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZOBJ(obj);
375+
zend_get_gc_buffer *gc_buffer = zend_get_gc_buffer_create();
376+
377+
if (intern->watchers) {
378+
zval *zv;
379+
ZEND_HASH_FOREACH_VAL(intern->watchers, zv) {
380+
zend_get_gc_buffer_add_zval(gc_buffer, zv);
381+
} ZEND_HASH_FOREACH_END();
382+
}
383+
384+
zend_get_gc_buffer_use(gc_buffer, table, n);
385+
return NULL;
386+
}
387+
346388
/* Utility functions */
347389

348390
static zend_always_inline zend_ulong php_io_poll_compute_ptr_key(void *ptr)
@@ -448,13 +490,19 @@ PHP_METHOD(StreamPollHandle, __construct)
448490

449491
php_poll_handle_object *intern = PHP_POLL_HANDLE_OBJ_FROM_ZV(getThis());
450492

493+
if (intern->handle_data) {
494+
zend_throw_error(NULL, "StreamPollHandle object is already constructed");
495+
RETURN_THROWS();
496+
}
497+
451498
/* Set up stream-specific data */
452499
php_stream_poll_handle_data *data = emalloc(sizeof(php_stream_poll_handle_data));
453500
data->stream = stream;
501+
data->res = stream->res;
454502
intern->handle_data = data;
455503

456504
/* Add reference to stream */
457-
GC_ADDREF(stream->res);
505+
GC_ADDREF(data->res);
458506
}
459507

460508
PHP_METHOD(StreamPollHandle, getStream)
@@ -657,6 +705,11 @@ PHP_METHOD(Io_Poll_Context, __construct)
657705

658706
php_io_poll_context_object *intern = PHP_POLL_CONTEXT_OBJ_FROM_ZV(getThis());
659707

708+
if (intern->ctx) {
709+
zend_throw_error(NULL, "Io\\Poll\\Context object is already constructed");
710+
RETURN_THROWS();
711+
}
712+
660713
php_poll_backend_type backend_type = PHP_POLL_BACKEND_AUTO;
661714
if (backend_obj != NULL) {
662715
backend_type = php_io_poll_backend_enum_to_type(Z_OBJ_P(backend_obj));
@@ -861,6 +914,7 @@ PHP_MINIT_FUNCTION(poll)
861914
memcpy(&php_io_poll_handle_object_handlers, &std_object_handlers, sizeof(zend_object_handlers));
862915
php_io_poll_handle_object_handlers.offset = offsetof(php_poll_handle_object, std);
863916
php_io_poll_handle_object_handlers.free_obj = php_poll_handle_object_free;
917+
php_io_poll_handle_object_handlers.clone_obj = NULL;
864918
php_stream_poll_handle_class_entry->default_object_handlers = &php_io_poll_handle_object_handlers;
865919

866920
/* Register Watcher class */
@@ -871,6 +925,8 @@ PHP_MINIT_FUNCTION(poll)
871925
sizeof(zend_object_handlers));
872926
php_io_poll_watcher_object_handlers.offset = offsetof(php_io_poll_watcher_object, std);
873927
php_io_poll_watcher_object_handlers.free_obj = php_io_poll_watcher_free_object;
928+
php_io_poll_watcher_object_handlers.get_gc = php_io_poll_watcher_get_gc;
929+
php_io_poll_watcher_object_handlers.clone_obj = NULL;
874930
php_io_poll_watcher_class_entry->default_object_handlers = &php_io_poll_watcher_object_handlers;
875931

876932
/* Register Context class */
@@ -881,6 +937,8 @@ PHP_MINIT_FUNCTION(poll)
881937
sizeof(zend_object_handlers));
882938
php_io_poll_context_object_handlers.offset = offsetof(php_io_poll_context_object, std);
883939
php_io_poll_context_object_handlers.free_obj = php_io_poll_context_free_object;
940+
php_io_poll_context_object_handlers.get_gc = php_io_poll_context_get_gc;
941+
php_io_poll_context_object_handlers.clone_obj = NULL;
884942
php_io_poll_context_class_entry->default_object_handlers = &php_io_poll_context_object_handlers;
885943

886944
/* Register exception hierarchy */
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
--TEST--
2+
Io\Poll: Context, Watcher and StreamPollHandle are not cloneable
3+
--FILE--
4+
<?php
5+
require_once __DIR__ . '/poll.inc';
6+
7+
list($r, $w) = pt_new_socket_pair();
8+
$ctx = pt_new_stream_poll();
9+
$handle = new StreamPollHandle($r);
10+
$watcher = $ctx->add($handle, [Io\Poll\Event::Read]);
11+
12+
foreach ([$ctx, $handle, $watcher] as $obj) {
13+
try {
14+
clone $obj;
15+
} catch (Error $e) {
16+
echo $e->getMessage(), "\n";
17+
}
18+
}
19+
20+
echo "done\n";
21+
?>
22+
--EXPECT--
23+
Trying to clone an uncloneable object of class Io\Poll\Context
24+
Trying to clone an uncloneable object of class StreamPollHandle
25+
Trying to clone an uncloneable object of class Io\Poll\Watcher
26+
done
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
--TEST--
2+
Io\Poll: calling __construct() twice throws instead of leaking
3+
--FILE--
4+
<?php
5+
require_once __DIR__ . '/poll.inc';
6+
7+
list($r, $w) = pt_new_socket_pair();
8+
9+
$handle = new StreamPollHandle($r);
10+
try {
11+
$handle->__construct($r);
12+
} catch (Error $e) {
13+
echo $e->getMessage(), "\n";
14+
}
15+
16+
$ctx = pt_new_stream_poll();
17+
try {
18+
$ctx->__construct();
19+
} catch (Error $e) {
20+
echo $e->getMessage(), "\n";
21+
}
22+
23+
echo "done\n";
24+
?>
25+
--EXPECT--
26+
StreamPollHandle object is already constructed
27+
Io\Poll\Context object is already constructed
28+
done
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
--TEST--
2+
Io\Poll: StreamPollHandle cleanup is safe when the stream is closed first
3+
--FILE--
4+
<?php
5+
require_once __DIR__ . '/poll.inc';
6+
7+
list($r, $w) = pt_new_socket_pair();
8+
$ctx = pt_new_stream_poll();
9+
$watcher = $ctx->add(new StreamPollHandle($r), [Io\Poll\Event::Read]);
10+
11+
// Close the underlying streams before the watcher and handle are freed.
12+
fclose($r);
13+
fclose($w);
14+
15+
unset($watcher, $ctx);
16+
gc_collect_cycles();
17+
echo "ok\n";
18+
?>
19+
--EXPECT--
20+
ok
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
--TEST--
2+
Io\Poll: StreamPollHandle releases its stream resource (no fd leak)
3+
--SKIPIF--
4+
<?php
5+
if (!is_dir('/proc/self/fd')) {
6+
die("skip requires /proc/self/fd (Linux)\n");
7+
}
8+
?>
9+
--FILE--
10+
<?php
11+
function open_fds(): int {
12+
return count(scandir('/proc/self/fd'));
13+
}
14+
15+
$before = open_fds();
16+
for ($i = 0; $i < 100; $i++) {
17+
list($r, $w) = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
18+
$h = new StreamPollHandle($r);
19+
unset($h, $r, $w);
20+
}
21+
gc_collect_cycles();
22+
$delta = open_fds() - $before;
23+
24+
// Without the fix each handle pins its stream, leaking ~100 fds.
25+
var_dump($delta < 10);
26+
?>
27+
--EXPECT--
28+
bool(true)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
--TEST--
2+
Io\Poll: cycle collector reclaims a Watcher referenced through its own data
3+
--FILE--
4+
<?php
5+
require_once __DIR__ . '/poll.inc';
6+
7+
class Canary {
8+
public $ref;
9+
public function __destruct() {
10+
echo "Canary freed\n";
11+
}
12+
}
13+
14+
list($r, $w) = pt_new_socket_pair();
15+
$ctx = pt_new_stream_poll();
16+
$watcher = $ctx->add(new StreamPollHandle($r), [Io\Poll\Event::Read]);
17+
18+
$c = new Canary();
19+
$c->ref = $watcher; // Canary -> Watcher
20+
$watcher->modifyData($c); // Watcher->data -> Canary (cycle)
21+
22+
unset($ctx, $watcher, $c, $r, $w);
23+
24+
echo "before gc\n";
25+
gc_collect_cycles();
26+
echo "after gc\n";
27+
?>
28+
--EXPECT--
29+
before gc
30+
Canary freed
31+
after gc
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
--TEST--
2+
Io\Poll: Watcher operations are safe after its Context is destroyed
3+
--FILE--
4+
<?php
5+
require_once __DIR__ . '/poll.inc';
6+
7+
list($r, $w) = pt_new_socket_pair();
8+
$ctx = pt_new_stream_poll();
9+
$watcher = $ctx->add(new StreamPollHandle($r), [Io\Poll\Event::Read], "data");
10+
11+
// Drop the Context while still holding the Watcher it returned.
12+
unset($ctx);
13+
gc_collect_cycles();
14+
15+
var_dump($watcher->isActive());
16+
17+
try {
18+
$watcher->remove();
19+
} catch (Io\Poll\InactiveWatcherException $e) {
20+
echo $e->getMessage(), "\n";
21+
}
22+
23+
try {
24+
$watcher->modifyEvents([Io\Poll\Event::Write]);
25+
} catch (Io\Poll\InactiveWatcherException $e) {
26+
echo $e->getMessage(), "\n";
27+
}
28+
29+
echo "done\n";
30+
?>
31+
--EXPECT--
32+
bool(false)
33+
Cannot remove inactive watcher
34+
Cannot modify inactive watcher
35+
done

main/poll/poll_core.c

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ PHPAPI php_poll_ctx *php_poll_create_by_name(const char *preferred_backend, uint
191191
/* Set event capacity hint (optional optimization) */
192192
PHPAPI zend_result php_poll_set_max_events_hint(php_poll_ctx *ctx, int max_events)
193193
{
194-
if (UNEXPECTED(!ctx || max_events <= 0)) {
194+
ZEND_ASSERT(ctx);
195+
if (UNEXPECTED(max_events <= 0)) {
195196
php_poll_set_error(ctx, PHP_POLL_ERR_INVALID);
196197
return FAILURE;
197198
}
@@ -243,7 +244,8 @@ PHPAPI void php_poll_destroy(php_poll_ctx *ctx)
243244
/* Add file descriptor */
244245
PHPAPI zend_result php_poll_add(php_poll_ctx *ctx, int fd, uint32_t events, void *data)
245246
{
246-
if (UNEXPECTED(!ctx || !ctx->initialized || fd < 0)) {
247+
ZEND_ASSERT(ctx);
248+
if (UNEXPECTED(!ctx->initialized || fd < 0)) {
247249
php_poll_set_error(ctx, PHP_POLL_ERR_INVALID);
248250
return FAILURE;
249251
}
@@ -259,7 +261,8 @@ PHPAPI zend_result php_poll_add(php_poll_ctx *ctx, int fd, uint32_t events, void
259261
/* Modify file descriptor */
260262
PHPAPI zend_result php_poll_modify(php_poll_ctx *ctx, int fd, uint32_t events, void *data)
261263
{
262-
if (UNEXPECTED(!ctx || !ctx->initialized || fd < 0)) {
264+
ZEND_ASSERT(ctx);
265+
if (UNEXPECTED(!ctx->initialized || fd < 0)) {
263266
php_poll_set_error(ctx, PHP_POLL_ERR_INVALID);
264267
return FAILURE;
265268
}
@@ -275,7 +278,8 @@ PHPAPI zend_result php_poll_modify(php_poll_ctx *ctx, int fd, uint32_t events, v
275278
/* Remove file descriptor */
276279
PHPAPI zend_result php_poll_remove(php_poll_ctx *ctx, int fd)
277280
{
278-
if (UNEXPECTED(!ctx || !ctx->initialized || fd < 0)) {
281+
ZEND_ASSERT(ctx);
282+
if (UNEXPECTED(!ctx->initialized || fd < 0)) {
279283
php_poll_set_error(ctx, PHP_POLL_ERR_INVALID);
280284
return FAILURE;
281285
}
@@ -292,7 +296,8 @@ PHPAPI zend_result php_poll_remove(php_poll_ctx *ctx, int fd)
292296
PHPAPI int php_poll_wait(php_poll_ctx *ctx, php_poll_event *events, int max_events,
293297
const struct timespec *timeout)
294298
{
295-
if (UNEXPECTED(!ctx || !ctx->initialized || !events || max_events <= 0)) {
299+
ZEND_ASSERT(ctx);
300+
if (UNEXPECTED(!ctx->initialized || !events || max_events <= 0)) {
296301
php_poll_set_error(ctx, PHP_POLL_ERR_INVALID);
297302
return -1;
298303
}

0 commit comments

Comments
 (0)