Repository navigation
Use-after-free when a destructor runs during compound assignment ($obj->prop .= ...) #24728
Description
Description
The following code:
<?php
class Old {
public function __toString(): string { return "old"; }
public function __destruct() { $GLOBALS['c'] = null; }
}
class C { public mixed $p; }
$c = new C;
$c->p = new Old;
$c->p .= "tail";Resulted in this output:
==72016==ERROR: AddressSanitizer: heap-use-after-free on address 0x604000029190 at pc 0x000105acb2f4 bp 0x00016b34fe80 sp 0x00016b34fe78
READ of size 4 at 0x604000029190 thread T0
#0 zend_gc_delref zend_types.h:1370
#1 zend_objects_store_del zend_objects_API.c:182
#2 rc_dtor_func zend_variables.c:57
#3 i_zval_ptr_dtor zend_variables.h:45
#4 zval_ptr_dtor zend_variables.c:84
#5 zend_binary_assign_op_typed_prop zend_execute.c:1723
#6 ZEND_ASSIGN_OBJ_OP_SPEC_CV_CONST_HANDLER zend_vm_execute.h:42677
#7 execute_ex zend_vm_execute.h:58729
#8 zend_execute zend_vm_execute.h:64381
#9 zend_execute_script zend.c:1943
#10 php_execute_script_ex main.c:2623
freed by thread T0 here:
#0 free(libclang_rt.asan_osx_dynamic.dylib:arm64e)
#1 __zend_free zend_alloc.c:3728
#2 _efree zend_alloc.c:2889
#3 zend_objects_store_del zend_objects_API.c:200
#4 zend_object_release zend_objects_API.h:77
#5 zend_objects_destroy_object zend_objects.c:209
#6 zend_objects_store_del zend_objects_API.c:181
#7 rc_dtor_func zend_variables.c:57
#8 i_zval_ptr_dtor zend_variables.h:45
#9 zval_ptr_dtor zend_variables.c:84
#10 zend_binary_assign_op_typed_prop zend_execute.c:1723
SUMMARY: AddressSanitizer: heap-use-after-free zend_types.h:1370 in zend_gc_delref
But I expected this output instead:
(no output, clean exit)
Debug + ASan build, run with USE_ZEND_ALLOC=0.
Compound assignment to a property whose current value is an object drops that object's
refcount while the pointer is still in the slot, so the destructor can free the slot before
the result is stored.
Three paths are affected. The one above is zend_binary_assign_op_typed_prop
(Zend/zend_execute.c:1723). The other two:
zend_binary_assign_op_typed_ref, Zend/zend_execute.c:1703
<?php
class Old {
public function __toString(): string { return "old"; }
public function __destruct() { unset($GLOBALS['r']); $GLOBALS['holder'] = null; }
}
class C { public mixed $p; }
$holder = new C;
$holder->p = new Old;
$r = &$holder->p;
$r .= "tail";concat_function, Zend/zend_operators.c:2089 — the in-place concat path:
if (result == op1) {
/* Destroy the old result first to drop the refcount, such that $x .= ...; may happen in-place. */
if (free_op1_string) {
/* op1_string will be used as the result, so we should not free it */
i_zval_ptr_dtor(result);<?php
class Old {
public function __toString(): string { return "old"; }
public function __destruct() { $GLOBALS['c'] = null; }
}
class C { public $p; }
$c = new C;
$c->p = new Old;
$c->p .= "tail";Both produce the same report with frame #5 replaced accordingly.
Plain assignment is already correct, which makes a useful contrast — this does not crash:
<?php
class Old { public function __destruct() { $GLOBALS['c'] = null; } }
class C { public $p; }
$c = new C;
$c->p = new Old;
$c->p = "replacement";because zend_assign_to_typed_ref was changed in b39107c774 ("Delay destructor for
zend_assign_to_typed_ref") to stash the old value in garbage and release it only after the
slot has been written. The same treatment does not appear on the three paths above.
PHP Version
PHP 8.4.28-dev (cli) (built: Oct 25 2026 01:02:13) (NTS DEBUG)
Also reproduced on master at 29b5e168bf.
PHP Version
PHP 8.4.28-dev (cli) (built: Oct 25 2026 01:02:13) (NTS DEBUG)
Copyright (c) The PHP Group
Zend Engine v4.4.28-dev, Copyright (c) Zend Technologies
PHP 8.7.0-dev (cli) (built: Oct 24 2026 21:13:17) (NTS DEBUG)
Copyright © The PHP Group and Contributors
Zend Engine v4.7.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.7.0-dev, Copyright ©, by Zend by Perforce
Operating System
macOS 27.0.1 (arm64)
Activity
devnexen commentedon Oct 26, 2026
might be another instance of #20001
IzaiahSun commentedon Oct 26, 2026
Yes — same class:
zval_ptr_dtor()runs while the pointer is still in the slot. Happy forthis to be tracked under #20001.
One detail from the report that may be worth keeping for the general fix: the three paths are
not all in a property-write path.
zend_binary_assign_op_typed_prop—Zend/zend_execute.c:1723zend_binary_assign_op_typed_ref—Zend/zend_execute.c:1703concat_function—Zend/zend_operators.c:2089The third one drops the old value inside
concat_function, in theresult == op1in-place branch:
It is reached through
zend_binary_op(zend_execute.c:1652) fromZEND_ASSIGN_OBJ_OP, so a fix that only changes how the assign handlers write the propertymay or may not cover it depending on whether the property slot can still be passed as
result. Worth checking against all three.For reference, the existing local precedent is
b39107c774("Delay destructor forzend_assign_to_typed_ref"), which stashes the old value in
garbageand releases it after theslot has been written. Plain assignment is already correct for that reason, which is why
differ.
ndossche commentedon Oct 26, 2026
Since this is the same class as GH-20001 and #20319, I'm going to close this as a duplicate.
We already have similar reproducers and will normally in the near future vote on a general solution for this issue.