These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP SecurityPHAR Deserialization

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=php

Then 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.jpg

Then the upload-and-trigger flow:

  1. Upload avatar.jpg to anywhere the application stores files (avatar, attachment, profile picture, document — anywhere it’ll accept “jpg”).
  2. Find a filesystem call that receives a path you can influence.
  3. Send phar:///var/www/uploads/avatar.jpg/test (the /test is 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:

  1. Origin of the path — is phar:// reachable, or is the scheme stripped/validated first?
  2. 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 getimagesize and most MIME checks.
  3. 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.
  4. 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.