From a4f740710ad14ec8fd640b4922843c6e634764d7 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 9fbe37c9d27a..5f808e8d0746 100644 --- a/NEWS +++ b/NEWS @@ -26,6 +26,8 @@ PHP NEWS . Fixed bug GH-25311 (Assertion ret == SUCCESS in array_map()/array_filter() after delayed out-of-memory). (ndossche) . Fixed AST export of by-reference property hooks. (Jorg Sowa) + . Fixed bug GH-23332 (Saturation of the HashTable iterator counter leads to + UAF). (luizmenos, Ilia Alshanetsky) - Curl: . Fixed curl_reset() retaining CURLOPT_PRIVATE after reset. (lacatoire) 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 3d88d179f7cc..57c51ce2f69e 100644 --- a/Zend/zend_hash.c +++ b/Zend/zend_hash.c @@ -787,7 +787,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); }