Insecure file upload is one of the most consistently dangerous web application vulnerabilities. When an application lets users upload files without properly validating what those files are, attackers can upload server-side scripts — webshells — and achieve remote code execution (RCE) on the server. The OWASP Top 10 classifies it under A04:2021 (Insecure Design), and it maps to CWE-434: Unrestricted Upload of File with Dangerous Type.
This guide covers how insecure file upload works, how to test for it, and how to fix it in ASP.NET Core, PHP, Django, and Express — with secure code examples for each.
How Insecure File Upload Works
The attack is conceptually simple:
- An attacker uploads a server-side script (e.g.,
shell.php,cmd.aspx,shell.jsp) disguised as an image or document - The server stores the file in a web-accessible directory
- The attacker requests the uploaded file’s URL directly
- The web server executes the script as application code
- The attacker now has RCE — they can read files, execute commands, pivot laterally, or establish persistence
The complete attack path requires two conditions to be true simultaneously:
- The server accepts the uploaded file (weak or missing validation)
- The web server can execute the uploaded file (storage in the document root or web-accessible directory)
Preventing either condition is sufficient to prevent RCE. But the most robust defense eliminates both.
A Minimal Working Exploit
Given a vulnerable PHP endpoint that accepts file uploads:
POST /api/upload-profile-photo HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="photo.php"
Content-Type: image/jpeg
<?php
if(isset($_GET['cmd'])) {
echo "<pre>" . shell_exec($_GET['cmd']) . "</pre>";
}
?>
------WebKitFormBoundary--
If the file is stored as uploads/photo.php under the document root, the attacker visits:
https://target.com/uploads/photo.php?cmd=id
And receives:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
RCE is confirmed. From here, the attacker typically reads /etc/passwd, downloads the application config to extract database credentials, establishes a reverse shell, or moves laterally.
What Makes File Upload Validation Fail
Trusting the Content-Type Header
The most common mistake is validating the Content-Type header:
// VULNERABLE — Content-Type is controlled by the attacker
if ($_FILES['file']['type'] !== 'image/jpeg') {
die("Only JPEG files allowed");
}
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
The Content-Type header is set by the browser — or, in the case of a custom HTTP request, by the attacker. Changing Content-Type: image/jpeg while uploading shell.php takes less than a second in any HTTP proxy tool.
Checking Only the File Extension
// VULNERABLE — extension check is easily bypassed
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
if ($ext !== 'jpg' && $ext !== 'png') {
die("Only images allowed");
}
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
Common bypass techniques for extension-only checks:
- Double extension:
shell.php.jpg— if the server executes.phpregardless of what follows - Null byte injection:
shell.php%00.jpg— in older PHP versions, terminates the string at the null byte - Case variation:
shell.PHP,shell.Php— if the check is case-sensitive - Less common executable extensions:
.phtml,.php5,.phar— often not in the deny list
Storing Files in the Document Root
Even with good validation, storing uploaded files inside the wwwroot, public_html, or equivalent directory is dangerous. If validation is ever bypassed (or if a future developer relaxes validation), files become executable.
// RISKY — even with good validation, storing in wwwroot creates future risk
var path = Path.Combine("wwwroot/uploads", safeFileName);
Using the Original Filename
# VULNERABLE — path traversal via filename
filename = request.FILES['file'].name
with open(f'/var/uploads/{filename}', 'wb') as f:
...
An attacker submits a filename like ../../etc/cron.d/evil to write outside the intended upload directory.
How to Test for Insecure File Upload
With Burp Suite
-
Intercept the upload request. Use Burp Proxy to capture the multipart form POST when you submit a legitimate image.
-
Modify the filename. Change
photo.jpgtoshell.php(orshell.aspx,shell.jspdepending on the server language). -
Try different Content-Type bypasses:
# Test 1: PHP shell with image/jpeg Content-Type
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg
<?php system($_GET['cmd']); ?>
# Test 2: Double extension
filename="shell.php.jpg"
Content-Type: image/jpeg
# Test 3: Null byte (PHP < 5.3)
filename="shell.php%00.jpg"
# Test 4: JPEG header + PHP code (magic bytes bypass)
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg
ÿØÿà <?php system($_GET['cmd']); ?>
-
Check the response. Note the URL where the file was stored (often returned in the JSON response or visible in the page source after upload).
-
Request the uploaded file. Visit the URL — if the server returns the PHP code as text, PHP is not executing (but the upload still worked). If the server executes the code and returns command output, you have RCE.
With SAST Tools
Static analysis tools find the code patterns that create this vulnerability — before the code is ever deployed. Offensive360 SAST detects:
- Unvalidated file extensions — upload handlers that don’t check or restrict file extensions
- Content-Type-only validation — relying solely on the
Content-Typeheader from the request - Original filename usage — using
file.FileName,$_FILES[...]['name'], orrequest.FILES['file'].namedirectly as the storage path (path traversal risk) - Web-accessible storage paths — files stored inside the web root without execution restrictions
The advantage of SAST is detecting the vulnerability at code review time — before it’s deployed and before manual testing is even possible.
Secure File Upload Code Examples
ASP.NET Core — Complete Secure Implementation
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using System.IO;
[ApiController]
[Route("api/[controller]")]
public class UploadController : ControllerBase
{
// ✅ Explicit allow-list of permitted extensions
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".webp"
};
// ✅ JPEG, PNG, GIF, WebP magic bytes
private static readonly Dictionary<string, byte[]> MagicBytes = new()
{
[".jpg"] = new byte[] { 0xFF, 0xD8, 0xFF },
[".jpeg"] = new byte[] { 0xFF, 0xD8, 0xFF },
[".png"] = new byte[] { 0x89, 0x50, 0x4E, 0x47 },
[".gif"] = new byte[] { 0x47, 0x49, 0x46, 0x38 },
[".webp"] = new byte[] { 0x52, 0x49, 0x46, 0x46 },
};
// ✅ Store outside the web root — not under wwwroot
private readonly string _uploadPath = "/var/app/uploads";
[HttpPost("avatar")]
public async Task<IActionResult> UploadAvatar(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest("No file uploaded.");
// ✅ File size limit
if (file.Length > 5 * 1024 * 1024)
return BadRequest("File exceeds 5 MB limit.");
// ✅ Extension allow-list check (case-insensitive)
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return BadRequest("File type not permitted.");
// ✅ Magic bytes validation — verify actual file content
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var fileBytes = ms.ToArray();
if (!HasValidMagicBytes(ext, fileBytes))
return BadRequest("File content does not match the declared type.");
// ✅ Generate a random filename — never use file.FileName directly
var safeFileName = $"{Guid.NewGuid()}{ext}";
// ✅ Store outside the web root
var fullPath = Path.Combine(_uploadPath, safeFileName);
await System.IO.File.WriteAllBytesAsync(fullPath, fileBytes);
// Return only the identifier — serve through a controlled endpoint
return Ok(new { id = safeFileName });
}
private static bool HasValidMagicBytes(string ext, byte[] fileBytes)
{
if (!MagicBytes.TryGetValue(ext, out var magic))
return false;
if (fileBytes.Length < magic.Length)
return false;
for (int i = 0; i < magic.Length; i++)
{
if (fileBytes[i] != magic[i]) return false;
}
return true;
}
}
Serving uploaded files through a controlled endpoint:
[HttpGet("avatar/{id}")]
public IActionResult GetAvatar(string id)
{
// ✅ Validate the ID format — no path traversal
if (!Regex.IsMatch(id, @"^[a-f0-9\-]+\.(jpg|jpeg|png|gif|webp)$", RegexOptions.IgnoreCase))
return BadRequest("Invalid file identifier.");
var filePath = Path.Combine(_uploadPath, id);
if (!System.IO.File.Exists(filePath))
return NotFound();
// ✅ Set Content-Disposition: attachment prevents browser execution
return PhysicalFile(filePath, "application/octet-stream",
enableRangeProcessing: false);
}
PHP — Secure File Upload
<?php
declare(strict_types=1);
// ✅ Extension allow-list
const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif'];
// ✅ Maximum file size: 5 MB
const MAX_FILE_SIZE = 5 * 1024 * 1024;
// ✅ Upload directory outside document root
const UPLOAD_DIR = '/var/app/uploads/';
function validateAndSaveUpload(array $file): string
{
// Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload error: ' . $file['error']);
}
// ✅ Size check
if ($file['size'] > MAX_FILE_SIZE) {
throw new InvalidArgumentException('File exceeds maximum size.');
}
// ✅ Extension allow-list (case-insensitive)
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ALLOWED_EXTENSIONS, true)) {
throw new InvalidArgumentException('File type not permitted.');
}
// ✅ MIME type validation via finfo — not from $_FILES['type']
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$allowedMimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
];
if (!isset($allowedMimeTypes[$ext]) || $allowedMimeTypes[$ext] !== $mimeType) {
throw new InvalidArgumentException('File MIME type does not match extension.');
}
// ✅ Generate random filename — never use original
$safeFileName = bin2hex(random_bytes(16)) . '.' . $ext;
$destination = UPLOAD_DIR . $safeFileName;
// ✅ Use move_uploaded_file (verifies this is actually an HTTP upload)
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new RuntimeException('Failed to save uploaded file.');
}
return $safeFileName;
}
// Usage in upload handler
try {
$fileId = validateAndSaveUpload($_FILES['avatar']);
echo json_encode(['id' => $fileId]);
} catch (InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
} catch (RuntimeException $e) {
http_response_code(500);
echo json_encode(['error' => 'Upload failed.']);
}
Python / Django — Secure Upload
import uuid
import os
import imghdr
from pathlib import Path
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.views import View
# ✅ Upload directory outside MEDIA_ROOT (which is web-accessible)
SECURE_UPLOAD_DIR = Path('/var/app/uploads')
SECURE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_IMAGE_TYPES = {'jpeg', 'png', 'gif', 'webp'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
class AvatarUploadView(View):
def post(self, request):
if 'file' not in request.FILES:
return JsonResponse({'error': 'No file provided'}, status=400)
uploaded_file = request.FILES['file']
# ✅ Size check
if uploaded_file.size > MAX_FILE_SIZE:
return JsonResponse({'error': 'File too large'}, status=400)
# ✅ Content validation using imghdr (checks actual file bytes)
# imghdr.what() reads the file signature, not the Content-Type header
img_type = imghdr.what(uploaded_file)
if img_type not in ALLOWED_IMAGE_TYPES:
return JsonResponse({'error': 'Invalid file type'}, status=400)
# ✅ Generate safe random filename
safe_name = f"{uuid.uuid4()}.{img_type}"
save_path = SECURE_UPLOAD_DIR / safe_name
# ✅ Save to directory outside MEDIA_ROOT
with open(save_path, 'wb') as dest:
for chunk in uploaded_file.chunks():
dest.write(chunk)
return JsonResponse({'id': safe_name})
Serving uploaded files securely in Django:
import re
from django.http import FileResponse, Http404
def serve_upload(request, file_id):
# ✅ Validate file_id to prevent path traversal
if not re.match(r'^[a-f0-9-]+\.(jpeg|png|gif|webp)$', file_id):
raise Http404
file_path = SECURE_UPLOAD_DIR / file_id
if not file_path.exists():
raise Http404
# ✅ Serve as attachment — prevents browser execution
return FileResponse(
open(file_path, 'rb'),
as_attachment=True,
filename=file_id,
content_type='application/octet-stream'
)
Node.js / Express — Secure Upload with Multer
const express = require('express');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const fs = require('fs');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
const app = express();
// ✅ File type signatures (magic bytes)
const MAGIC_BYTES = {
jpeg: [Buffer.from([0xFF, 0xD8, 0xFF])],
png: [Buffer.from([0x89, 0x50, 0x4E, 0x47])],
gif: [Buffer.from([0x47, 0x49, 0x46, 0x38])],
webp: [Buffer.from([0x52, 0x49, 0x46, 0x46])],
};
// ✅ Store in a temp directory first, then validate
const storage = multer.diskStorage({
destination: '/tmp/uploads-pending/',
filename: (req, file, cb) => {
// Use a temp name — we'll rename after validation
cb(null, `tmp-${uuidv4()}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // ✅ 5 MB limit
fileFilter: (req, file, cb) => {
// ✅ Extension allow-list check
const ext = path.extname(file.originalname).toLowerCase().slice(1);
if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
return cb(new Error('File type not permitted'));
}
cb(null, true);
},
});
// ✅ Secure upload directory — outside the Express static file directory
const SECURE_UPLOAD_DIR = '/var/app/uploads/';
async function validateMagicBytes(filePath, ext) {
const normalizedExt = ext === 'jpg' ? 'jpeg' : ext;
const signatures = MAGIC_BYTES[normalizedExt];
if (!signatures) return false;
const buffer = await readFile(filePath);
return signatures.some(sig => buffer.slice(0, sig.length).equals(sig));
}
app.post('/api/upload/avatar', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const ext = path.extname(req.file.originalname).toLowerCase().slice(1);
try {
// ✅ Validate magic bytes — reject if content doesn't match extension
const isValid = await validateMagicBytes(req.file.path, ext);
if (!isValid) {
fs.unlinkSync(req.file.path); // Clean up temp file
return res.status(400).json({ error: 'File content does not match declared type' });
}
// ✅ Generate final safe filename and move to secure directory
const safeFileName = `${uuidv4()}.${ext === 'jpg' ? 'jpeg' : ext}`;
const finalPath = path.join(SECURE_UPLOAD_DIR, safeFileName);
fs.renameSync(req.file.path, finalPath);
res.json({ id: safeFileName });
} catch (err) {
// Clean up temp file on error
if (fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path);
}
res.status(500).json({ error: 'Upload failed' });
}
});
Defense-in-Depth: The Complete Checklist
Implement all of these layers — a single layer can be bypassed; all layers together create robust protection:
Layer 1: Input Validation
- Extension allow-list — only permit specific extensions (
jpg,png,pdf). Deny all others, including.php,.phtml,.aspx,.jsp,.exe. - Magic bytes check — read the first 4–8 bytes of the file and compare against known-good file signatures. Do not trust the
Content-Typeheader from the request. - File size limit — enforce a maximum size at both the application layer and the web server (
client_max_body_sizein nginx,LimitRequestBodyin Apache). - Filename sanitization — reject path separators (
/,\,..) in filenames. Generate a random UUID-based filename instead of using the original.
Layer 2: Storage Security
- Store outside the web root — uploaded files should be in a directory that the web server does not serve as static files. Examples:
/var/app/uploads/(notwwwroot/uploads/orpublic/uploads/). - Disable execution in upload directories — if uploads must be in the web root, configure the web server to never execute scripts in that directory:
# nginx — disable PHP execution in the upload directory
location /uploads/ {
location ~ \.php$ { deny all; }
}
# Apache — disable execution in uploads directory
<Directory /var/www/html/uploads>
php_flag engine off
Options -ExecCGI
AddType text/plain .php .phtml .php5 .phar
</Directory>
Layer 3: Serving Files Securely
- Serve through a controlled endpoint — never expose the raw filesystem path. Serve uploaded files through an application endpoint that validates the file ID before returning the file.
- Set
Content-Disposition: attachment— this tells browsers to download the file rather than render it, even if the server misconfigures the MIME type. - Set a safe Content-Type — return
application/octet-streamfor user-uploaded files rather than trusting the stored MIME type.
Layer 4: Re-encoding for Images (Highest Assurance)
For the highest assurance against polyglot files (files that are valid both as images and as executable scripts), re-encode images server-side:
// ASP.NET Core — re-encode image to strip any embedded content
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
var image = await Image.LoadAsync(fileBytes);
using var outputStream = new MemoryStream();
await image.SaveAsJpegAsync(outputStream); // Re-encode as clean JPEG
var cleanBytes = outputStream.ToArray();
Re-encoding destroys any PHP/script content embedded in an image file, because the re-encoder reads only pixel data and writes a fresh, clean file.
How SAST Detects Insecure File Upload
A SAST tool with taint analysis detects insecure file upload by tracking data from the upload handler to the file storage sink. Specific patterns Offensive360 flags:
Pattern 1: Original filename used as storage path
// FLAGGED — file.FileName flows directly to Path.Combine
var path = Path.Combine("uploads", file.FileName); // ← Tainted source in storage sink
Pattern 2: Content-Type-only validation
// FLAGGED — validation relies solely on attacker-controlled Content-Type
if ($_FILES['file']['type'] === 'image/jpeg') {
move_uploaded_file(...);
}
Pattern 3: Storage in web-accessible directory
# FLAGGED — storage path is within MEDIA_ROOT (web-accessible)
save_path = settings.MEDIA_ROOT / uploaded_file.name
These patterns are detectable at code review time — before the vulnerability is deployed. Running Offensive360 SAST in your CI/CD pipeline catches insecure file upload in pull requests, blocking the vulnerable code before it reaches staging or production.
Frequently Asked Questions
Can an attacker upload a webshell without using a PHP extension?
Yes, depending on the server configuration. Server-Side Template Injection (SSTI) via uploaded template files, SSRF via crafted SVG files with <image> tags pointing to internal services, and stored XSS via SVG files with embedded <script> tags are all possible even when PHP execution is blocked. Always validate file content, not just file extension.
Does checking magic bytes completely prevent file upload attacks?
Magic bytes validation is a strong control but not infallible. Polyglot files — files that are simultaneously valid as both an image and as executable code — can pass magic byte checks. The most robust defense combines magic bytes validation with server-side re-encoding (which destroys any non-image content) and storage outside the web root (which prevents execution even if the file sneaks through).
What is a polyglot file in the context of file upload attacks?
A polyglot file has valid headers for one file format (e.g., a valid JPEG SOI marker at offset 0) but also contains executable code that a server will execute if the file is served from the document root. Tools like FFmpeg and Python scripts can generate JPEG/PNG files that contain embedded PHP code after the image data. Magic byte validation alone doesn’t catch these — server-side re-encoding does.
Should I scan uploaded files with antivirus?
For applications that accept document uploads (PDF, DOCX, XLSX), antivirus scanning via ClamAV or a cloud malware scanning API is a useful additional layer. However, for web applications concerned with webshell uploads, preventing server-side execution (by storing outside the web root and never executing uploaded files) is more reliable than relying on antivirus detection, which lags behind new webshell variants.
How do I secure file uploads in a microservices architecture?
The same principles apply, but delegate file validation and storage to a dedicated upload service. The upload service validates, re-encodes (for images), generates a UUID-based ID, stores to object storage (S3, Azure Blob, GCS) outside the web root, and returns the ID. Application services request files through the upload service, which serves them with appropriate headers. Object storage is inherently not a web server — it won’t execute PHP or ASPX files, which eliminates the execution vector entirely.
Summary
Insecure file upload is preventable with a few consistent practices:
| Defense | Prevents |
|---|---|
| Extension allow-list | Obvious executable extension uploads |
| Magic bytes validation | Content-type spoofing |
| Random UUID filename | Original filename path traversal |
| Storage outside web root | Direct URL execution of uploaded files |
Content-Disposition: attachment | Browser-side execution |
| Image re-encoding | Polyglot file execution |
Every one of these controls is independently implementable and detectable by SAST tooling. The vulnerability persists because developers don’t know which controls are required — it isn’t enough to check the extension, and it isn’t enough to check the Content-Type header.
Run a one-time SAST scan on your codebase to find insecure file upload handlers and 100+ other vulnerability classes — full report delivered within 48 hours for $500. Or browse the knowledge base entry for CWE-434 for a quick remediation reference.