Web Security Academy: Arbitrary Object Injection in PHP

· falasi.net


Table of Contents

Summary #

While testing the application's session handling, I noticed that the session cookie was a URL-encoded Base64 blob that decoded to a PHP serialized object. This is a strong signal — when an application stores serialized objects on the client side and reconstructs them with unserialize(), an attacker can often craft a malicious payload that triggers unintended behavior during reconstruction. This is the classic shape of an insecure deserialization vulnerability.

The lab goal was to delete morale.txt from Carlos's home directory. Solving it required source code access to identify a usable gadget — a class whose magic methods perform a dangerous operation on attacker-controlled properties. The central question driving the exploitation was straightforward: is there a class on disk whose destructor (or other automatically-invoked method) will operate on a property an attacker fully controls?

This write-up walks through identifying the vulnerable session mechanism, recovering the source code via an editor backup file, locating the gadget, and crafting a serialized payload that delivers the file deletion through the destructor of an unrelated class.


Methodology #

The approach was to work backwards from the session cookie:

  1. Decode the session cookie to confirm it contains serialized PHP and to understand the object structure the application expects.
  2. Look for any clues in the application's HTML or behavior that point at additional source code.
  3. Recover source for any referenced classes, especially ones with magic methods (__destruct, __wakeup, __toString).
  4. Identify a usable gadget — a method that runs automatically on unserialize and performs a dangerous operation on a controllable property.
  5. Construct a serialized object that, when sent in place of the legitimate session, triggers the gadget against the lab's target file.
  6. Submit the payload and confirm the file was deleted.

The work splits cleanly into two phases. The first phase is reconnaissance — confirming the vulnerability class exists at all, and finding source code to drive the exploit. The second phase is gadget construction — once the source is in hand, the payload is mostly mechanical, but the serialized format has enough quirks (private property naming, byte-accurate length prefixes) to trip up a payload that is otherwise correct.


The application set a session cookie on login. After URL-decoding and Base64-decoding the value, the underlying string was a PHP serialized object:

1O:4:"User":2:{s:8:"username";s:6:"wiener";s:12:"access_token";s:32:"nzhv9igyrpa4riiv7orb545vwrww20xf";}

This tells us a lot. The cookie is a serialized User object with two string properties: username and access_token. The application is almost certainly calling unserialize() on the decoded cookie to reconstruct the session — that is what makes this format useful to it. Storing a serialized object on the client side and rebuilding it on every request is the textbook setup for PHP object injection.

At this stage, however, the only class we know about is User, and it does not appear to have any obviously dangerous behavior. To exploit the deserialization we need a class with a magic method — typically __destruct or __wakeup — that does something useful when invoked on attacker-controlled properties. That requires source code.


Step 2: Recover Source Code via an Editor Backup #

The home page included an HTML comment that read:

1<!-- TODO: Refactor once /libs/CustomTemplate.php is updated -->

Requesting the file directly returned an empty response, which is expected — the web server passes .php files through the PHP interpreter, and only the interpreter's output reaches the browser. The raw source never leaves the server under normal circumstances.

Editor backup files are the common exception. Many Unix editors (Vim, Emacs) automatically save a backup copy of the edited file with a trailing ~. That backup ends in .php~, not .php, so the web server's PHP-handler rule does not match it, and the file is served as plain text:

1GET /libs/CustomTemplate.php~ HTTP/1.1
2Host: 0a31003d0300fb71806a0ef500840079.web-security-academy.net

The response was the full source:

 1<?php
 2
 3class CustomTemplate {
 4    private $template_file_path;
 5    private $lock_file_path;
 6
 7    public function __construct($template_file_path) {
 8        $this->template_file_path = $template_file_path;
 9        $this->lock_file_path = $template_file_path . ".lock";
10    }
11
12    // ... saveTemplate, getTemplate omitted ...
13
14    function __destruct() {
15        // Carlos thought this would be a good idea
16        if (file_exists($this->lock_file_path)) {
17            unlink($this->lock_file_path);
18        }
19    }
20}

