Insecure Deserialization in PHP: From Concept to Remote Code Execution

Serialization packs structured data into a string you can store or send. Deserialization unpacks that string back into values (and sometimes objects) in memory. When unserialize() in PHP runs on attacker-controlled data, the runtime may rebuild classes and run magic methods; combined with dangerous patterns like eval(), that path can lead to PHP object injection and remote code execution (RCE).

This article explains the mechanics in order: format, how a web search parameter can expose the bug, how objects escalate impact, and mitigations that actually work in production.

You will learn: how to read PHP serialized strings, how to recognize blind deserialization in HTTP parameters, what POI requires, and how to break the attack class with JSON, signing, and safe unserialize options.

warning

For education and authorized security testing only. Do not target systems without explicit permission.

Concepts: serialization and the trust boundary

Think of serialization like flat-pack furniture: you disassemble a structure so it fits in one box for transport. Deserialization is reassembly at the destination.

For developers, that “box” is convenient: one database column can hold a whole array or object graph instead of many relational fields. The security problem is trust: if the box is opened with unserialize() and the bytes came from a user (query string, cookie, file upload, API body), you are asking the runtime to rebuild whatever object graph they encoded, including gadget classes that execute code when constructed or destroyed.

That blind trust is insecure deserialization. OWASP classifies it as a major category because one weak entry point can compromise the host.

PHP serialization format (mechanics)

PHP’s serialize() output is deterministic text, not opaque binary. Learn to read a few patterns and debugging gets easier.

Example: arrays, strings, integers

<?php
$array   = array("Cyber", "Punk");
$string  = "Cyber Punk";
$integer = 12345;

echo 'Result data array: '. serialize($array) . PHP_EOL;
echo 'Result data string: '. serialize($string) . PHP_EOL;
echo 'Result data integer: '. serialize($integer) . PHP_EOL;
?>

Typical output:

Result data array: a:2:{i:0;s:5:"Cyber";i:1;s:4:"Punk";}
Result data string: s:10:"Cyber Punk";
Result data integer: i:12345;

How to read it

  • a:2:{...}: array with 2 elements. i:0; is integer key 0. s:5:"Cyber" is a string of length 5. Keys and values alternate inside the braces.
  • s:10:"Cyber Punk";: a string of length 10 (spaces count).
  • i:12345;: integer literal.

Reversing with unserialize()

<?php
$array   = 'a:2:{i:0;s:5:"Cyber";i:1;s:4:"Punk";}';
$string  = 's:10:"Cyber Punk";';
$integer = 'i:12345;';

echo 'Result data array: '; print_r(unserialize($array)); echo PHP_EOL;
echo 'Result data string: '; print_r(unserialize($string)); echo PHP_EOL;
echo 'Result data integer: '; print_r(unserialize($integer)); echo PHP_EOL;
?>

You get native PHP values back (arrays, scalars) until the payload starts with O:, which means an object is being instantiated.

Lab walkthrough: a search form

The screenshots below use a small search interface to show how parameters change when you move from plain text to serialized data.

Lab layout: a search field where the backend may call unserialize() on the input.

Plain string input

A normal query such as Cyber Punk travels as a familiar query string:

?search=Cyber+Punk

No serialization, just URL-encoded text.

Baseline: string-only search parameter.

Serialized array input

If the application unserializes the search value, you can submit a serialized array, for example:

a:2:{i:0;s:5:"Cyber";i:1;s:4:"Punk";}

In the browser that becomes percent-encoded in the URL (long search=... value). After deserialization, the page may print the array, which is strong evidence that user input reaches unserialize() without a safe alternative.

Array payload: serialized structure reflected after deserialization.

If you see output resembling:

Array ( [0] => Cyber [1] => Punk )

…you have confirmed the deserialization entry point for deeper testing (still only on systems you are allowed to test).

From arrays to objects: PHP object injection (POI)

Arrays and strings are bad enough for logic bugs, but objects are worse: PHP may invoke magic methods during unserialization and teardown.

Two ingredients for a classic chain

  1. A class in the application (or autoloaded) whose magic methods do something dangerous, or call eval() on properties.
  2. unserialize() applied to data the attacker controls (directly or indirectly).

Magic methods that often appear in write-ups

  • __wakeup(): runs after the object is reconstructed from a serialized blob; often used to “repair” state (and sometimes abused).
  • __destruct(): runs when the object is destroyed at the end of a request; historically abused in chains.

