skip to content

A legacy PHP app unserializes a 'remember me' cookie on every request; why is that a remote-code-execution risk, and does unserialize's allowed_classes option fix it?

level: seniorimportance: must knowfreq 52%

answer

  1. cookie bytes drive object construction
  2. magic methods fire during unserialize
  3. allowed_classes narrows, does not remove risk
  4. allowed_classes omitted means all classes
  5. switch to json_decode/json_encode

basics

~20 s

unserialize() rebuilds whatever object graph the cookie bytes describe and calls magic methods like __wakeup and __destruct, so attacker-chosen objects run code (a gadget chain). allowed_classes narrows which classes are built but PHP still warns against untrusted input; replace the cookie with signed JSON.

solid answer

~50 s

`unserialize($cookie)` reconstructs the value the bytes encode. If they encode objects, PHP **instantiates those classes and calls their magic methods** — `__wakeup()`/`__unserialize()` during, `__destruct()` at request end. An attacker who forges the cookie chooses which of your (and your dependencies') classes come to life and with what properties, chaining their side effects into file writes, SQL, or code execution — a **gadget chain**. The `allowed_classes` option (omitting it, or `true`, allows *all* classes; `false` allows none, turning objects into `__PHP_Incomplete_Class`) narrows the set, but the manual is explicit: **do not pass untrusted input to `unserialize` regardless of `allowed_classes`**, because instantiation and autoloading still occur. The right fix is a data format that builds no objects: store the cookie as JSON and, since the client holds it, sign it with `hash_hmac` and verify with `hash_equals` before decoding.

go deeper

for a junior

Know that unserializing a cookie is dangerous and that JSON is the safer format for client data.

for a middle

Explain that unserialize instantiates classes and fires magic methods, and what allowed_classes does (all by default, false = none, TypeError on a bad type since 8.4).

for a senior

Diagnose a gadget chain, migrate the endpoint to signed JSON, and hold the line with allowed_classes while the migration runs.

for a principal

Decide policy: eliminate the sink class-wide versus wrap instances, and weigh where signed data is unavoidable against the crypto and key-management cost.

## What the cookie actually does PHP's `serialize()` produces a string that fully describes a value, including objects and their private properties. `unserialize()` reverses it. When the bytes describe an object of class `Foo`, PHP: 1. **autoloads** `Foo` if needed, 2. **allocates** the instance and restores its properties directly (the constructor is *not* called), 3. calls **`__unserialize()`** if defined, else **`__wakeup()`**, 4. later, when the object is destroyed, calls **`__destruct()`**. Every one of those is code the attacker triggered by choosing the class and property values. The cookie is attacker-controlled by definition — it lives on the client — so unserializing it "on every request" means running attacker-chosen object lifecycles on every request. ## Why this reaches code execution — the gadget chain The attacker rarely needs a class that is exploitable on its own. They assemble a **gadget chain**: pick a class whose `__destruct` or `__wakeup` does something useful (writes a file, calls a method on a stored property), and set that property to *another* object whose method does the next step. Popular frameworks and libraries ship classes that, composed this way, reach `file_put_contents`, `system`-like calls, or SQL. The application never intended those combinations; `unserialize` assembled them from the cookie. This is why the theory (found-appsec-deserialization-risks) frames it as "the set of constructible types is the attack surface," not "the blob is malicious." ## What `allowed_classes` does and does not buy you `unserialize($s, ['allowed_classes' => ['App\Session']])` restricts which classes may be instantiated: | `allowed_classes` value | Effect | |---|---| | omitted, or `true` | **every** class may be built (the dangerous default) | | `false` | no classes; any object becomes `__PHP_Incomplete_Class` | | `['A', 'B']` | only `A` and `B`; others become `__PHP_Incomplete_Class` | | enums | **not affected** — enum cases are always restored | Since PHP 8.4 a value that is neither an array nor a bool throws `TypeError`/`ValueError` (before, it returned `false` with a warning). Restricting to one plain data-holder class shrinks the reachable gadgets, but the manual still says do not feed untrusted input to `unserialize` at all: your one allowed class may itself have an exploitable `__wakeup`, and autoloading side effects remain. Treat `allowed_classes` as defence-in-depth for *trusted-but-versioned* data, never as a sanitiser for a cookie. ## The fix - **Change the format.** JSON via `json_decode($s, true)` returns arrays and scalars and constructs **no objects**, removing the whole class of bug. Rebuild the session from the decoded array yourself. - **Sign client-held data.** A remember-me token is client-held, so even JSON must be integrity-checked: store `payload . '.' . hash_hmac('sha256', $payload, $key)` and verify with `hash_equals` before decoding, so a tampered cookie is rejected before parsing. - **Do not roll a "safe unserialize."** Blocklists of known gadget classes are the weakest option — new gadgets appear in dependencies. Prefer eliminating `unserialize` on the untrusted path. Note that `false` is also the failure return of `unserialize`, and it collides with a legitimately serialized `false`; on PHP 8.3+ a bad string also emits `E_WARNING`. None of that is a security control — it is why error handling around `unserialize` is awkward, which is one more reason to move to JSON.

  • If the cookie is HMAC-signed, is it then safe to `unserialize` it?
    Signing removes *tampering*, so an attacker can't forge new bytes — that closes the gadget-chain path as long as the key stays secret. But you have added a crypto dependency to every request and still run PHP's object machinery on decode. JSON over signed bytes is simpler and builds no objects, so prefer it.
  • The constructor isn't called on unserialize — so how does attacker code run?
    Restoration bypasses `__construct` but still triggers `__unserialize`/`__wakeup` on decode and `__destruct` at teardown, plus any method reached through a property later. Those magic methods, composed across classes, are the gadgets — the constructor being skipped doesn't help.
  • Does `allowed_classes => false` make it safe?
    It stops object *instantiation* (unknown objects become `__PHP_Incomplete_Class`), which blocks most chains, but the manual still advises against untrusted input because autoloading and enum restoration remain, and a returned incomplete-class object can cause its own errors. Use it as a hardening layer, not the primary control.

It is like mailing a customer a build sheet and then constructing whatever machine comes back stamped on it — a rival can post you a sheet for a bomb. You should mail a plain parts list (JSON) and check the tamper-seal (HMAC) before reading it, not build straight from the returned sheet.

saying these in an interview costs you the question

  • unserialize with allowed_classes set is safe on untrusted input
  • the constructor runs, so a class without a dangerous constructor is safe
  • only __wakeup matters; __destruct can't be part of an attack
  • JSON and serialize are interchangeable, just pick either
  • unserialize just returns data structures, it never runs code
  • a blocklist of known gadget classes is the strongest defence