Skip to content

8.4.27 / 8.5.12 regression: GC double-frees the SimpleXMLElement properties table (zend_mm_heap corrupted), introduced by the GH-24050 fix #24488

Closed

Description

@evankford

Description

Description

The following code:

<?php
for ($i = 0; $i < 2; $i++) {
    $xml = new SimpleXMLElement('<a><b/></a>');
    get_object_vars($xml);

    $holder = new stdClass;
    $holder->xml = $xml;
    $holder->self = $holder;
    unset($xml, $holder);

    gc_collect_cycles();
    $junk[] = str_repeat('x', 100);
}
echo "done\n";

Resulted in this output:

zend_mm_heap corrupted

But I expected this output instead:

done

It fails every time on 8.4.27 and 8.5.12 (official php:*-cli Docker images, aarch64) and on a macOS NTS build of 8.4.27. It passes on 8.4.26.

Each of these is required to trigger it:

  • Something that materializes the SimpleXML properties table (get_object_vars(), an (array) cast, or json_encode()). foreach or plain property access does not trigger it.
  • The SimpleXMLElement must be reachable from a cycle that the GC collects.

With the system allocator, a single GC run is enough. Here is a one-shot version:

<?php
$xml = new SimpleXMLElement('<a><b/></a>');
get_object_vars($xml);
$holder = new stdClass;
$holder->xml = $xml;
$holder->self = $holder;
unset($xml, $holder);
gc_collect_cycles();
$ USE_ZEND_ALLOC=0 php one-shot.php
free(): double free detected in tcache 2

Valgrind output (8.4.27 built with -g -O2, USE_ZEND_ALLOC=0):

Invalid read of size 4
   at zend_gc_collect_cycles (zend_gc.c:2070)
   by zif_gc_collect_cycles (zend_builtin_functions.c:124)
 Address 0xb4b0324 is 4 bytes inside a block of size 56 free'd
   at free
   by zend_gc_collect_cycles (zend_gc.c:2081)        <- obj->handlers->free_obj(obj)
 Block was alloc'd at
   by _zend_new_array_0 (zend_hash.c:327)
   by sxe_get_prop_hash (simplexml.c:1096)
   by zend_std_get_properties_for (zend_object_handlers.c:2643)
   by zif_get_object_vars (zend_builtin_functions.c:801)

Invalid write of size 4
   at zend_gc_collect_cycles (zend_gc.c:2089)
   ... (26 invalid accesses total)

Bisected cause: the hunk added to gc_collect_white() by 7b75d71 (GH-24050, PR #24061):

if (GC_REF_CHECK_COLOR(ht, GC_WHITE)) {
    GC_REF_SET_BLACK(ht);
+   if (!GC_INFO(ht)) {
+       gc_add_garbage((zend_refcounted *)ht);
+   }

I built 8.4.27 from the release tarball twice. The unmodified build crashes. The same build with only those three lines removed prints done and is clean under valgrind.

The table that get_gc returns is now added to the garbage buffer. sxe_get_gc() returns sxe->properties, a table that SimpleXML owns. sxe_object_free_storage() releases it with zend_hash_release() while the GC is freeing the object. The GC then reaches the table's own garbage slot and reads, destroys and frees memory that has already been freed.

Using the same pattern with stdClass (dynamic properties), ArrayObject and DateTime is clean under valgrind. Any other extension whose get_gc returns a table it owns and releases itself may be affected the same way.

Real-world impact: our Laravel/Pest suite crashes paratest workers with zend_mm_heap corrupted (exit 134) on 8.4.27. Mockery expectations return collections of SimpleXMLElements, the mock and its expectations form a cycle, and PHPUnit's comparator/exporter reads all properties of the elements. We have pinned CI to 8.4.26.

PHP Version

PHP 8.4.27 (cli) (built: Oct 21 2026 15:54:26) (NTS)
Copyright (c) The PHP Group
Built by https://github.com/docker-library/php
Zend Engine v4.4.27, Copyright (c) Zend Technologies
    with Zend OPcache v8.4.27, Copyright (c), by Zend Technologies

Also reproduces on 8.5.12 (php:8.5-cli). Not reproducible on 8.4.26.

Operating System

Debian (official php:8.4.27-cli Docker image, aarch64); also macOS (Laravel Herd build)

Activity

  1. self-assigned this
    on Oct 21, 2026
  2. evankford commented on Oct 21, 2026

    @evankford
    ContributorAuthor

    @devnexen

    I have a proposed fix, one line in SimpleXML, replacing zend_hash_release(sxe->properties) with zend_array_release(sxe->properties);

    I am working on the tests/repro off of 8.4, would you like to take it over or I can try to get a PR up?

  3. devnexen commented on Oct 21, 2026

    @devnexen
    Member

    I came to the same conclusion but I m still testing it, if confirmed I ll mention you in the NEWS.

  4. added a commit that references this issue on Oct 21, 2026
    1cf7720
  5. evankford commented on Oct 21, 2026

    @evankford
    ContributorAuthor

    I've opened #24494 with a proposed fix. The analysis below was written with an LLM (Claude) and checked by me.

    Root cause: sxe_object_free_storage() frees sxe->properties with zend_hash_release(), which never calls GC_REMOVE_FROM_BUFFER(). Since the GH-24050 fix, gc_collect_white() puts the table returned by get_gc into the garbage buffer, so the GC later reads and frees an already-freed table (confirmed with gdb and valgrind).

    Fix: use zend_array_release(), which goes through zend_array_destroy() and unlinks the table first. A one-line change, plus a regression test.

    Possible engine hardening, for maintainers to decide: third-party extensions whose get_gc returns a table they free with zend_hash_release() or FREE_HASHTABLE() would hit the same crash. Two options both fixed the repro and kept GH-24050 fixed:

    1. Add GC_REMOVE_FROM_BUFFER(array) to zend_hash_release(). Third-party extensions would need a rebuild to pick it up, since it is inline.
    2. Only gc_add_garbage() the table when it is obj->properties, the case GC frees an object still held by a resurrected closure when (object) and use share an array #24050 needed.
  6. evankford commented on Oct 21, 2026

    @evankford
    ContributorAuthor

    I got the repro and fix into a simple PR, I think.

    No pressure to use my PR, obviously. Thanks for checking this out, and let me know if I can help in any way.

  7. datasage commented on Oct 21, 2026

    @datasage

    I did see this start to fail in one of my test suites after the update today. I am not sure this has caused any production impacts yet, still investigating.

  8. steven-actual commented on Oct 22, 2026

    @steven-actual

    Confirming this regression from a second, unrelated code base, on a different architecture and build than the original report.
    Our production CIs on our self-hosted runners (Ubuntu 24.04) are impacted

    zend_mm_heap corrupted
    
    In Process.php line 498:
    
      The process has been signaled with signal "6". 

    ⚠️ This report was produced by Claude Fable 5.1 and was human-read to confirm the environment and verify that it contains no corporate sensitive data.

    Environment

    • PHP 8.5.12 NTS CLI, x86_64, Debian 13 (trixie), DEB.SURY.ORG packages php8.5-cli 8.5.12-1+0~20261021.27+debian13~1.gbp1a3e8a, built Oct 21 2026 08:09:42
    • OPcache not loaded on CLI (opcache.enable_cli=0), JIT disabled; also reproduced with opcache.enable_cli=1 + opcache.jit=tracing
    • Reproduced with and without pcov (pcov.enabled=0) and with Xdebug 3.5.3 loaded (xdebug.mode=off and coverage)
    • Last known good: PHP 8.5.9 NTS, same packaging (same code, same tests, same database). 8.5.10 and 8.5.11 were not available to test, but the hunk blamed in the original report (GC frees an object still held by a resurrected closure when (object) and use share an array #24050, commit 7b75d71, merged Oct 2 2026) shipped in 8.5.12 and 8.4.27 only.

    Results

    Build gh24488.phpt reproducer from #24494 Laravel test sequence (7 tests, PHPUnit 13.0.6)
    PHP 8.5.9 prints done 7 passed
    PHP 8.5.12 zend_mm_heap corrupted, exit 139 zend_mm_heap corrupted, SIGABRT
    PHP 8.5.12, zend.enable_gc=0 n/a 7 passed
    PHP 8.5.12, USE_ZEND_ALLOC=0 n/a 7 passed under glibc malloc, double free visible under valgrind (below)

    Real-world trigger

    No get_object_vars() or gc_collect_cycles() in user code. The chain is:

    1. A Mockery mock returns a SimpleXMLElement from a mocked SOAP client (the SimpleXMLElement is held by the mock's expectation object, which sits in a cycle: container -> mock -> expectation -> return value).
    2. A Laravel controller returns that SimpleXMLElement as the HTTP response; the framework does (array) / json_encode() on it, which materializes sxe->properties through zend_std_get_properties_for().
    3. At test teardown the mock cycle is released; gc_possible_root_when_full() runs zend_gc_collect_cycles(), the SimpleXMLElement is destroyed inside the collection and sxe_object_free_storage() releases the properties table with zend_hash_release(). The table was also added to the GC buffer by the new gc_collect_white() hunk, so the collector frees it a second time.
    4. The crash itself surfaces a few hundred allocations later, when the corrupted 56-byte free list is hit (here while constructing an exception, zend_fetch_debug_backtrace -> _emalloc_56).

    So any application that casts or JSON-encodes a SimpleXMLElement that later dies inside a GC run is affected, not only code calling get_object_vars().

    gdb backtrace of the abort (8.5.12, pcov disabled, php8.5-cli-dbgsym)

    #4  zend_mm_panic (message="zend_mm_heap corrupted") at ./Zend/zend_alloc.c:404
    #5  zend_mm_get_next_free_slot (heap=<optimized out>, bin_num=6, slot=<optimized out>) at ./Zend/zend_alloc.c:1456
    #6  zend_mm_alloc_small (heap=<optimized out>, bin_num=6) at ./Zend/zend_alloc.c:1545
    #7  _emalloc_56 () at ./Zend/zend_alloc.c:2840
    #8  _zend_new_array_0 () at ./Zend/zend_hash.c:326
    #9  zend_fetch_debug_backtrace (return_value=0x7fffffffb200, skip_last=0, options=<optimized out>, limit=0) at ./Zend/zend_builtin_functions.c:2092
    #10 zend_default_exception_new (class_type=0x7fffdfe0a8d0) at ./Zend/zend_exceptions.c:299
    #11 _object_and_properties_init (arg=0x7ffff6e232b0, class_type=0x7fffdfe0a8d0, properties=0x0) at ./Zend/zend_API.c:1863
    #12 object_init_ex (arg=0x7ffff6e232b0, class_type=0x7fffdfe0a8d0) at ./Zend/zend_API.c:1877
    #13 ZEND_NEW_SPEC_CONST_UNUSED_HANDLER () at ./Zend/zend_vm_execute.h:11316
    #14 execute_ex (ex=0x10) at ./Zend/zend_vm_execute.h:117699
    #15 zend_execute (op_array=0x7ffff6e98000, return_value=0x0) at ./Zend/zend_vm_execute.h:122038
    #16 zend_execute_script (type=8, retval=0x0, file_handle=0x7fffffffd840) at ./Zend/zend.c:1990
    #17 php_execute_script_ex (primary_file=<optimized out>, retval=0x0) at ./main/main.c:2688
    #18 php_execute_script (primary_file=<optimized out>) at ./main/main.c:2728
    #19 do_cli (argc=11, argv=0x555555ff8490) at ./sapi/cli/php_cli.c:960
    #20 main (argc=11, argv=0x555555ff8490) at ./sapi/cli/php_cli.c:1383
    

    valgrind (8.5.12, USE_ZEND_ALLOC=0 ZEND_DONT_UNLOAD_MODULES=1, pcov disabled)

    First invalid access, the collector touching the properties table after SimpleXML released it:

    Invalid read of size 4
       at 0x6B4B63: zend_gc_collect_cycles (zend_gc.c:2131)
       by 0x6B24F6: gc_possible_root_when_full (zend_gc.c:702)
       by 0x6AE1B4: zend_object_release (zend_objects_API.h:79)
       by 0x6AE1B4: ZEND_DO_FCALL_SPEC_RETVAL_USED_HANDLER (zend_vm_execute.h:2197)
       by 0x6AE1B4: execute_ex (zend_vm_execute.h:116600)
       by 0x6A3A64: zend_execute (zend_vm_execute.h:122038)
     Address 0x15b19e54 is 4 bytes inside a block of size 56 free'd
       at 0x484787F: free (vg_replace_malloc.c:989)
       by 0x6F30F6: zend_objects_store_del (zend_objects_API.c:196)
       by 0x6BEBDB: i_zval_ptr_dtor (zend_variables.h:45)
       by 0x6BEBDB: zend_array_destroy (zend_hash.c:2053)
       by 0x6F3390: zend_object_dtor_property (zend_objects.c:65)
       by 0x6F3390: zend_object_std_dtor (zend_objects.c:95)
       by 0x6B4D00: zend_gc_collect_cycles (zend_gc.c:2142)
       by 0x6B24F6: gc_possible_root_when_full (zend_gc.c:702)
     Block was alloc'd at
       at 0x4844818: malloc (vg_replace_malloc.c:446)
       by 0x60962C: __zend_malloc (zend_alloc.c:3947)
       by 0x6B827C: _zend_new_array_0 (zend_hash.c:326)
       by 0xC7C2A64: ??? (in /usr/lib/php/20250925/simplexml.so)
       by 0x6F2D7C: zend_std_get_properties_for (zend_object_handlers.c:2633)
       by 0x6679CA: zend_cast_zval_to_array (zend_execute.h:271)
       by 0x6679CA: ZEND_CAST_SPEC_CV_HANDLER (zend_vm_execute.h:42113)
    

    Followed by an Invalid write of size 4 at zend_gc_collect_cycles (zend_gc.c:2150) on the same block, and the double free of the table's bucket storage:

    Invalid free() / delete / delete[] / realloc()
       at 0x484787F: free (vg_replace_malloc.c:989)
       by 0x6B4B8D: zend_gc_collect_cycles (zend_gc.c:2156)
       by 0x6B24F6: gc_possible_root_when_full (zend_gc.c:702)
       by 0x6AE1B4: zend_object_release (zend_objects_API.h:79)
     Address 0x155179f0 is 0 bytes inside a block of size 320 free'd
       at 0x484787F: free (vg_replace_malloc.c:989)
       by 0xC7C0AF7: ??? (in /usr/lib/php/20250925/simplexml.so)
       by 0x6F30F6: zend_objects_store_del (zend_objects_API.c:196)
       by 0x6BEBDB: i_zval_ptr_dtor (zend_variables.h:45)
       by 0x6BEBDB: zend_array_destroy (zend_hash.c:2053)
       by 0x6F3390: zend_object_dtor_property (zend_objects.c:65)
       by 0x6F3390: zend_object_std_dtor (zend_objects.c:95)
       by 0x6B4D00: zend_gc_collect_cycles (zend_gc.c:2142)
     Block was alloc'd at
       at 0x4844818: malloc (vg_replace_malloc.c:446)
       by 0x60962C: __zend_malloc (zend_alloc.c:3947)
       by 0x6B8B39: zend_hash_real_finit_mixed_ex (zend_hash.c:192)
       by 0x6B8B39: zend_hash_real_finit_mixed (zend_hash.c:456)
       by 0x6BAC22: _zend_hash_add_or_update_i (zend_hash.c:991)
       by 0x6BAC22: zend_hash_lookup (zend_hash.c:1191)
       by 0xC7BF3E8: ??? (in /usr/lib/php/20250925/simplexml.so)
       by 0xC7C2D33: ??? (in /usr/lib/php/20250925/simplexml.so)
       by 0x6F2D7C: zend_std_get_properties_for (zend_object_handlers.c:2633)
       by 0x6679CA: zend_cast_zval_to_array (zend_execute.h:271)
    

    (The simplexml.so frames are unresolved because DEB.SURY.ORG ships no dbgsym for php8.5-xml; the .c frames come from php8.5-cli-dbgsym.)

    Note on the fix

    The one-line change in #24494 (zend_hash_release -> zend_array_release in sxe_object_free_storage) fixes SimpleXML, but the engine-level hardening suggested there seems worth doing as well: after the GH-24050 hunk, every get_gc handler that returns a table it owns and later frees with zend_hash_release() has the same double free. This also affects production, not just test suites: any request that JSON-encodes a SimpleXMLElement that ends up in a collected cycle can abort the worker (FPM child), which is a trivial crash vector for 8.4.27 / 8.5.12 deployments.

  9. jogoossens commented on Oct 22, 2026

    @jogoossens

    @steven-actual in case it helps until a release ships the fix from #24494 (disclosure: I'm with Hosted Power, we maintain this): our open-source extension php_uploadprogress_guard v3.2.1 has the same effect as that one-line change, without rebuilding PHP. It wraps SimpleXML's free_obj handler so sxe->properties is released with zend_array_release() instead of zend_hash_release(), and only does so on 8.4.27 and 8.5.12. On an 8.4.27 staging host, a SimpleXML script that crashed with zend_mm_heap corrupted completes with it loaded. It is meant as a bridge until the fixed release is out.

  10. added 4 commits that reference this issue on Oct 22, 2026
    f963484
    5bd92da
    e4576ef
    05d164f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions