File upload is a feature with a deceptively simple surface area — a user provides a file, your application stores it. The vulnerability class emerges from the gap between what the application intends to accept and what it actually accepts. When that gap exists, attackers don’t upload images or documents. They upload server-side scripts.
The most direct consequence: an attacker uploads a PHP file to a directory served by the web server and receives remote code execution. The file doesn’t have to be large or complex. A functional web shell in PHP is four lines.
The Core Risk
Unrestricted file upload is dangerous only when two conditions are met simultaneously: the uploaded file can contain executable code, and the web server will execute it when the file is accessed. Addressing either condition breaks the exploit chain.
The first condition is controlled by what you accept. The second by where you store it and how you serve it.
What Attackers Upload
PHP web shells: The classic. If the application stores uploads in a directory under the web root and serves PHP, an attacker uploads a file with a .php extension containing <?php system($_GET['cmd']); ?>. Accessing the file executes the command.
Polyglot files: A file that is simultaneously valid in two formats — a JPEG that is also valid PHP, or a PDF with embedded script. These bypass naive content-type checks because the file genuinely passes MIME type validation.
Path traversal via filename: Instead of accepting the filename from user input, an attacker crafts a filename like ../../config/settings.php and — if the application uses the filename directly — overwrites a file outside the upload directory.
Archive extraction attacks (zip slip): If the application extracts archives, a malicious archive can contain paths like ../../../etc/cron.d/backdoor, escaping the extraction directory into sensitive filesystem locations.
Bypass Techniques for Inadequate Controls
Client-side validation bypass: Extension or type checks performed in JavaScript are irrelevant — Burp Suite intercepts the request after the browser sends it, and the check never ran server-side.
MIME type spoofing: The Content-Type header in a multipart upload is set by the browser but can be modified. Setting Content-Type: image/jpeg on a PHP file bypasses any validation that trusts the header.
Double extension: shell.php.jpg — applications that check for a allowed extension anywhere in the filename, rather than the final extension, accept this. Some web servers process the PHP extension even with a secondary extension appended.
Null byte injection: In older PHP versions and some filename parsers, shell.php%00.jpg truncates at the null byte, resulting in shell.php on disk.
Case variation and alternative extensions: .PHP, .pHP, .php5, .phtml, .phar — web servers may execute any of these as PHP depending on configuration.
Magic bytes manipulation: Checks against file content (the first few bytes) can be bypassed by prepending valid image header bytes (FFD8FF for JPEG) before the PHP payload.
Controls That Work
1. Store uploads outside the web root
This is the most effective single control. Files stored in /uploads/ under your document root can be accessed via HTTP. Files stored in /var/app/uploads/ outside the document root cannot be accessed directly by URL — the web server won’t serve them.
Serve uploaded files through a controller that reads the file from the non-web-accessible location and streams it to the client with the correct Content-Type header:
# Flask example
@app.route('/files/<file_id>')
def serve_file(file_id):
# Validate file_id against database, not filesystem
file_record = db.get_file(file_id)
if not file_record:
abort(404)
file_path = os.path.join(UPLOAD_DIR, file_record.stored_name)
return send_file(file_path,
mimetype=file_record.mime_type,
as_attachment=True)
The UPLOAD_DIR is outside the web root. The file is served by your application, not the web server directly.
2. Rename files on storage
Never store a file using the name provided by the user. Generate a random name (UUID or similar) and store the mapping in your database:
import uuid
import os
def store_upload(uploaded_file):
ext = get_validated_extension(uploaded_file) # validated, not user-supplied
stored_name = f"{uuid.uuid4().hex}{ext}"
file_path = os.path.join(UPLOAD_DIR, stored_name)
uploaded_file.save(file_path)
return stored_name
This prevents path traversal via filename and prevents overwriting known files.
3. Validate by content, not headers
Check actual file content against expected magic bytes for the file types you allow. Python’s python-magic or similar libraries do this:
import magic
def validate_file_type(file_path, allowed_types):
detected = magic.from_file(file_path, mime=True)
if detected not in allowed_types:
os.unlink(file_path)
raise ValueError(f"File type {detected} not permitted")
Check this server-side against the file content after it’s written to disk, not against the Content-Type header sent by the client.
4. Enforce extension allowlists, not blocklists
Blocklisting dangerous extensions is a losing game — there are too many variations. Instead, define which extensions are explicitly permitted:
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.pdf', '.docx'}
def get_validated_extension(filename):
# Use pathlib to handle double extensions correctly
from pathlib import Path
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise ValueError("Extension not permitted")
return ext
pathlib.Path.suffix returns only the final extension (.jpg from shell.php.jpg), closing the double-extension bypass.
5. Serve from a separate domain or disable execution
If files must be accessible from a public URL, serve them from a separate domain that has no server-side execution (a CDN origin, a static file host, or a storage service with web serving disabled for script execution). S3 static hosting, Azure Blob Storage public access, and Cloudflare R2 serve files but do not execute PHP, Python, or JavaScript server-side — an uploaded PHP file is returned as text, not executed.
6. Limit file size server-side
Not a primary defence against web shells (small files are more dangerous than large ones), but limits denial-of-service via upload. Apply limits in your framework and at the reverse proxy layer, not only in application logic.
What Not to Rely On
Client-side validation: Interceptable.
Content-Type header: Spoofable.
File size as a proxy for safety: A 100-byte PHP shell is more dangerous than a 10MB legitimate document.
Antivirus scanning at upload time: Useful defence-in-depth, not a primary control. Web shells are trivially obfuscated beyond signature detection.
The controls that reliably work are architectural: files stored outside the web root, served through application code with execution disabled on the serving layer, renamed on storage, and validated by content. Implement those, and the attack surface collapses.