Skip to content

Fix GH-9618: drain __wakeup/__unserialize queue on unserialize failure#21716

Open
iliaal wants to merge 1 commit intophp:masterfrom
iliaal:fix/gh9618-unserialize-wakeup-drain
Open

Fix GH-9618: drain __wakeup/__unserialize queue on unserialize failure#21716
iliaal wants to merge 1 commit intophp:masterfrom
iliaal:fix/gh9618-unserialize-wakeup-drain

Conversation

@iliaal
Copy link
Copy Markdown
Contributor

@iliaal iliaal commented Apr 10, 2026

Fixes #9618

unserialize() defers __wakeup() and __unserialize() calls until the entire payload has been parsed, then drains them inside PHP_VAR_UNSERIALIZE_DESTROY at the end of php_unserialize_with_options. On the failure path, zval_ptr_dtor(return_value) at ext/standard/var.c:1469 runs first and triggers destructors of the partially-built return value. A destructor that touches a sibling object sees that sibling in its pre-wakeup state, bypassing any invariant installed in __wakeup() or __unserialize().

The POC uses a malformed property key length to abort unserialization after constructing both A (with a destructor) and B (whose __wakeup() overwrites a field that A::__destruct later passes to eval()). Before this fix, A::__destruct runs first, reaches B with its pre-wakeup value, and eval() executes the attacker-controlled string.

Drain the deferred-call queue on the failure path, before the zval_ptr_dtor(return_value) that triggers destructors. __wakeup() and __unserialize() now run first, so sibling destructors see the post-wakeup state.

Implementation:

  • New static var_drain_entry() in var_unserializer.re factors the per-slot drain out of var_destroy(). Both var_destroy() and the new var_invoke_delayed_calls() share one implementation.
  • var_invoke_delayed_calls() walks the var dtor hash and calls var_drain_entry() on each slot, clearing the VAR_WAKEUP_FLAG / VAR_UNSERIALIZE_FLAG markers so a subsequent var_destroy() skips already-drained entries.
  • php_unserialize_with_options calls var_invoke_delayed_calls() on the failure branch, before zval_ptr_dtor(return_value).

Success path is unchanged. Tests cover both the __wakeup and __unserialize drain paths; both fail on unfixed master and pass after the fix.

…lure

`unserialize()` defers `__wakeup` and `__unserialize` calls until the end
of parsing and drains them inside `PHP_VAR_UNSERIALIZE_DESTROY`. On the
failure path, `zval_ptr_dtor(return_value)` in `php_unserialize_with_options`
runs first and triggers destructors of the partially-built return value.
A destructor that touches a sibling object sees that sibling in its
pre-wakeup state, bypassing any invariant a `__wakeup` or `__unserialize`
installed.

Drain the deferred-call queue on the failure path before `zval_ptr_dtor`
so `__wakeup` and `__unserialize` run first.

Factor the per-slot drain out of `var_destroy()` into a static
`var_drain_entry()` helper. Both `var_destroy()` and the new
`var_invoke_delayed_calls()` share one implementation.

Closes phpGH-9618
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

unserialize __wakeup bypass

1 participant