Description
Disclosure: this report was drafted with LLM assistance (Claude Sonnet 4.6 with cross-checks from GPT-5.4) per the request in CONTRIBUTING.md. The measurements — stack traces, disassembly, repro behavior, class identification, version comparison — are from my own system. The analytical framing and source-tree references were co-written.
GH-21600 was closed after the xsltCleanupGlobals removal in 8.4.20 (96e93e9), but the second backtrace in nwellnhof's comment on that issue is still 100% reproducible. I do not load ext/xsl here; the crash reaches the same area through a different path.
Minimal repro:
php -r 'proc_open(["date"], [], $pipes);'
Output:
Sun May 10 11:02:12 CEST 2026
Segmentation fault (core dumped)
Expected: clean exit, no signal.
The crash only triggers once enough extensions are loaded. With php -n: 0/10. With seven extensions: 4/10. With ~13 or more: 10/10. The trigger is memory-layout-sensitive — looks like a use-after-free that becomes deterministic once the global class table grows enough that the freed slot is reliably overwritten.
Symbolicated stack (PHP rebuilt from lang/php84 ports with STRIP=, otherwise unchanged):
frame #0: destroy_zend_class + 1228
frame #1: zend_hash_graceful_reverse_destroy + 395
frame #2: zend_shutdown + 58
frame #3: php_module_shutdown + 41
frame #4: main + 842
Faulting instruction:
0x6dc36c <+1228>: cmpq %rbx, 0x20(%r15) ; <- segv
0x6dc370 <+1232>: je 0x6dc386
0x6dc372 <+1234>: movq %r15, %rdi
0x6dc375 <+1237>: callq free
r15 = 0x6b588e9c404 in this dump — not 8-byte aligned, far below the live arena. Other live zend_class_entry pointers in the same core are all 0x6b524.../0x6b525.... Same fault-address low-bit pattern (...8e9c4XX) shows up in unrelated FreeBSD users' reports under bug 277888, so the poisoning seems deterministic rather than random heap garbage.
Offset 0x20 in zend_property_info (8.4 layout: offset, flags, name, doc_comment, attributes, ce) is prop_info->ce. The instruction sequence above matches the inner loop of the ZEND_INTERNAL_CLASS branch in Zend/zend_opcode.c::destroy_zend_class() around line 463:
ZEND_HASH_MAP_FOREACH_PTR(&ce->properties_info, prop_info) {
if (prop_info->ce == ce) {
zend_string_release(prop_info->name);
zend_type_release(prop_info->type, /* persistent */ 1);
if (prop_info->attributes) {
zend_hash_release(prop_info->attributes);
}
free(prop_info);
}
} ZEND_HASH_FOREACH_END();
So prop_info is the dangling pointer — a slot in ce->properties_info that points into freed memory. The crash is on the ce field read, not on free() itself.
I read the name field of the class being torn down at the time of the crash from a separate core (*(rbx + 0x08) to get zend_string *, then val[] at +0x18) and got Pdo\Pgsql — an internal class introduced in 8.4 by the PDO Driver-Specific Subclasses RFC, internal child of internal PDO. With pdo_pgsql not loaded but other extensions loaded, the same UAF surfaces in zend_function_dtor → zend_type_release instead. So Pdo\Pgsql isn't the unique culprit; it just happened to be the class whose properties_info slot got poisoned in that particular extension layout.
PHP 8.3.30 on the exact same FreeBSD 15 host with a comparable extension set: 0/10 crashes. So this is a regression introduced somewhere in 8.4.
I haven't pinned down where the dangling pointer is produced — that's why I'm filing rather than sending a PR. Best guesses, in Zend/zend_inheritance.c, ordered by my subjective likelihood:
inherit_property_hook() (new in 8.4) does child_info->hooks = zend_arena_alloc(&CG(arena), ...). Arena allocations are request-scoped. If child_info here is in some path actually a shared parent pointer (because the property wasn't redeclared and was _zend_hash_append_ptr'd in the else branch of do_inherit_property), this would write an arena-allocated value into a struct that survives until module shutdown.
zend_build_properties_info_table() got a new prototype-chain walking section in 8.4 that stores pointers into the flat properties_info_table. Could be storing references to structs that are later replaced.
do_inherit_property() else branch — both 8.3 and 8.4 alias the parent pointer here. Normally safe via the prop_info->ce == ce gate at shutdown, but 8.4 added bookkeeping (num_hooked_props, abstract-flag handling) and the zend_property_info struct itself grew with the prototype and hooks fields. If something later writes through one of those new fields assuming child ownership, the parent's struct gets corrupted.
These are guesses, not measurements. Input from someone who knows the inheritance code well would be more reliable than my source-tree archaeology.
About FreeBSD-specificity: probably not actually FreeBSD-only. jemalloc on FreeBSD junks freed chunks aggressively, so the UAF dereference cleanly faults at the first field read. glibc on Linux likely leaves the chunk readable for longer or recycles it into something else, so the crash either doesn't surface or shows up elsewhere. Worth someone with a Linux setup running the same proc_open repro under valgrind or ASAN with a representative extension load.
Background: GH-21600 was closed after 96e93e9. That fix doesn't apply here — ext/xsl isn't loaded. The stack in nwellnhof's comment on that issue is what I'm seeing. Same with FreeBSD bug 277888, marked FIXED via the 8.4.20 ports update that pulled the xsl commit.
PHP Version
PHP 8.4.20 (cli) (built: May 10 2026 10:49:00) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.4.20, Copyright (c) Zend Technologies
(Built from FreeBSD lang/php84 ports with STRIP= so symbols stay; otherwise default port options. All extensions loaded as php84-* system pkgs.)
Operating System
FreeBSD 15.0-RELEASE-p7
Description
GH-21600 was closed after the
xsltCleanupGlobalsremoval in 8.4.20 (96e93e9), but the second backtrace in nwellnhof's comment on that issue is still 100% reproducible. I do not loadext/xslhere; the crash reaches the same area through a different path.Minimal repro:
Output:
Expected: clean exit, no signal.
The crash only triggers once enough extensions are loaded. With
php -n: 0/10. With seven extensions: 4/10. With ~13 or more: 10/10. The trigger is memory-layout-sensitive — looks like a use-after-free that becomes deterministic once the global class table grows enough that the freed slot is reliably overwritten.Symbolicated stack (PHP rebuilt from
lang/php84ports withSTRIP=, otherwise unchanged):Faulting instruction:
r15 = 0x6b588e9c404in this dump — not 8-byte aligned, far below the live arena. Other livezend_class_entrypointers in the same core are all0x6b524.../0x6b525.... Same fault-address low-bit pattern (...8e9c4XX) shows up in unrelated FreeBSD users' reports under bug 277888, so the poisoning seems deterministic rather than random heap garbage.Offset 0x20 in
zend_property_info(8.4 layout:offset, flags, name, doc_comment, attributes, ce) isprop_info->ce. The instruction sequence above matches the inner loop of theZEND_INTERNAL_CLASSbranch inZend/zend_opcode.c::destroy_zend_class()around line 463:So
prop_infois the dangling pointer — a slot ince->properties_infothat points into freed memory. The crash is on thecefield read, not onfree()itself.I read the
namefield of the class being torn down at the time of the crash from a separate core (*(rbx + 0x08)to getzend_string *, thenval[]at +0x18) and gotPdo\Pgsql— an internal class introduced in 8.4 by the PDO Driver-Specific Subclasses RFC, internal child of internalPDO. Withpdo_pgsqlnot loaded but other extensions loaded, the same UAF surfaces inzend_function_dtor → zend_type_releaseinstead. SoPdo\Pgsqlisn't the unique culprit; it just happened to be the class whoseproperties_infoslot got poisoned in that particular extension layout.PHP 8.3.30 on the exact same FreeBSD 15 host with a comparable extension set: 0/10 crashes. So this is a regression introduced somewhere in 8.4.
I haven't pinned down where the dangling pointer is produced — that's why I'm filing rather than sending a PR. Best guesses, in
Zend/zend_inheritance.c, ordered by my subjective likelihood:inherit_property_hook()(new in 8.4) doeschild_info->hooks = zend_arena_alloc(&CG(arena), ...). Arena allocations are request-scoped. Ifchild_infohere is in some path actually a shared parent pointer (because the property wasn't redeclared and was_zend_hash_append_ptr'd in the else branch ofdo_inherit_property), this would write an arena-allocated value into a struct that survives until module shutdown.zend_build_properties_info_table()got a new prototype-chain walking section in 8.4 that stores pointers into the flatproperties_info_table. Could be storing references to structs that are later replaced.do_inherit_property()else branch — both 8.3 and 8.4 alias the parent pointer here. Normally safe via theprop_info->ce == cegate at shutdown, but 8.4 added bookkeeping (num_hooked_props, abstract-flag handling) and thezend_property_infostruct itself grew with theprototypeandhooksfields. If something later writes through one of those new fields assuming child ownership, the parent's struct gets corrupted.These are guesses, not measurements. Input from someone who knows the inheritance code well would be more reliable than my source-tree archaeology.
About FreeBSD-specificity: probably not actually FreeBSD-only. jemalloc on FreeBSD junks freed chunks aggressively, so the UAF dereference cleanly faults at the first field read. glibc on Linux likely leaves the chunk readable for longer or recycles it into something else, so the crash either doesn't surface or shows up elsewhere. Worth someone with a Linux setup running the same
proc_openrepro under valgrind or ASAN with a representative extension load.Background: GH-21600 was closed after 96e93e9. That fix doesn't apply here —
ext/xslisn't loaded. The stack in nwellnhof's comment on that issue is what I'm seeing. Same with FreeBSD bug 277888, marked FIXED via the 8.4.20 ports update that pulled the xsl commit.PHP Version
(Built from FreeBSD
lang/php84ports withSTRIP=so symbols stay; otherwise default port options. All extensions loaded asphp84-*system pkgs.)Operating System
FreeBSD 15.0-RELEASE-p7