The destructor is the gadget. It calls unlink() on $this->lock_file_path — a property whose value, on a deserialized object, comes directly from the serialized input rather than from the constructor. PHP's unlink() is a thin wrapper around the Unix unlink(2) syscall: deleting a file on a Unix filesystem is literally the act of removing its directory entry — its link — which is where the name comes from. The function does not care whether the path looks like a lock file, an image, a log, or /etc/shadow. It removes whatever path it is given, subject to the process's permissions.


Step 3: Understand Why the Destructor Is Exploitable #

Two PHP behaviors combine to make this exploitable.

The first is that __construct is not called when an object is rebuilt by unserialize(). PHP reconstructs the object by setting properties directly from the serialized data, then resumes the normal object lifecycle. Any path-building or validation logic the developer placed in the constructor is bypassed entirely.

The second is that __destruct is called on deserialized objects, automatically, when they go out of scope or the script terminates. The developer's intent — "delete the lock file we created when saving a template" — is encoded in a comment, not in the code. The code itself simply calls unlink() on whatever string happens to be in $lock_file_path. That string is now attacker-controlled.

This is the gap the exploit lives in: the developer assumed $lock_file_path would always hold a safe path produced by __construct, but on a deserialized object it holds whatever the attacker put in the cookie.


Step 4: Construct the Malicious Serialized Object #

The payload is a CustomTemplate object with lock_file_path set to the file we want to delete:

1O:14:"CustomTemplate":1:{s:30:"\0CustomTemplate\0lock_file_path";s:23:"/home/carlos/morale.txt";}

Two details matter.

The class name length prefix (O:14) and string length prefixes (s:30, s:23) must be byte-accurate. PHP's unserialize() reads exactly that many bytes for each value; a wrong count produces a parse failure and the destructor never runs.

The property name lock_file_path is declared private. In PHP's serialization format, private properties are prefixed with a null byte, the declaring class name, and another null byte. The serialized name is therefore \0CustomTemplate\0lock_file_path — 30 bytes total: one null byte, the 14-byte class name CustomTemplate, another null byte, and the 14-byte property name lock_file_path. Forgetting the null bytes is the most common reason a payload that looks right fails silently: PHP will deserialize the object but the property assignment will land on a different (public) property, and the destructor will see an empty lock_file_path.

After URL-encoding the null bytes and Base64-encoding the whole string, the cookie value is ready to send.


Step 5: Deliver the Payload #

Rather than re-encode the payload by hand on every iteration, I used Hackvertor tags directly inside the Repeater request. Hackvertor evaluates the chained tags at send time and rewrites the cookie value before the request leaves Burp, so the working payload stays editable as plaintext serialized PHP:

1GET /my-account?id=wiener HTTP/2
2Host: 0a31003d0300fb71806a0ef500840079.web-security-academy.net
3Cookie: session=<@urlencode><@base64>O:14:"CustomTemplate":1:{s:14:"lock_file_path";s:23:"/home/carlos/morale.txt";}</@base64></@urlencode>

