Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Vulnerability Research

Insecure File Upload Vulnerability: Fixes

Insecure file upload lets attackers upload webshells for RCE. Detect it with SAST, test with Burp Suite, and fix it in ASP.NET, PHP, Django & Express.

Offensive360 Security Research Team — min read
insecure file upload file upload vulnerability unrestricted file upload CWE-434 webshell upload remote code execution OWASP A04 SAST web application security file upload security magic bytes MIME type validation

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:

  1. An attacker uploads a server-side script (e.g., shell.php, cmd.aspx, shell.jsp) disguised as an image or document
  2. The server stores the file in a web-accessible directory
  3. The attacker requests the uploaded file’s URL directly
  4. The web server executes the script as application code
  5. 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 .php regardless 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

  1. Intercept the upload request. Use Burp Proxy to capture the multipart form POST when you submit a legitimate image.

  2. Modify the filename. Change photo.jpg to shell.php (or shell.aspx, shell.jsp depending on the server language).

  3. 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']); ?>
  1. 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).

  2. 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-Type header from the request
  • Original filename usage — using file.FileName, $_FILES[...]['name'], or request.FILES['file'].name directly 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-Type header from the request.
  • File size limit — enforce a maximum size at both the application layer and the web server (client_max_body_size in nginx, LimitRequestBody in 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/ (not wwwroot/uploads/ or public/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-stream for 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:

DefensePrevents
Extension allow-listObvious executable extension uploads
Magic bytes validationContent-type spoofing
Random UUID filenameOriginal filename path traversal
Storage outside web rootDirect URL execution of uploaded files
Content-Disposition: attachmentBrowser-side execution
Image re-encodingPolyglot 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.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.