File Upload Vulnerability Defense: Practical Configuration Guide
Disable script execution in Nginx, then stack app-layer checks in PHP and Flask. Covers extension allowlists, MIME detection, magic-byte validation, and random rename.
Scope
File upload is among the most common Web app attack surfaces. Attackers upload malicious scripts (WebShell), executables, crafted images, or oversized files to take over the server, leak data, or cause DoS. Insecure file upload remains a high-risk category in OWASP Top 10. This tutorial is for Web developers, security testers, and ops engineers. Covers PHP, Python (Flask/Django), and Nginx.
Prerequisites
- Admin access to the Web server (Nginx/Apache)
- Backend on PHP, Python, or Java (this tutorial uses PHP + Nginx)
- Familiarity with Web application architecture
Mechanism
Root cause: the app fails to validate and constrain uploaded files. Attacks include MIME spoofing (forged Content-Type), extension bypass (.php5, .phtml, .php.jpg double extensions), magic-byte spoofing (GIF89a prefix on PHP), and race conditions (hit the file between validation and move). Defense must run at both the server layer (Nginx: no execute) and the app layer (multi-check).
Procedure
Step 1: Nginx — deny script execution in the upload directory
Most important step — even if a .php file is uploaded, it cannot run:
# add inside the Nginx server block
location /uploads/ {
alias /var/www/html/uploads/;
# disable PHP execution
location ~ \.php$ {
deny all;
return 403;
}
# deny access to .htaccess .user.ini and similar config files
location ~ /\. {
deny all;
return 403;
}
# deny script files
location ~* \.(php|php5|phtml|phar|pl|cgi|py|asp|aspx|jsp|sh)$ {
deny all;
return 403;
}
}
Step 2: Apache — disable execute in the upload directory
On Apache, use .htaccess or httpd.conf:
# add to .htaccess in the upload directory
# (auto-generate this file before upload)
# disable all PHP interpreters
RemoveHandler .php .php5 .phtml .phar
RemoveType .php .php5 .phtml .phar
php_flag engine off
# deny hidden files
RewriteRule "(^|/)\." - [F]
# allow only specific extensions
RewriteCond %{REQUEST_URI} !\.(jpg|jpeg|png|gif|webp|pdf|doc|docx|xlsx)$
RewriteRule .* - [F,L]
Step 3: App layer — PHP upload validation
The app must enforce strict multi-layer checks:
$max_size) {
die('file exceeds size limit');
}
// check 5: rename (UUID or timestamp)
$new_filename = uniqid('upload_', true) . '.' . $extension;
$upload_path = '/var/www/html/uploads/' . $new_filename;
// move file into the upload directory
if (move_uploaded_file($file['tmp_name'], $upload_path)) {
echo 'upload ok: ' . $new_filename;
} else {
die('file save failed');
}
?>
Step 4: App layer — Python Flask upload validation
# app.py - Flask secure file upload example
import os
import uuid
import imghdr
from flask import Flask, request, abort
from werkzeug.utils import secure_filename
app = Flask(__name__)
UPLOAD_DIR = '/var/www/app/uploads/'
MAX_SIZE = 10 * 1024 * 1024 # 10MB
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'}
def allowed_file(filename):
# allowlist extension check
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
abort(400, 'no file selected')
file = request.files['file']
if file.filename == '':
abort(400, 'empty filename')
# validate extension
if not allowed_file(file.filename):
abort(400, 'disallowed file type')
# validate size
file.seek(0, os.SEEK_END)
size = file.tell()
if size > MAX_SIZE:
abort(400, 'file exceeds size limit')
file.seek(0)
# read content and check magic bytes
header = file.read(12)
file.seek(0)
# check image magic
image_type = imghdr.what(None, h=header)
if image_type not in ('jpeg', 'png', 'gif', 'webp'):
abort(400, 'invalid image file')
# rename and save
ext = file.filename.rsplit('.', 1)[1].lower()
new_name = f"{uuid.uuid4().hex}.{ext}"
file.save(os.path.join(UPLOAD_DIR, new_name))
return {'status': 'ok', 'filename': new_name}
Step 5: Restrict PHP upload settings
# edit php.ini
sudo vim /etc/php/8.1/fpm/php.ini
# key upload settings
file_uploads = On
upload_max_filesize = 10M
post_max_size = 12M # must be larger than upload_max_filesize
max_file_uploads = 20 # max files per request
max_execution_time = 30
max_input_time = 60
memory_limit = 128M
# disable dangerous functions
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
Step 6: Random filenames and store metadata
Do not use the user-supplied name. Use a random string:
# Python random filename (recommended)
import uuid
new_filename = uuid.uuid4().hex + '.jpg'
# example: a1b2c3d4e5f6789012345678abcdef01.jpg
# PHP random filename (recommended)
$new_filename = bin2hex(random_bytes(16)) . '.jpg';
# example: a1b2c3d4e5f6789012345678abcdef01.jpg
# also store original name and uploader info in the DB
# CREATE TABLE upload_files (
# id INT AUTO_INCREMENT PRIMARY KEY,
# original_name VARCHAR(255),
# stored_name VARCHAR(255),
# mime_type VARCHAR(100),
# file_size INT,
# upload_ip VARCHAR(45),
# created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
# );
Verification
After config, run these checks:
Check 1: upload PHP script
# try uploading a fake image containing phpinfo
echo 'GIF89a' > test.php.gif
curl -F "file=@test.php.gif" https://helixcyber.ai/upload.php
# expected: rejected (extension check fails)
Check 2: MIME spoof
# forge Content-Type to bypass
curl -F "file=@shell.php;type=image/jpeg" https://helixcyber.ai/upload.php
# expected: rejected (server-side content check is not a real image)
Check 3: execute uploaded file
# if an image passes checks, request its URL with appended PHP
curl https://helixcyber.ai/uploads/evil.jpg
# expected: served as image, PHP not executed
Check 4: path traversal
curl -F "file=@evil.png" "https://helixcyber.ai/upload.php?filename=../../etc/passwd"
# expected: file renamed, original path ignored
Check 5: size limit
dd if=/dev/zero of=largefile.bin bs=1M count=20
curl -F "file=@largefile.bin" https://helixcyber.ai/upload.php
# expected: file exceeds size limit error
FAQ
Q1: Business requires SVG uploads, but SVG can carry XSS and XXE. How to handle it safely?
SVG is XML; it allows scripts and entity refs. Risk is real. Recommended: (1) sanitize after upload with a mature SVG cleaner (DOMPurify, svg-sanitizer) to strip script tags and external entities; (2) set Content-Disposition: attachment and X-Content-Type-Options: nosniff so the browser does not execute SVG scripts in-page; (3) cap SVG size and nesting depth.
Q2: After uploading to cloud storage (OSS/S3), is further defense needed?
Yes. Cloud storage isolates server-side execution, but the file itself can still be malicious. Direct access to uploaded HTML can run XSS. Recommended: (1) CDN WAF in front of the bucket, block non-allowlisted extensions; (2) hotlink protection (Referer allowlist); (3) still run content validation and virus scan on upload.
Summary
Core defense: no execute at the server + multi-check at the app. Nginx/Apache deny script execution in the upload dir. App layer: extension allowlist, MIME check, magic-byte check, size cap, random rename. Bake this into CI/CD as a project-init security baseline. For high-security cases, add ClamAV scan and sandbox dynamic analysis.