The tag order matters and reads inside-out: Base64 first (matching the original cookie's encoding), then URL-encode the result so any +, /, or = characters survive the cookie header transit. This is the inverse of the decoding chain applied in Step 1.

One detail in this payload is worth flagging because it will not transfer cleanly to other targets. The property name is sent as the bare string lock_file_path (s:14) rather than the null-byte-prefixed \0CustomTemplate\0lock_file_path (s:30) that PHP's own serialize() emits for a private property. This specific PortSwigger lab is known to accept both forms — a simplification that keeps the lab focused on the gadget concept rather than on serialization-format trivia. Against a real target this shortcut will usually fail silently: the deserialized object ends up with lock_file_path as a new public property while the original \0CustomTemplate\0lock_file_path slot stays empty, so the destructor's file_exists() check returns false and the gadget never fires. When in doubt, generate the payload with serialize() itself (see the appendix) and copy the bytes verbatim.

The server processed the request normally and the lab status updated to solved on the next page load, confirming morale.txt had been deleted from Carlos's home directory.


Why This Worked #

The vulnerability is not "the application uses unserialize()." It is the assumption, baked into the CustomTemplate class, that the value of $lock_file_path could only ever come from __construct — where it was built by appending .lock to a controlled template path. That assumption holds for objects created with new CustomTemplate(...). It does not hold for objects created by unserialize(), where every property is populated directly from attacker-supplied bytes and the constructor is skipped.

The destructor then does exactly what it says: it calls unlink() on a property. The property name lock_file_path is a label for human readers; PHP does not enforce any semantic meaning on it. There is no check that the path ends in .lock, lives in a specific directory, or was created by this object. Any string the attacker can place in that property will be passed to unlink().

This shape — a magic method that performs a dangerous operation on an unvalidated property — is what makes a class a gadget. The gadget exists in code that has nothing to do with sessions or authentication. It became reachable only because the application chose to deserialize a client-controlled cookie, and PHP will happily reconstruct any class the autoloader can find, not just the User class the application intended.

The source code leak via the ~ backup file is a separate, compounding misconfiguration. Without the source, this gadget would have been much harder to find — but the underlying deserialization flaw would still exist. Source-code exposure made the exploit fast; it did not make it possible.


Code Review Perspective #

Vulnerable Example — Direct Pass-Through #

1$session = base64_decode($_COOKIE['session']);
2$user = unserialize($session);

The application takes a client-controlled cookie, decodes it, and passes the result straight into unserialize(). PHP will reconstruct any class the autoloader can resolve, invoke its magic methods, and run whatever side effects those methods produce. The application has no way to constrain which classes are reconstructed, because the class name is part of the serialized payload itself.

Less Obviously Broken — Type-Checked After Deserialization #

1$session = base64_decode($_COOKIE['session']);
2$user = unserialize($session);
3
4if (!($user instanceof User)) {
5    throw new Exception("Invalid session");
6}

This looks defensive: only User objects are accepted. The check is too late. By the time instanceof runs, unserialize() has already constructed the object and PHP has already begun the object's lifecycle. If the payload was a CustomTemplate, its destructor will fire when the object goes out of scope at the end of the request — long after the instanceof check has thrown an exception. The exception does not prevent the destructor from running.

This pattern shows up in real code surprisingly often. The developer reasoned about the type of the result without reasoning about the side effects of producing it.

Weak Fix — Class Allowlist via unserialize Options #

1$user = unserialize($session, ['allowed_classes' => ['User']]);

PHP 7+ supports an allowed_classes option that restricts which classes unserialize() will instantiate. Any class not in the list is reconstructed as __PHP_Incomplete_Class, which has no methods and cannot trigger gadgets.

This is a meaningful improvement over the previous examples, but it has two failure modes. First, if the allowlist itself contains a class with a dangerous magic method, the vulnerability persists — the User class might be safe today and gain a __wakeup later that does something unsafe. Second, the option is easy to forget on subsequent calls; a single unserialize($input) elsewhere in the codebase reintroduces the full vulnerability.

It also does not address the deeper question of why the application is reconstructing arbitrary objects from a client-supplied string at all.

Secure Implementation — Server-Side Sessions with Opaque Identifiers #

1session_start();
2
3if (!isset($_SESSION['user_id'])) {
4    http_response_code(401);
5    exit;
6}
7
8$user = User::loadById($_SESSION['user_id']);

The cookie no longer carries serialized object state. It carries an opaque session identifier, and the actual user record is loaded from server-side storage. The client cannot influence which class is instantiated, because the application chooses the class — User::loadById always returns a User. There is no unserialize() call on user input, so there is no gadget surface, regardless of what other classes exist in the codebase.

This design removes the vulnerable pattern rather than defending it. Sessions stop being a serialization problem and become a database lookup problem, which is well understood and has standard solutions.


Impact #

In this lab, the exploit deletes a single file. In a realistic environment, the same primitive scales considerably. An attacker with file deletion through a destructor gadget can target log files to cover their tracks, lock files to disrupt application behavior, configuration files to force fallback to insecure defaults, or session storage files to invalidate other users' sessions.

More importantly, file deletion is rarely the strongest gadget available in a real codebase. Production applications typically include framework code (Symfony, Laravel, WordPress core, Composer dependencies) where well-known gadget chains escalate from object injection to arbitrary file write or remote code execution. Tools like PHPGGC ship pre-built chains for most major frameworks for exactly this reason. A single unserialize() call on a client-controlled string is not a "data deletion" risk in a realistic environment — it is an RCE risk waiting for the right gadget chain.

The combined effect of the deserialization flaw and the source-code leak is also worth noting. The backup-file exposure shortened the gadget-discovery phase from "audit the running framework" to "read the source." In real engagements, those two findings should be reported separately but with the link explicitly drawn — they make each other worse.


Remediation #

The strongest fix is architectural: do not deserialize untrusted input. Replace client-side serialized session state with an opaque session identifier and a server-side store. This eliminates the class of vulnerability rather than defending against it, and it removes the dependency between session security and the codebase's gadget surface.

If serialized state on the client is unavoidable, sign it. Compute an HMAC over the serialized payload using a server-side key, append the signature to the cookie, and verify the signature before calling unserialize(). An attacker who cannot forge a valid signature cannot reach the deserialization sink. The HMAC must be verified before deserialization, not after — verifying after is equivalent to the failed instanceof check above.

Where neither is feasible, use unserialize($data, ['allowed_classes' => [...]]) with the smallest possible class list, audit every class on that list for dangerous magic methods, and treat the option as mandatory at every call site. This is a defense, not a fix.

Separately, configure the web server to deny requests for backup file extensions (~, .bak, .swp, .old, .orig). In Apache:

1<FilesMatch "(\.bak|\.swp|\.old|\.orig|~)$">
2    Require all denied
3</FilesMatch>

The cleanest version of this fix is to deploy from a build artifact that does not contain editor scratch files in the first place. Production servers should not be edited in place.


Key Takeaway #

Insecure deserialization is not a flaw in unserialize() — it is a flaw in the assumption that an object's properties can only ever hold values produced by its constructor. Once an attacker controls the serialized bytes, every property of every reachable class is attacker-controlled, every magic method is an entrypoint, and the constructor's safety logic is bypassed entirely. When auditing PHP code, the question to ask is not "does this class look dangerous?" but "if I could put any value into any property of this class, what would __destruct, __wakeup, or __toString do with it?" That mental shift is the difference between reading code as a developer and reading it as an attacker.


Appendix #

Lab #

PortSwigger Web Security Academy — Arbitrary object injection in PHP: https://portswigger.net/web-security/deserialization/exploiting/lab-deserialization-arbitrary-object-injection-in-php

Tools Used #

Background Reading #

Reusable Templates #

Hackvertor tag chain for serialized session cookies. Wrap the plaintext serialized object so the cookie is re-encoded on every send:

1Cookie: session=<@urlencode><@base64>O:14:"<CLASS_NAME>":1:{s:<LEN>:"<PROP_NAME>";s:<LEN>:"<VALUE>";}</@base64></@urlencode>

Private property name format. When the target property is declared private, the serialized name must include null bytes and the declaring class name:

1\0<DECLARING_CLASS>\0<PROPERTY_NAME>

The s: length prefix counts every byte, including the two null bytes. For a 14-byte class name and 14-byte property name, the prefix is s:30.

Useful Commands #

Decode a session cookie from the command line:

1echo 'Tzo0OiJVc2VyIjoyOnt...' | base64 -d

Generate a serialized payload directly with PHP, sidestepping manual length-counting:

1php -r 'class CustomTemplate { private $lock_file_path = "/home/carlos/morale.txt"; } echo urlencode(base64_encode(serialize(new CustomTemplate())));'

The private declaration on the property ensures PHP emits the null-byte-prefixed name in the correct format automatically.

last updated: