From e3bd6c437c4ffbea06d1fa3311e9c73b1fd43213 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 21 Aug 2026 09:05:59 -0400 Subject: [PATCH] Fix GH-23332: HashTable iterator counter loses its saturation nIteratorsCount saturates at 0xff and must stay frozen once it does, because HT_HAS_ITERATORS() is what makes zend_array_destroy() poison stale iterators before the table is freed. zend_hash_iterator_pos_ex() decremented the old table's counter while gating that decrement on the new table's overflow state, so a saturated counter could drop back below 0xff, later reach zero with iterators still pointing at the table, and leave it freed unpoisoned. The seven other arithmetic sites already gate on the table they mutate, and the wholesale transfers in array_splice() and array_unshift() move the counter intact. Fixes GH-23332 --- NEWS | 2 ++ Zend/tests/gh23332.phpt | 45 +++++++++++++++++++++++++++++++++++++++++ Zend/zend_hash.c | 2 +- 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 Zend/tests/gh23332.phpt diff --git a/NEWS b/NEWS index 86b6bd3e2e0c..d95ec7b47c41 100644 --- a/NEWS +++ b/NEWS @@ -7,6 +7,8 @@ PHP NEWS next() call on the inner generator). (iliaal) . Fixed bug GH-23301 (Nested "yield from" yields a value twice when the middle generator delegates again). (Lazizbek Ergashev) + . Fixed bug GH-23332 (Saturation of the HashTable iterator counter leads to + UAF). (luizmenos, iliaal) - DOM: . Fixed a use-after-free when cloning a DOMNameSpaceNode after diff --git a/Zend/tests/gh23332.phpt b/Zend/tests/gh23332.phpt new file mode 100644 index 000000000000..9d58a2620217 --- /dev/null +++ b/Zend/tests/gh23332.phpt @@ -0,0 +1,45 @@ +--TEST-- +GH-23332 (Saturation of the HashTable iterator counter leads to UAF) +--FILE-- + 1, 'second' => 2, 'third' => 3]; + +// Saturate $a's iterator counter (it caps at 255). +$gens = []; +for ($i = 0; $i < 255; $i++) { + $g = gen($a); + $g->current(); + $gens[] = $g; +} +unset($g); + +$array = $a; + +$pass = 0; +$retained = null; +foreach ($array as $key => &$value) { + echo "pass ", ++$pass, ": $key => $value\n"; + if ($pass === 1) { + $retained = $array; + $array = ['replacement' => 4242]; + continue; + } + for ($i = 0; $i < 254; $i++) { + unset($gens[$i]); + } + $retained = null; + unset($gens[254]); +} + +echo "done\n"; +?> +--EXPECT-- +pass 1: first => 1 +pass 2: replacement => 4242 +done diff --git a/Zend/zend_hash.c b/Zend/zend_hash.c index 82d0318428fa..552ed0919753 100644 --- a/Zend/zend_hash.c +++ b/Zend/zend_hash.c @@ -632,7 +632,7 @@ ZEND_API HashPosition ZEND_FASTCALL zend_hash_iterator_pos_ex(uint32_t idx, zval ZEND_ASSERT(idx != (uint32_t)-1); if (UNEXPECTED(iter->ht != ht) && !zend_hash_iterator_find_copy_pos(idx, ht)) { if (EXPECTED(iter->ht) && EXPECTED(iter->ht != HT_POISONED_PTR) - && EXPECTED(!HT_ITERATORS_OVERFLOW(ht))) { + && EXPECTED(!HT_ITERATORS_OVERFLOW(iter->ht))) { HT_DEC_ITERATORS_COUNT(iter->ht); }