Skip to content

Fix GH-24579: Crash in FILTER_VALIDATE_INT when __toString() mutates the options - #24580

Open
bercedev wants to merge 2 commits into
php:PHP-8.4from
bercedev:fix/gh24579-filter-options-reentry
Open

bercedev wants to merge 2 commits into
php:PHP-8.4from
bercedev:fix/gh24579-filter-options-reentry

Conversation

@bercedev

Copy link
Copy Markdown
Contributor

Fixes #24579

php_filter_call() kept options pointing at the zval inside the filter args (through the reference in the repro). For an object value, php_zval_filter() converts it to a string first, which runs __toString(); reassigning the referenced options there frees the array, and the filter then reads it (FETCH_LONG_OPTION on what is now a string). The call now holds its own counted copy of the options zval and releases it on every exit, so the filter sees the options as they were when it was called.

Added ext/filter/tests/gh24579.phpt with the repro from the issue: it fails on PHP-8.4 (debug build aborts with the inconsistent hash table assertion) and passes with this. All ext/filter/tests pass on a debug build.

…es the options

php_filter_call() kept a pointer to the options zval inside the caller's array. When the value is an object, converting it to a string runs __toString(), which can replace the options through a reference, so the filter then read a freed array. Keep a counted copy of the options for the duration of the call.
@anun333

anun333 commented Oct 25, 2026

Copy link
Copy Markdown

Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.

Tested on debug builds of PHP-8.4 (51808ac5478) without and with this branch (b7f58887f89):

Case Without the patch With it
the issue's repro: __toString() turns the options into a string aborts: ht=0x… is being cleaned int(1)
the same through filter_var_array() aborts ["a" => int(1)]
__toString() swaps in ['min_range' => 5] bool(false) int(1)
the swapped-in array also has 'default' => 42 int(42) int(1)
  • gh24579.phpt fails without the patch and passes with it; ext/filter/tests: 103 passed, 0 failed, 17 skipped.
  • filter_var_array() is fixed too, since it also goes through php_filter_call().
  • Every return after the ZVAL_COPY now goes through out:, and the debug build reports no leaks.

The last two rows are a behaviour change that may be worth pinning in the test: the filter now uses the options as they were passed, where before it read whatever __toString() left behind. That seems the right reading to me, and a second case in gh24579.phpt would make it deliberate.

#24619 fixes the same crash on master by checking the type in the FETCH_*_OPTION macros; there the last two rows stay bool(false) and int(42).

How this was tested

Builds (Linux x86_64, gcc 13.3), the same for both trees:

./buildconf --force
./configure --enable-debug --with-mysqli --disable-cgi --disable-phpdbg --disable-fileinfo
make -j6

Cases, each run as sapi/cli/php -n case.php:

<?php // the issue's repro
$shared = ['min_range' => 0]; $args = ['options' => &$shared];
class R { function __toString(): string { global $shared; $shared = 'changed'; return '1'; } }
var_dump(filter_var(new R, FILTER_VALIDATE_INT, $args));
<?php // through filter_var_array()
$shared = ['min_range' => 0];
class R { function __toString(): string { global $shared; $shared = 'changed'; return '1'; } }
var_dump(filter_var_array(['a' => new R], ['a' => ['filter' => FILTER_VALIDATE_INT, 'options' => &$shared]]));
<?php // another array; for the last row it is ['min_range' => 5, 'default' => 42]
$shared = ['min_range' => 0]; $args = ['options' => &$shared];
class R { function __toString(): string { global $shared; $shared = ['min_range' => 5]; return '1'; } }
var_dump(filter_var(new R, FILTER_VALIDATE_INT, $args));

Without the patch, the first two end with:

Zend/zend_hash.c(2920) : ht=0x… is being cleaned
php: Zend/zend_hash.c:74: _zend_is_inconsistent: Assertion `0' failed.

(exit 134). The suite: TEST_PHP_EXECUTABLE=sapi/cli/php sapi/cli/php run-tests.php -q -n ext/filter/tests.

@bercedev

Copy link
Copy Markdown
Contributor Author

Thanks for testing it. Agreed on pinning it, added the second case in 491341b: __toString() swaps in ['min_range' => 5] and the filter still returns int(1), since it uses the options as passed. Checked on a debug build: that case alone gives bool(false) without the patch and int(1) with it, and ext/filter/tests still passes (103 passed, 17 skipped).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants