The October 21 PHP security release fixed two critical memory-corruption bugs. It also shipped a new one. Within hours of the release, a regression in the garbage collector was crashing Laravel test suites and production workers on PHP 8.4.27 and 8.5.12 with zend_mm_heap corrupted. The bug is a double free in SimpleXML handling, it is confirmed by the PHP project, and it turns any application that JSON-encodes a SimpleXMLElement inside a garbage-collected cycle into a remote crash vector.

The report

The issue is php-src #24488, opened on October 21, 2026 by evankford and assigned to devnexen the same day. The reporter gave a minimal reproduction:


<?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";

On 8.4.27 and 8.5.12 it prints zend_mm_heap corrupted. On 8.4.26 it prints done. The reporter tested official php:*-cli Docker images on aarch64 and a macOS NTS build, and bisected the cause to a three-line hunk added to gc_collect_white() by commit 7b75d71 for GH-24050.

The bug was verified by the PHP project and a fix was proposed as PR #24494. A second, unrelated reporter confirmed it on October 22 from a production CI environment: PHP 8.5.12 NTS on x86_64 Debian 13, with OPcache disabled, JIT disabled, and Xdebug loaded or not. Their last known good build was PHP 8.5.9. The hunk merged on October 2 and shipped only in 8.4.27 and 8.5.12.

Why the collector frees the same table twice

SimpleXML does not store element properties in the standard object property table. It builds them lazily in sxe->properties, a hash table the extension owns. Accessing the element's properties through (array), get_object_vars() or json_encode() materializes that table. foreach and direct property access do not.

The GH-24050 fix was about a different bug: the collector could free an object that was still reachable from a resurrected closure. To handle it, gc_collect_white() began adding tables returned by an extension's get_gc handler to the garbage buffer:


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

For SimpleXML, sxe_get_gc() returns sxe->properties, and that table is owned by the extension, not by the engine. When the object is freed, sxe_object_free_storage() releases the table with zend_hash_release(), which does not call GC_REMOVE_FROM_BUFFER(). The collector then reaches the table's own garbage slot and reads, destroys and frees memory that has already been freed.

The package's gdb receipts pin both frees to the same 56-byte block in a single gc_collect_cycles() call: the first through the object free path at zend_gc.c:2142, the second through the garbage-entry tail efree at zend_gc.c:2170. A valgrind run on 8.5.12 shows the first invalid read at zend_gc.c:2131, an invalid write at zend_gc.c:2150, and the double free of the table's 320-byte bucket storage at zend_gc.c:2156.

Crash behaviour depends on the allocator

With the system allocator (USE_ZEND_ALLOC=0), glibc catches the double free immediately:


free(): double free detected in tcache 2

With PHP's default Zend memory manager, nothing fires at the point of the double free. The corrupted free list is only detected later, when the allocator is asked for another block in the same size class:


zend_mm_panic (message="zend_mm_heap corrupted") at Zend/zend_alloc.c:404
zend_mm_get_next_free_slot (heap=..., bin_num=6, slot=...) at Zend/zend_alloc.c:1456
zend_mm_alloc_small (heap=..., bin_num=6) at Zend/zend_alloc.c:1545
_emalloc_56 () at Zend/zend_alloc.c:2840
_zend_new_array_0 () at Zend/zend_hash.c:326
zend_fetch_debug_backtrace (...) at Zend/zend_builtin_functions.c:2092
zend_default_exception_new (...) at Zend/zend_exceptions.c:299

That last stack is from the second reporter. The crash surfaces several hundred allocations after the corruption, while PHP is constructing an exception. The process exits with SIGABRT, which a process supervisor sees as exit 134, or with SIGSEGV, 139, depending on where the corrupted bin is next touched.

Remote reachability is broader than the PoC

The minimal PoC calls gc_collect_cycles() explicitly. Real applications do not need to. The collector runs automatically when the root buffer fills, which means the only requirements are:

  • The application parses attacker-controlled XML with SimpleXML: RSS, Atom, sitemap, SOAP, XML-RPC, or any import feature.
  • It materializes the element's properties with an (array) cast, get_object_vars() or json_encode(), which is standard in XML-to-JSON conversion code.
  • The element ends up in a cycle that the collector frees, or the application accumulates enough roots to trigger a collection.

The second reporter's production trigger is instructive. A Mockery mock returned a SimpleXMLElement from a mocked SOAP client. The mock's expectation object sat in a cycle with the container. A Laravel controller returned the element as an HTTP response, and the framework cast it to an array. At test teardown the cycle was released, the collector ran, and the worker aborted. No get_object_vars() or explicit GC call appeared in application code.

For a web deployment the practical impact is a denial of service. A php-fpm child that aborts is replaced, so a single request kills one worker; a stream of requests kills them repeatedly. The package also demonstrates a remote kill against a one-file php -S victim that parses php://input, materializes properties and lets the element join a cycle. Warmup returns 200 OK, the triggering request kills the server process mid-request.

Why this is not (yet) an RCE

The package is careful about this, and the care is warranted. A double free is a powerful primitive, but turning it into control-flow hijack requires winning a second allocation of the same block while a live object still points into it.

On stock PHP 8.5 builds, ZendMM ships with ZEND_MM_HEAP_PROTECTION enabled. Each free slot stores a shadow checksum derived from the next pointer and a 64-bit per-heap random key. Every pop re-derives and compares the checksum. The package's gdb trace shows the first bin-56 pop succeeding, because the second free rewrote the next pointer and shadow consistently as a self-loop, and the second pop panicking in zend_mm_get_next_free_slot. That converts the classic double-pop overlay into an allocator panic.

The package therefore claims remote crash and silent heap-corruption state, not command execution, and lists the open escalation questions: engine writes into the freed block before an allocator pop, use-after-free field reads that influence control flow, and hosts running with the system allocator where fastbin and tcache behaviour differ. Two independent sources have now confirmed the crash. Neither has published a working RCE.

Fix and mitigation

The one-line fix proposed in PR #24494 replaces zend_hash_release(sxe->properties) with zend_array_release(sxe->properties), which routes through zend_array_destroy() and unlinks the table from the GC buffer first. That fixes SimpleXML.

The engine-level question is larger. Any third-party extension whose get_gc returns a table it owns and later frees with zend_hash_release() or FREE_HASHTABLE() has the same double free. The issue thread suggests two hardening options: add GC_REMOVE_FROM_BUFFER() to zend_hash_release(), which is inline and requires extensions to be rebuilt, or restrict gc_add_garbage() to obj->properties, the case GH-24050 actually needed.

Until a fixed release lands, the mitigations are unglamorous:

  • Pin 8.4.26 or an 8.5 build from before the GH-24050 hunk, and treat 8.4.27 and 8.5.12 as crash-prone.
  • Avoid casting or JSON-encoding a SimpleXMLElement that can participate in a cycle. Convert to arrays and drop the element reference before the request ends.
  • zend.enable_gc=0 suppresses the crash at the cost of unbounded cycle growth, which is a poor trade for long-running workers but can buy time during an incident.
  • Monitor for exit 134 and 139 from PHP workers, and for zend_mm_heap corrupted in stderr or the supervisor log. The string is distinctive.

The uncomfortable symmetry is worth stating plainly. The same release that fixed two memory-safety bugs in the engine introduced a third in the garbage collector, and the regression was found by a user's test suite within hours. That is a better outcome than the alternative, but it is not a validation of the patch process.