Skip to content

destroy_zend_class shutdown UAF in 8.4 (non-xsl path of GH-21600) #21995

@Kernel-Error

Description

@Kernel-Error

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:

  1. 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.
  2. 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.
  3. 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

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