Skip to content

Commit b63b651

Browse files
committed
Merge branch 'PHP-8.5' into PHP-8.6
* PHP-8.5: Fix GH-22063: Stream filter chain UAF on self-removal during callback PHP-8.6 added filter seek(), so php_stream_filters_seek() gets the same removal bracket as the filter chain walks.
2 parents 0e66515 + cb7612f commit b63b651

8 files changed

Lines changed: 226 additions & 1 deletion

File tree

‎NEWS‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ PHP NEWS
3434
. Fixed an exception thrown by the session_get_cb stream context callback
3535
being replaced with a TypeError. (Ilia Alshanetsky)
3636

37+
- Streams:
38+
. Fixed bug GH-22063 (Stream filter chain UAF on self-removal during
39+
callback). (Ilia Alshanetsky)
40+
3741
05 Nov 2026, PHP 8.6.0RC5
3842

3943
- Core:

‎ext/standard/streamsfuncs.c‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1394,7 +1394,23 @@ PHP_FUNCTION(stream_filter_remove)
13941394
RETURN_THROWS();
13951395
}
13961396

1397-
if (php_stream_filter_flush(filter, 1) == FAILURE) {
1397+
if (!filter->chain || !filter->chain->stream) {
1398+
php_error_docref(NULL, E_WARNING, "Unable to flush filter, not removing");
1399+
RETURN_FALSE;
1400+
}
1401+
1402+
php_stream *stream = filter->chain->stream;
1403+
uint32_t no_remove_flag = filter->chain == &stream->readfilters ?
1404+
PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE : PHP_STREAM_FLAG_NO_WRITE_FILTER_REMOVE;
1405+
if (stream->flags & no_remove_flag) {
1406+
php_error_docref(NULL, E_WARNING, "Cannot remove filter while it is being applied");
1407+
RETURN_FALSE;
1408+
}
1409+
1410+
stream->flags |= no_remove_flag;
1411+
zend_result flushed = php_stream_filter_flush(filter, 1);
1412+
stream->flags &= ~no_remove_flag;
1413+
if (flushed == FAILURE) {
13981414
php_error_docref(NULL, E_WARNING, "Unable to flush filter, not removing");
13991415
RETURN_FALSE;
14001416
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
--TEST--
2+
GH-22063 (Stream filter chain UAF via self-removal during callback)
3+
--FILE--
4+
<?php
5+
class SelfRemovingFilter extends php_user_filter {
6+
public $stream;
7+
public static ?string $key = null;
8+
public static ?string $other_key = null;
9+
public static bool $on_closing_only = false;
10+
11+
public function filter($in, $out, &$consumed, $closing): int
12+
{
13+
while ($bucket = stream_bucket_make_writeable($in)) {
14+
$consumed += $bucket->datalen;
15+
stream_bucket_append($out, $bucket);
16+
}
17+
if (self::$key !== null && (!self::$on_closing_only || $closing)) {
18+
$res = $GLOBALS[self::$key];
19+
$other = self::$other_key !== null ? $GLOBALS[self::$other_key] : null;
20+
self::$key = null;
21+
self::$other_key = null;
22+
var_dump(stream_filter_remove($res));
23+
if ($other) {
24+
var_dump(stream_filter_remove($other));
25+
}
26+
}
27+
return PSFS_PASS_ON;
28+
}
29+
}
30+
31+
stream_filter_register('self-removing', SelfRemovingFilter::class);
32+
33+
echo "write side:\n";
34+
$f = fopen('php://memory', 'r+');
35+
SelfRemovingFilter::$key = 'write_res';
36+
SelfRemovingFilter::$on_closing_only = false;
37+
$GLOBALS['write_res'] = stream_filter_append($f, 'self-removing', STREAM_FILTER_WRITE);
38+
fwrite($f, 'hello');
39+
fwrite($f, ' world');
40+
rewind($f);
41+
echo stream_get_contents($f), "\n";
42+
43+
echo "cross-filter side:\n";
44+
$f = fopen('php://memory', 'r+');
45+
SelfRemovingFilter::$key = 'cross_res';
46+
SelfRemovingFilter::$other_key = 'cross_other';
47+
SelfRemovingFilter::$on_closing_only = false;
48+
$GLOBALS['cross_res'] = stream_filter_append($f, 'self-removing', STREAM_FILTER_WRITE);
49+
$GLOBALS['cross_other'] = stream_filter_append($f, 'string.rot13', STREAM_FILTER_WRITE);
50+
fwrite($f, 'hello world');
51+
rewind($f);
52+
echo stream_get_contents($f), "\n";
53+
54+
echo "read side:\n";
55+
$f = fopen('php://memory', 'r+');
56+
fwrite($f, 'abcdefghij');
57+
rewind($f);
58+
SelfRemovingFilter::$key = 'read_res';
59+
SelfRemovingFilter::$on_closing_only = false;
60+
$GLOBALS['read_res'] = stream_filter_append($f, 'self-removing', STREAM_FILTER_READ);
61+
echo fread($f, 4), '|', fread($f, 6), "\n";
62+
63+
echo "closing-flush side:\n";
64+
$f = fopen('php://memory', 'r+');
65+
SelfRemovingFilter::$key = 'close_res';
66+
SelfRemovingFilter::$on_closing_only = true;
67+
$GLOBALS['close_res'] = stream_filter_append($f, 'self-removing', STREAM_FILTER_WRITE);
68+
var_dump(stream_filter_remove($GLOBALS['close_res']));
69+
?>
70+
--EXPECTF--
71+
write side:
72+
73+
Warning: stream_filter_remove(): Cannot remove filter while it is being applied in %s on line %d
74+
bool(false)
75+
hello world
76+
cross-filter side:
77+
78+
Warning: stream_filter_remove(): Cannot remove filter while it is being applied in %s on line %d
79+
bool(false)
80+
81+
Warning: stream_filter_remove(): Cannot remove filter while it is being applied in %s on line %d
82+
bool(false)
83+
uryyb jbeyq
84+
read side:
85+
86+
Warning: stream_filter_remove(): Cannot remove filter while it is being applied in %s on line %d
87+
bool(false)
88+
abcd|efghij
89+
closing-flush side:
90+
91+
Warning: stream_filter_remove(): Cannot remove filter while it is being applied in %s on line %d
92+
bool(false)
93+
bool(true)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
--TEST--
2+
GH-22063 (stream filter self-removal denied during a seek() callback)
3+
--FILE--
4+
<?php
5+
class SeekRemover extends php_user_filter {
6+
public function filter($in, $out, &$consumed, $closing): int {
7+
while ($b = stream_bucket_make_writeable($in)) {
8+
stream_bucket_append($out, $b);
9+
}
10+
return PSFS_PASS_ON;
11+
}
12+
public function seek(int $offset, int $whence, int $chain): bool {
13+
var_dump(stream_filter_remove($GLOBALS['f']));
14+
return true;
15+
}
16+
}
17+
stream_filter_register('seekremover', SeekRemover::class);
18+
19+
$fp = fopen('php://memory', 'r+');
20+
fwrite($fp, 'hello world data here');
21+
rewind($fp);
22+
$GLOBALS['f'] = stream_filter_append($fp, 'seekremover', STREAM_FILTER_READ);
23+
var_dump(fread($fp, 4));
24+
var_dump(fseek($fp, 0));
25+
echo "done\n";
26+
?>
27+
--EXPECTF--
28+
string(4) "hell"
29+
30+
Warning: stream_filter_remove(): Cannot remove filter while it is being applied in %s on line %d
31+
bool(false)
32+
int(0)
33+
done
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
--TEST--
2+
GH-22063 (stream filter removal denied from a wrapper write during its own removal flush)
3+
--FILE--
4+
<?php
5+
class HoldFilter extends php_user_filter {
6+
private string $buf = '';
7+
public function filter($in, $out, &$consumed, $closing): int {
8+
while ($b = stream_bucket_make_writeable($in)) {
9+
$this->buf .= $b->data;
10+
$consumed += $b->datalen;
11+
}
12+
if ($closing && $this->buf !== '') {
13+
stream_bucket_append($out, stream_bucket_new($this->stream, $this->buf));
14+
$this->buf = '';
15+
return PSFS_PASS_ON;
16+
}
17+
return PSFS_FEED_ME;
18+
}
19+
}
20+
class RemovingWrapper {
21+
public $context;
22+
public static bool $armed = false;
23+
public function stream_open($path, $mode, $options, &$opened) { return true; }
24+
public function stream_write($data) {
25+
if (self::$armed) {
26+
self::$armed = false;
27+
var_dump(stream_filter_remove($GLOBALS['w']));
28+
}
29+
return strlen($data);
30+
}
31+
}
32+
stream_filter_register('hold', HoldFilter::class);
33+
stream_wrapper_register('rmw', RemovingWrapper::class);
34+
$fp = fopen('rmw://x', 'w');
35+
$GLOBALS['w'] = stream_filter_append($fp, 'hold', STREAM_FILTER_WRITE);
36+
fwrite($fp, 'abc');
37+
RemovingWrapper::$armed = true;
38+
var_dump(stream_filter_remove($GLOBALS['w']));
39+
echo "done\n";
40+
?>
41+
--EXPECTF--
42+
43+
Warning: stream_filter_remove(): Cannot remove filter while it is being applied in %s on line %d
44+
bool(false)
45+
bool(true)
46+
done

‎main/php_streams.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ struct _php_stream_wrapper {
191191

192192
#define PHP_STREAM_FLAG_NO_IO 0x400
193193

194+
#define PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE 0x800
195+
#define PHP_STREAM_FLAG_NO_WRITE_FILTER_REMOVE 0x1000
196+
194197
#define PHP_STREAM_FLAG_WAS_WRITTEN 0x80000000
195198

196199
struct _php_stream {

‎main/streams/filter.c‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,11 @@ PHPAPI zend_result php_stream_filter_append_ex(php_stream_filter_chain *chain, p
373373

374374
bucket = php_stream_bucket_new(stream, (char*) stream->readbuf + stream->readpos, stream->writepos - stream->readpos, 0, 0);
375375
php_stream_bucket_append(brig_inp, bucket);
376+
uint32_t orig_no_remove = stream->flags & PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE;
377+
stream->flags |= PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE;
376378
status = filter->fops->filter(stream, filter, brig_inp, brig_outp, &consumed, PSFS_FLAG_NORMAL);
379+
stream->flags &= ~PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE;
380+
stream->flags |= orig_no_remove;
377381

378382
if (stream->readpos + consumed > (uint32_t)stream->writepos) {
379383
/* No behaving filter should cause this. */
@@ -467,16 +471,24 @@ PHPAPI zend_result php_stream_filter_flush(php_stream_filter *filter, bool finis
467471

468472
chain = filter->chain;
469473
stream = chain->stream;
474+
uint32_t no_remove_flag = chain == &stream->readfilters ?
475+
PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE : PHP_STREAM_FLAG_NO_WRITE_FILTER_REMOVE;
476+
uint32_t orig_no_remove = stream->flags & no_remove_flag;
477+
stream->flags |= no_remove_flag;
470478

471479
for(current = filter; current; current = current->next) {
472480
php_stream_filter_status_t status;
473481

474482
status = current->fops->filter(stream, current, inp, outp, NULL, flags);
475483
if (status == PSFS_FEED_ME) {
476484
/* We've flushed the data far enough */
485+
stream->flags &= ~no_remove_flag;
486+
stream->flags |= orig_no_remove;
477487
return SUCCESS;
478488
}
479489
if (status == PSFS_ERR_FATAL) {
490+
stream->flags &= ~no_remove_flag;
491+
stream->flags |= orig_no_remove;
480492
return FAILURE;
481493
}
482494
/* Otherwise we have data available to PASS_ON
@@ -489,6 +501,8 @@ PHPAPI zend_result php_stream_filter_flush(php_stream_filter *filter, bool finis
489501

490502
flags = PSFS_FLAG_NORMAL;
491503
}
504+
stream->flags &= ~no_remove_flag;
505+
stream->flags |= orig_no_remove;
492506

493507
/* Last filter returned data via PSFS_PASS_ON
494508
Do something with it */

‎main/streams/streams.c‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,8 @@ PHPAPI zend_result php_stream_fill_read_buffer(php_stream *stream, size_t size)
493493
}
494494

495495
/* wind the handle... */
496+
uint32_t orig_no_remove = stream->flags & PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE;
497+
stream->flags |= PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE;
496498
for (filter = stream->readfilters.head; filter; filter = filter->next) {
497499
status = filter->fops->filter(stream, filter, brig_inp, brig_outp, NULL, flags);
498500

@@ -508,6 +510,8 @@ PHPAPI zend_result php_stream_fill_read_buffer(php_stream *stream, size_t size)
508510
brig_outp = brig_swap;
509511
memset(brig_outp, 0, sizeof(*brig_outp));
510512
}
513+
stream->flags &= ~PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE;
514+
stream->flags |= orig_no_remove;
511515

512516
switch (status) {
513517
case PSFS_PASS_ON:
@@ -1171,6 +1175,8 @@ static ssize_t php_stream_write_filtered(php_stream *stream, const char *buf, si
11711175
php_stream_bucket_append(&brig_in, bucket);
11721176
}
11731177

1178+
uint32_t orig_no_remove = stream->flags & PHP_STREAM_FLAG_NO_WRITE_FILTER_REMOVE;
1179+
stream->flags |= PHP_STREAM_FLAG_NO_WRITE_FILTER_REMOVE;
11741180
for (php_stream_filter *filter = stream->writefilters.head; filter; filter = filter->next) {
11751181
/* for our return value, we are interested in the number of bytes consumed from
11761182
* the first filter in the chain */
@@ -1188,6 +1194,8 @@ static ssize_t php_stream_write_filtered(php_stream *stream, const char *buf, si
11881194
brig_outp = brig_swap;
11891195
memset(brig_outp, 0, sizeof(*brig_outp));
11901196
}
1197+
stream->flags &= ~PHP_STREAM_FLAG_NO_WRITE_FILTER_REMOVE;
1198+
stream->flags |= orig_no_remove;
11911199

11921200
switch (status) {
11931201
case PSFS_PASS_ON:
@@ -1323,17 +1331,25 @@ static bool php_stream_are_filters_seekable(php_stream_filter *filter, bool is_s
13231331
static zend_result php_stream_filters_seek(php_stream *stream, php_stream_filter *filter,
13241332
bool is_start_seeking, zend_off_t offset, int whence, int chain_type)
13251333
{
1334+
uint32_t no_remove_flag = chain_type == PHP_STREAM_FILTER_READ ?
1335+
PHP_STREAM_FLAG_NO_READ_FILTER_REMOVE : PHP_STREAM_FLAG_NO_WRITE_FILTER_REMOVE;
1336+
uint32_t orig_no_remove = stream->flags & no_remove_flag;
1337+
stream->flags |= no_remove_flag;
13261338
while (filter) {
13271339
php_stream_filter_seekable_t seekable = (chain_type == PHP_STREAM_FILTER_READ) ?
13281340
filter->read_seekable : filter->write_seekable;
13291341
if (((seekable == PSFS_SEEKABLE_START && is_start_seeking) ||
13301342
seekable == PSFS_SEEKABLE_CHECK) &&
13311343
filter->fops->seek(stream, filter, offset, whence) == FAILURE) {
13321344
php_error_docref(NULL, E_WARNING, "Stream filter seeking for %s failed", filter->fops->label);
1345+
stream->flags &= ~no_remove_flag;
1346+
stream->flags |= orig_no_remove;
13331347
return FAILURE;
13341348
}
13351349
filter = filter->next;
13361350
}
1351+
stream->flags &= ~no_remove_flag;
1352+
stream->flags |= orig_no_remove;
13371353
return SUCCESS;
13381354
}
13391355

0 commit comments

Comments
 (0)