Why eval() is catastrophic here

If eval() runs on a property an attacker sets in the serialized object, they supply PHP source, not just data. That is a straight line to executing arbitrary code as the PHP process, often paired with system(), exec(), or other wrappers depending on what you place inside the evaluated string.

Vulnerable pattern (illustrative only)

<?php
class PHPObjectUser {
    public $username;
    public $password;

    function __wakeup() {
        if (isset($this->username)) {
            $str = $this->username;
            echo $str . '  ';
        }
        if (isset($this->password)) {
            eval($this->password);
            echo '  ';
        }
    }
}

if (isset($_GET['search'])) {
    $req = $_GET['search'];
    $var1 = unserialize($req);
    echo '<br><br>';
    print_r($var1);
} else {
    echo "";
}
?>

The critical line is unserialize($req) on GET input. In production code, never do this on untrusted bytes.

Example object payload (lab)

To trigger __wakeup() with a malicious password string processed by eval(), an attacker might send a serialized PHPObjectUser like:

O:13:"PHPObjectUser":2:{s:8:"username";s:13:"administrator";s:8:"password";s:13:"system('ls');";}

Structure in plain terms

  • O:13:"PHPObjectUser": object whose class name length is 13.
  • 2: two properties.
  • username / password property names and string values follow the usual s:N:"..." rules.
  • The password value is chosen so that eval() executes a PHP expression that calls the OS (here, listing files; lab only).

Object injection: long encoded search parameter and visible effects in the lab UI.

What typically happens in such a broken lab

  1. The browser sends the encoded payload in search.
  2. __wakeup() runs as soon as the object is materialized.
  3. The server may echo administrator (username branch).
  4. eval() runs the attacker-controlled PHP (e.g. system('ls')), so command output may appear (directory listing such as index.php).
  5. print_r may still dump the object graph afterward, which is useful for debugging and terrifying in production.

info

Treat eval() as banned in security-sensitive code paths. Prefer safe APIs, subprocess controls, and never evaluate strings derived from HTTP input.

Mitigation strategies (practical order)

1. Do not unserialize() untrusted data
This is the golden rule. If the bytes can be edited by a client, assume they are hostile. Use a format that cannot instantiate arbitrary PHP classes from the wire.

2. Prefer JSON for structured data
Use json_encode() / json_decode() for request bodies and cookies when you only need arrays and scalars. JSON does not deserialize into attacker-chosen PHP classes, so typical object gadget chains do not apply the same way.

3. If you must ship serialized blobs, sign them
Compute an HMAC (or similar) over the exact string using a server-only secret. Before calling unserialize(), verify the MAC. Any tamper breaks the signature and you can reject the input without parsing.

4. Restrict what unserialize() may instantiate
On modern PHP, pass options such as ['allowed_classes' => false] when objects are not required, or whitelist a small set of class names. Combine with type checks after decoding.

5. Defense in depth
Least privilege for the PHP worker, hardened images, logging, and avoiding dangerous helpers (eval, dynamic include of user paths) anywhere near request handling.

Closing

Insecure deserialization stays relevant because serialization feels convenient and old tutorials unserialize() cookies and parameters. Modern designs treat JSON, protobuf, or signed tokens as the default, and treat unserialize() on external input as a last resort, which is usually a mistake.

If you maintain legacy PHP, audit every unserialize(), phar wrappers, and session handlers that touch serialized data. Fixing trust boundaries here pays off more than chasing symptoms downstream.

Related Posts

Mastering Apple's App-Site Association (AASA) for Universal Links

How Apple’s AASA file powers Universal Links: where to host the JSON, why HTTPS is non‑negotiable, and how to read it from a security angle, without the myths.

Read More

ESP32 Wi‑Fi Packet Sniffer: Promiscuous Mode & Automated OUI Lookup

Turn an ESP32 into a Layer‑2 Wi‑Fi sniffer: capture MACs in promiscuous mode, dedupe with a hash set, then resolve vendors via a MAC/OUI API (ethics included).

Read More

Hybrid CVE Search & DeepSeek Analysis: A Semantic Security Pipeline

Keyword + FAISS semantic search over CVE text, NVD enrichment, and structured DeepSeek analysis, with validation loops. Full architecture, stack, workflow, and source appendices.

Read More
broMadX

broMadX: notes on app security, engineering, and what I’m learning. Written by achmad (formal résumé: Achmad Firdaus on About).