[php-src] Issue #21995: destroy_zend_class shutdown UAF in 8.4 (non-xsl path of GH-21600)

[email protected] (Kernel-Error)
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/21995
Author: 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 (96e93e9f9d), but the second backtrace in nwellnhof's [comment](https://github.com/php/php-src/issues/21600#issuecomment-4176180818) 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](https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=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](https://wiki.php.net/rfc/pdo_driver_specific_subclasses), 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 96e93e9f9d. 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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.