PHAR Deserialization
PHP archives (PHAR) embed serialised metadata. When PHP’s filesystem
functions are given a phar:// URI, the stream wrapper reads the archive
and deserialises that metadata silently — no explicit unserialize()
call required.
That turns an “innocent” file_exists($_GET['file']) into an object
injection sink. Same gadget chains as classic
unserialize; different
entrypoint.
Why
PHP stream wrappers run for any function that resolves a path through
PHP’s VFS layer. The phar:// wrapper, when asked to resolve a path
inside an archive, reads the archive’s manifest — which contains a
serialised metadata field. Reading the manifest deserialises that field
regardless of whether your code ever opens, reads, or otherwise touches the
contents.
PHP 8 disabled metadata deserialisation by default for some functions,
but the surface remains wider than people remember. Anything that calls
into stat, lstat, readlink, image inspection, or path resolution can
trigger it on certain versions/configs.
Search patterns
rg -n '\b(file_exists|file_get_contents|file_put_contents|is_file|is_dir|is_link|is_readable|is_writable|fopen|fpassthru|readfile|file|copy|unlink|rename|mkdir|rmdir|symlink|link|chmod|chown|touch|tempnam|filemtime|filectime|fileatime|filesize|fileowner|filegroup|fileperms|filetype|stat|lstat|getimagesize|exif_read_data|hash_file|md5_file|sha1_file)\s*\(' --type=php
# Indirect path use (very common in object code)
rg -n 'SplFileInfo|SplFileObject|DirectoryIterator|RecursiveIteratorIterator|parse_ini_file|simplexml_load_file|imagecreatefrom\w+\(' --type=phpThen filter for hits where the path argument comes from user input.
Test inputs
Build the PHAR with PHPGGC’s -p phar -pj flags. The -pj packs the PHAR
as a PNG/JPG polyglot:
phpggc -p phar -pj avatar.jpg Monolog/RCE6 system id > avatar.phar
mv avatar.phar avatar.jpgThen the upload-and-trigger flow:
- Upload
avatar.jpgto anywhere the application stores files (avatar, attachment, profile picture, document — anywhere it’ll accept “jpg”). - Find a filesystem call that receives a path you can influence.
- Send
phar:///var/www/uploads/avatar.jpg/test(the/testis required — PHAR URIs must point to a “file inside” the archive even if it doesn’t exist).
URL-encoded variants for filters that block literal phar://:
phar%3A%2F%2F…PHAR://…(case sensitivity check)
If the application doesn’t accept uploads but you can write to any
attacker-controllable file (cache file, log line, tmp upload):
- Find write primitive → drop a polyglot
- Find filesystem read on attacker path → trigger
Audit focus
For each filesystem-touching call with attacker input:
- Origin of the path — is
phar://reachable, or is the scheme stripped/validated first? - Upload surface — are file uploads accepted with arbitrary
extensions, or with weak server-side extension validation? PHAR files
embedded in JPGs are valid JPGs and pass
getimagesizeand most MIME checks. stream_wrapper_unregister("phar")— is the wrapper disabled at bootstrap? If the app doesn’t use PHAR (almost no app does), this is a single-line defense.- PHP version + functions — even read-only intent (
file_exists,is_file) is exploitable on the affected versions. Don’t dismiss “just a stat call.”
This is the bug that turns “we sanitised the file uploads” into a
remote-code-execution path months later, when someone adds a feature
that calls file_exists($user_path). Disabling the phar wrapper at
bootstrap is the cheap, durable defense.
Fix
At application bootstrap, drop the wrapper if you don’t need it:
if (in_array("phar", stream_get_wrappers(), true)) {
stream_wrapper_unregister("phar");
}For path inputs in general — confine to a directory and reject schemes:
if (preg_match('#^[a-z0-9]+://#i', $input)) {
abort(); // any scheme rejected
}
$base = realpath('/var/app/uploads');
$target = realpath($base . '/' . basename($input));
if ($target === false || !str_starts_with($target, $base . DIRECTORY_SEPARATOR)) {
abort();
}For uploads — re-encode images server-side (GD or Imagick). Re-encoding strips the PHAR metadata from the JPG container.