Skip to content

Use-after-free when a destructor runs during compound assignment ($obj->prop .= ...) #24728

Closed as not planned

Description

@IzaiahSun

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

  1. devnexen commented on Oct 26, 2026

    @devnexen
    Member

    might be another instance of #20001

  2. IzaiahSun commented on Oct 26, 2026

    @IzaiahSun
    Author

    2. might be another instance of #20001

    Yes — same class: zval_ptr_dtor() runs while the pointer is still in the slot. Happy for
    this 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:1723
    • zend_binary_assign_op_typed_ref — Zend/zend_execute.c:1703
    • concat_function — Zend/zend_operators.c:2089

    The third one drops the old value inside concat_function, in the result == op1
    in-place branch:

    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);

    It is reached through zend_binary_op (zend_execute.c:1652) from
    ZEND_ASSIGN_OBJ_OP, so a fix that only changes how the assign handlers write the property
    may 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 for
    zend_assign_to_typed_ref"), which stashes the old value in garbage and releases it after the
    slot has been written. Plain assignment is already correct for that reason, which is why

    $c->p = "replacement";   // fine
    $c->p .= "tail";         // crashes

    differ.

  3. ndossche commented on Oct 26, 2026

    @ndossche
    Member

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions