← All labs
HardWeb Exploitation ~30 min

Upload Filter Bypass to Web Shell

Defeat a naive upload filter to plant a web shell and get command execution on the simulated server.

↳ Based on the lesson: File Upload Vulnerabilities

Authorized Use Only

File-upload exploitation and web shell deployment must only be performed on applications you own or have explicit written authorization to test. Uploading shells to any system you do not control is unauthorized computer access - a criminal offence in most jurisdictions. This lab runs entirely against an intentionally vulnerable Docker container on your own machine.

The upload form says "images only." The server disagrees about what counts as an image.

Scenario

You are testing a legacy PHP application that lets users upload a profile picture. The developer added a filter: the application checks the filename extension and rejects anything that is not .jpg, .jpeg, .gif, or .png. The actual file content is never inspected. Your goal is to defeat this filter, land a PHP file on the server, and reach it through the browser to execute operating-system commands.

Setup - choose one:

# Option A: DVWA (Damn Vulnerable Web Application) via Docker
$ docker run -d -p 8080:80 vulnerables/web-dvwa
# Navigate to http://localhost:8080, log in (admin / password), set Security to Low
# Go to: DVWA Security -> set Low, then File Upload from the menu
 
# Option B: upload-labs (dedicated upload-bypass practice)
$ docker run -d -p 8080:80 c0ny1/upload-labs
# Navigate to http://localhost:8080 - start at Pass-01
 
# Option C: Mutillidae II
$ docker run -d -p 8080:80 webpwnized/mutillidae
# Navigate to OWASP Top 10 -> A04 Insecure Direct Object References -> Upload File

Your Objective

  1. Identify the upload filter mechanism (client-side, extension check, MIME type, or content inspection)
  2. Craft or rename a PHP file to bypass the extension filter
  3. Determine the URL path where uploaded files are stored
  4. Request your uploaded file through the browser and confirm command execution
  5. (Stretch) Bypass a secondary MIME-type check using a polyglot file

Hints

Hint 1 - check for client-side-only filtering

Open browser DevTools before you upload anything. Look at the file input element for an accept attribute restricting extensions. If the check happens in JavaScript before the request is sent, you can disable it entirely from DevTools - right-click the input, choose "Edit as HTML," remove the accept attribute, or use Burp to intercept and modify the filename in-flight after the browser sends the request.

Hint 2 - extension bypass approaches

A server that only checks the last extension can be confused by double extensions: shell.php.jpg is stored as a PHP file on misconfigured Apache servers that process any file whose name contains .php. Alternatively, try less common PHP extensions that the filter may not block: .php5, .phtml, .phar, .php7. Try each one.

Hint 3 - finding your uploaded file

After a successful upload the application usually tells you the path, or you can infer it: DVWA stores uploads under /hackable/uploads/. Use the browser address bar or Burp response body to find the confirmation message - it almost always includes the filename and path.

Hint 4 - polyglot GIF bypass

When the server checks the Content-Type request header, intercept the upload in Burp and change Content-Type: application/octet-stream to image/gif. When the server reads the first bytes of the file (magic bytes), prepend the four bytes GIF89a to the PHP content - most image-magic-byte checks stop after the header and never look at the rest of the file.

Walkthrough

Step 1: Probe the filter

Start by uploading a legitimate image to see what a successful upload looks like, then try uploading a .php file directly.

# Create a minimal PHP test file
$ echo "test" > test.php

In the browser, try uploading test.php. Three possible outcomes:

  • Rejected before the request is sent - client-side JavaScript filter. Fix: use DevTools or Burp.
  • Request sent but server rejects - server-side extension or MIME check. Fix: rename or intercept.
  • Accepted immediately - no real filter. Proceed to Step 3.

To distinguish client-side from server-side, open Burp Suite, enable intercept, and submit the upload. If the request never reaches Burp, the block is client-side.

Step 2: Bypass the extension filter

Create the defanged web shell file. The EXEC placeholder below represents a PHP command-execution function - in a real engagement this is where execution happens, but we keep the exact function name out of this document to avoid antivirus false positives:

<?php
// Defanged web shell. EXEC stands for a PHP command-execution function
// such as system(), shell_exec(), or passthru().
echo EXEC($_GET['cmd']);
?>

Save this as shell.php. Now try the bypass techniques in order:

Technique A - rename to double extension:

Rename the file to shell.php.jpg before uploading. If the server extracts the last extension only, it sees .jpg and allows it. Apache with AddHandler application/x-httpd-php .php in its config will still execute the file as PHP because the .php token appears anywhere in the filename.

Technique B - alternative PHP extension:

Rename to shell.phtml, shell.php5, or shell.phar. The upload filter may only check for .php literally, leaving these variants unblocked.

Technique C - Burp intercept - change filename in the request:

Upload a real .jpg, intercept in Burp, find the filename= field in the Content-Disposition header, and change it to shell.php. The browser sent a .jpg but the server stores whatever name is in the request body.

Content-Disposition: form-data; name="uploaded"; filename="shell.php"
Content-Type: image/jpeg
Step 3: Locate the uploaded file

Read the server's upload-success message carefully - it usually includes the full or relative path. Common locations:

ApplicationUpload path
DVWA/hackable/uploads/
upload-labs/upload/
Mutillidae/uploads/

Confirm the path with a directory listing request if the server has directory indexing enabled:

$ curl http://localhost:8080/hackable/uploads/

If the listing shows your file, the upload path is confirmed.

Step 4: Execute commands through the web shell

Request your uploaded PHP file with a cmd parameter. Start with a harmless command to confirm execution:

# Confirm execution - id shows which OS user the web server runs as
$ curl "http://localhost:8080/hackable/uploads/shell.php?cmd=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
 
# List the web root
$ curl "http://localhost:8080/hackable/uploads/shell.php?cmd=ls+-la+/var/www/html"
 
# Read a sensitive file
$ curl "http://localhost:8080/hackable/uploads/shell.php?cmd=cat+/etc/passwd"

If id returns output, you have arbitrary OS command execution as the web server user. The server is compromised.

Step 5 (stretch): Polyglot GIF bypass for MIME-type checks

Some servers check the first bytes of the uploaded file (magic bytes) rather than the extension. A polyglot file is valid as two formats simultaneously. Prepend the GIF magic bytes to the PHP shell:

GIF89a
<?php
// Defanged web shell. EXEC stands for a PHP command-execution function
// such as system(), shell_exec(), or passthru().
echo EXEC($_GET['cmd']);
?>

Save this as polyglot.php.gif (or polyglot.gif for DVWA Low). The file starts with GIF89a so a magic-byte check sees a valid GIF. Because the server executes .php files, the PHP interpreter ignores the leading GIF89a text and runs the PHP block.

When the MIME-type check reads Content-Type from the request header rather than the file content, intercept in Burp and change it to image/gif.

Solution

Minimal bypass for a pure extension check:

  1. Create a PHP file using the defanged shell template above.
  2. Rename it to shell.php.jpg (double extension) or shell.phtml (alternative extension).
  3. Upload - the filter passes it.
  4. Request http://localhost:8080/hackable/uploads/shell.php.jpg?cmd=id.
  5. Read the OS command output in the response body.

Burp intercept bypass (most reliable):

  1. Start with any .jpg file.
  2. Intercept the upload POST in Burp.
  3. Change filename="photo.jpg" to filename="shell.php" in the request body.
  4. Forward - the server stores shell.php.
  5. Request the uploaded file with ?cmd=id.

Why the filter fails: Checking only the file extension (or only the Content-Type header supplied by the client) is entirely attacker-controlled. The server trusts data the attacker sends. Only inspecting the actual file bytes with a server-side library, and storing uploads outside the web root, break the attack chain.

Defend It

The fix: server-side content inspection and storage outside the web root

Never allow uploaded files to be directly executable. Three controls together make this attack impossible: validate content with a server-side library, randomize the stored filename, and store files in a directory the web server cannot execute.

PHP - safe upload handling pattern:

// Check MIME type server-side using fileinfo, not the Content-Type header:
// $finfo = new finfo(FILEINFO_MIME_TYPE);
// $mime  = $finfo->file($_FILES['upload']['tmp_name']);
// $allowed = ['image/jpeg', 'image/png', 'image/gif'];
// if (!in_array($mime, $allowed)) { die('Rejected'); }
 
// Generate a random stored name - never use the attacker-supplied filename:
// $storedName = bin2hex(random_bytes(16)) . '.jpg';
 
// Store outside the web root so the file cannot be requested directly:
// $dest = '/var/uploads/' . $storedName;   // NOT /var/www/html/uploads/
// move_uploaded_file($_FILES['upload']['tmp_name'], $dest);

Hardening checklist:

  • Store outside the web root - a file at /var/uploads/ cannot be fetched via HTTP even if PHP executes it.
  • Randomize stored filenames - removes the attacker's ability to predict or guess the URL of their upload.
  • Validate content with server-side magic-byte inspection - use finfo (PHP), python-magic, or equivalent; never trust the client-supplied Content-Type header.
  • Allowlist extensions and MIME types - reject anything not on the allowlist rather than trying to blocklist dangerous types.
  • Serve uploads through a controller script - the controller reads the file from the safe location and streams it as a response; the file is never directly accessible as a URL.
  • Set Content-Disposition: attachment - forces downloads instead of in-browser rendering, preventing MIME-sniffing execution.

Key Takeaways

  • A file-upload filter is only as strong as what the server actually checks - extension, Content-Type header, and magic bytes are all attacker-controllable to varying degrees.
  • Storing uploaded files inside the web root is the root cause; even a perfect extension check is undermined if the server later executes the file.
  • Polyglot files exploit the fact that a single byte sequence can satisfy two format validators simultaneously - valid image header, valid PHP payload.
  • Defence in depth (random name + storage outside web root + content inspection + no-execute permissions) eliminates the attack even if one control is bypassed.