Reliance on File Name or Extension of Externally-Supplied File

Description

Reliance on File Name or Extension of Externally-Supplied File occurs when an application makes security decisions based solely on the filename or extension of an uploaded or received file without examining the actual content. Attackers can upload malicious files (executables, scripts, web shells) with trusted extensions or misleading names to bypass security controls.

Risk

Web shells uploaded with image extensions (.jpg, .gif) but executed as code. Executable malware disguised as documents. Server-side scripts bypassing upload filters. Malicious content processed by vulnerable parsers. Ransomware delivered through seemingly innocent files. Data exfiltration through files that bypass DLP systems.

Solution

Validate file content using magic bytes and MIME detection. Use content-based type checking (file signatures). Rename uploaded files with generated names. Store uploads outside web root. Implement allowlist of permitted content types verified by inspection. Use antivirus scanning. Strip executable permissions from uploads.

Common Consequences

ImpactDetails
IntegrityScope: Code Execution

Malicious code executed on server or client.
ConfidentialityScope: Data Breach

Uploaded backdoors provide persistent access.
AvailabilityScope: System Compromise

Full server takeover possible.

Example Code + Solution Code

Vulnerable Code

// VULNERABLE: Extension-only validation
@RestController
public class VulnerableUploadController {

    private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
        "jpg", "jpeg", "png", "gif", "pdf"
    );

    @PostMapping("/upload")
    public String upload(@RequestParam("file") MultipartFile file) {
        String filename = file.getOriginalFilename();

        // VULNERABLE: Only checking extension
        String extension = getExtension(filename).toLowerCase();
        if (!ALLOWED_EXTENSIONS.contains(extension)) {
            throw new BadRequestException("File type not allowed");
        }

        // Attacker uploads shell.php.jpg or shell.jpg (with PHP inside)
        // Apache with misconfiguration may execute it!

        Path uploadPath = Paths.get("/uploads", filename);
        Files.copy(file.getInputStream(), uploadPath);

        return "File uploaded: " + filename;
    }

    // VULNERABLE: Double extension bypass
    @PostMapping("/upload2")
    public String upload2(@RequestParam("file") MultipartFile file) {
        String filename = file.getOriginalFilename();

        // Attacker: filename = "malware.jpg.exe"
        // This check passes!
        if (filename.contains(".jpg") || filename.contains(".png")) {
            saveFile(file);
        }
    }
}

// VULNERABLE: MIME type from request (client-controlled)
@PostMapping("/upload-mime")
public String uploadMime(@RequestParam("file") MultipartFile file) {
    // VULNERABLE: contentType comes from client
    String contentType = file.getContentType();

    if (contentType.startsWith("image/")) {
        // Attacker sets Content-Type: image/png for .exe file
        saveFile(file);
    }
}
# VULNERABLE: Python extension-only validation
import os
from flask import Flask, request

app = Flask(__name__)
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}

def allowed_file_vulnerable(filename):
    # VULNERABLE: Only checks extension
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/upload', methods=['POST'])
def upload_vulnerable():
    file = request.files['file']

    if not allowed_file_vulnerable(file.filename):
        return 'File type not allowed', 400

    # Attacker uploads: shell.php.jpg or webshell.jpg (with PHP code)
    filename = file.filename
    file.save(os.path.join('/uploads', filename))
    return 'File uploaded'

# VULNERABLE: Null byte bypass (older systems)
@app.route('/upload-nullbyte', methods=['POST'])
def upload_nullbyte():
    filename = request.form['filename']

    # Attacker: filename = "shell.php%00.jpg"
    # Extension check sees .jpg
    if filename.endswith('.jpg'):
        # But file is created as shell.php on some systems
        save_file(filename, request.files['file'])

# VULNERABLE: Content-Type from request
@app.route('/upload-mime', methods=['POST'])
def upload_mime():
    file = request.files['file']

    # VULNERABLE: Trusting client-provided MIME type
    if file.content_type.startswith('image/'):
        file.save(os.path.join('/uploads', file.filename))
// VULNERABLE: Node.js extension validation
const express = require('express');
const multer = require('multer');
const path = require('path');

const app = express();

// VULNERABLE: Extension-only validation
const storage = multer.diskStorage({
    destination: '/uploads',
    filename: (req, file, cb) => {
        cb(null, file.originalname);  // Keeps original filename!
    }
});

const fileFilter = (req, file, cb) => {
    const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif'];
    const ext = path.extname(file.originalname).toLowerCase();

    // VULNERABLE: Only checking extension
    if (allowedExtensions.includes(ext)) {
        cb(null, true);
    } else {
        cb(new Error('Invalid file type'));
    }
};

const upload = multer({ storage, fileFilter });

app.post('/upload', upload.single('file'), (req, res) => {
    res.json({ filename: req.file.filename });
});

// VULNERABLE: MIME type from client
const mimeFilter = (req, file, cb) => {
    // file.mimetype comes from client!
    if (file.mimetype.startsWith('image/')) {
        cb(null, true);
    } else {
        cb(new Error('Only images allowed'));
    }
};
<?php
// VULNERABLE: Extension-only check
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'pdf'];

$filename = $_FILES['file']['name'];
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

if (!in_array($extension, $allowed)) {
    die('File type not allowed');
}

// VULNERABLE: Original filename kept
move_uploaded_file($_FILES['file']['tmp_name'], "/uploads/$filename");
// Attacker uploads shell.php.jpg - may execute as PHP!

// VULNERABLE: MIME type from client
$mime = $_FILES['file']['type'];  // Client-controlled!
if (strpos($mime, 'image/') === 0) {
    // Attacker sets Content-Type: image/png
    move_uploaded_file($_FILES['file']['tmp_name'], "/uploads/$filename");
}

// VULNERABLE: getimagesize() bypass
if (getimagesize($_FILES['file']['tmp_name'])) {
    // Attackers can embed valid image header in malicious file
    // "polyglot" files pass this check
    move_uploaded_file($_FILES['file']['tmp_name'], "/uploads/$filename");
}
?>

Fixed Code

// SAFE: Content-based validation
@RestController
public class SafeUploadController {

    private static final Map<String, List<byte[]>> MAGIC_BYTES = Map.of(
        "image/jpeg", List.of(new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}),
        "image/png", List.of(new byte[]{(byte)0x89, 0x50, 0x4E, 0x47}),
        "image/gif", List.of(new byte[]{0x47, 0x49, 0x46, 0x38}),
        "application/pdf", List.of(new byte[]{0x25, 0x50, 0x44, 0x46})
    );

    @PostMapping("/upload")
    public String upload(@RequestParam("file") MultipartFile file) throws IOException {
        // Validate content type by examining file content
        String detectedType = detectContentType(file.getInputStream());

        if (!MAGIC_BYTES.containsKey(detectedType)) {
            throw new BadRequestException("File type not allowed");
        }

        // Additional validation with Apache Tika
        String tikaType = new Tika().detect(file.getInputStream());
        if (!tikaType.startsWith("image/") && !tikaType.equals("application/pdf")) {
            throw new BadRequestException("Invalid file content");
        }

        // Generate safe filename
        String safeFilename = UUID.randomUUID().toString() + getExtensionForType(detectedType);

        // Store outside web root
        Path uploadPath = Paths.get("/secure-uploads", safeFilename);
        Files.copy(file.getInputStream(), uploadPath);

        return "File uploaded: " + safeFilename;
    }

    private String detectContentType(InputStream is) throws IOException {
        byte[] header = new byte[8];
        is.read(header);
        is.reset();

        for (Map.Entry<String, List<byte[]>> entry : MAGIC_BYTES.entrySet()) {
            for (byte[] magic : entry.getValue()) {
                if (startsWith(header, magic)) {
                    return entry.getKey();
                }
            }
        }
        return "unknown";
    }

    private boolean startsWith(byte[] data, byte[] prefix) {
        if (data.length < prefix.length) return false;
        for (int i = 0; i < prefix.length; i++) {
            if (data[i] != prefix[i]) return false;
        }
        return true;
    }

    private String getExtensionForType(String type) {
        return switch (type) {
            case "image/jpeg" -> ".jpg";
            case "image/png" -> ".png";
            case "image/gif" -> ".gif";
            case "application/pdf" -> ".pdf";
            default -> "";
        };
    }
}
# SAFE: Content-based validation in Python
import magic
import os
import uuid
from PIL import Image
from flask import Flask, request

app = Flask(__name__)

# Allowed MIME types detected by content
ALLOWED_MIME_TYPES = {
    'image/jpeg',
    'image/png',
    'image/gif',
    'application/pdf'
}

# Extension mapping for allowed types
MIME_TO_EXTENSION = {
    'image/jpeg': '.jpg',
    'image/png': '.png',
    'image/gif': '.gif',
    'application/pdf': '.pdf'
}

def validate_file_content(file_stream):
    """Detect MIME type from file content using libmagic."""
    file_stream.seek(0)
    header = file_stream.read(2048)
    file_stream.seek(0)

    mime = magic.from_buffer(header, mime=True)
    return mime

def validate_image_integrity(file_stream, mime_type):
    """Additional validation for images - verify they can be opened."""
    if not mime_type.startswith('image/'):
        return True

    try:
        file_stream.seek(0)
        img = Image.open(file_stream)
        img.verify()  # Verify it's a valid image
        file_stream.seek(0)
        return True
    except Exception:
        return False

@app.route('/upload', methods=['POST'])
def upload_safe():
    file = request.files['file']

    # Detect MIME type from content
    detected_mime = validate_file_content(file.stream)

    if detected_mime not in ALLOWED_MIME_TYPES:
        return f'File type {detected_mime} not allowed', 400

    # Additional integrity check for images
    if not validate_image_integrity(file.stream, detected_mime):
        return 'Invalid or corrupted image', 400

    # Generate safe filename
    extension = MIME_TO_EXTENSION.get(detected_mime, '')
    safe_filename = str(uuid.uuid4()) + extension

    # Store in secure location (outside web root)
    upload_path = os.path.join('/secure-uploads', safe_filename)
    file.seek(0)
    file.save(upload_path)

    # Set restrictive permissions
    os.chmod(upload_path, 0o644)

    return {'filename': safe_filename, 'type': detected_mime}

# SAFE: With antivirus scanning
import clamd

def scan_file(file_path):
    cd = clamd.ClamdUnixSocket()
    result = cd.scan(file_path)
    return result[file_path][0] == 'OK'

@app.route('/upload-scanned', methods=['POST'])
def upload_scanned():
    # ... validation as above ...

    # Scan with antivirus
    if not scan_file(upload_path):
        os.remove(upload_path)
        return 'Malware detected', 400

    return {'filename': safe_filename}
// SAFE: Node.js with content validation
const express = require('express');
const multer = require('multer');
const fileType = require('file-type');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const fs = require('fs');

const app = express();

const ALLOWED_TYPES = new Map([
    ['image/jpeg', '.jpg'],
    ['image/png', '.png'],
    ['image/gif', '.gif'],
    ['application/pdf', '.pdf']
]);

// Store temporarily for validation
const tempStorage = multer.diskStorage({
    destination: '/tmp/uploads',
    filename: (req, file, cb) => {
        cb(null, uuidv4());
    }
});

const upload = multer({
    storage: tempStorage,
    limits: { fileSize: 10 * 1024 * 1024 }  // 10MB limit
});

app.post('/upload', upload.single('file'), async (req, res) => {
    const tempPath = req.file.path;

    try {
        // Detect type from file content
        const type = await fileType.fromFile(tempPath);

        if (!type || !ALLOWED_TYPES.has(type.mime)) {
            fs.unlinkSync(tempPath);
            return res.status(400).json({
                error: `File type ${type?.mime || 'unknown'} not allowed`
            });
        }

        // For images, additional validation
        if (type.mime.startsWith('image/')) {
            const sharp = require('sharp');
            try {
                await sharp(tempPath).metadata();
            } catch (e) {
                fs.unlinkSync(tempPath);
                return res.status(400).json({ error: 'Invalid image file' });
            }
        }

        // Move to final location with safe name
        const extension = ALLOWED_TYPES.get(type.mime);
        const safeFilename = uuidv4() + extension;
        const finalPath = path.join('/secure-uploads', safeFilename);

        fs.renameSync(tempPath, finalPath);
        fs.chmodSync(finalPath, 0o644);

        res.json({ filename: safeFilename, type: type.mime });

    } catch (error) {
        if (fs.existsSync(tempPath)) {
            fs.unlinkSync(tempPath);
        }
        res.status(500).json({ error: 'Upload failed' });
    }
});
<?php
// SAFE: Content-based validation in PHP
class SafeFileUpload {
    private $allowedTypes = [
        'image/jpeg' => '.jpg',
        'image/png' => '.png',
        'image/gif' => '.gif',
        'application/pdf' => '.pdf'
    ];

    private $uploadDir = '/secure-uploads/';

    public function upload($file) {
        // Detect MIME from content using finfo
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $detectedMime = $finfo->file($file['tmp_name']);

        if (!array_key_exists($detectedMime, $this->allowedTypes)) {
            throw new Exception("File type $detectedMime not allowed");
        }

        // Additional validation for images
        if (strpos($detectedMime, 'image/') === 0) {
            if (!$this->validateImage($file['tmp_name'])) {
                throw new Exception("Invalid or corrupted image");
            }
        }

        // Generate safe filename
        $extension = $this->allowedTypes[$detectedMime];
        $safeFilename = bin2hex(random_bytes(16)) . $extension;
        $finalPath = $this->uploadDir . $safeFilename;

        // Move file
        if (!move_uploaded_file($file['tmp_name'], $finalPath)) {
            throw new Exception("Upload failed");
        }

        // Set restrictive permissions
        chmod($finalPath, 0644);

        return [
            'filename' => $safeFilename,
            'type' => $detectedMime
        ];
    }

    private function validateImage($path) {
        // Use GD to verify image integrity
        $imageInfo = @getimagesize($path);
        if (!$imageInfo) {
            return false;
        }

        // Try to actually load the image
        switch ($imageInfo[2]) {
            case IMAGETYPE_JPEG:
                $img = @imagecreatefromjpeg($path);
                break;
            case IMAGETYPE_PNG:
                $img = @imagecreatefrompng($path);
                break;
            case IMAGETYPE_GIF:
                $img = @imagecreatefromgif($path);
                break;
            default:
                return false;
        }

        if (!$img) {
            return false;
        }

        imagedestroy($img);
        return true;
    }
}

// Usage
$uploader = new SafeFileUpload();
try {
    $result = $uploader->upload($_FILES['file']);
    echo json_encode($result);
} catch (Exception $e) {
    http_response_code(400);
    echo json_encode(['error' => $e->getMessage()]);
}
?>

Exploited in the Wild

Web Shell Uploads

PHP/ASP shells with image extensions executed on servers.

Malware Distribution

Executables disguised as documents.

RCE via Uploads

Server compromise through malicious file uploads.


Tools to test/exploit

  • Burp Suite — upload testing.

  • Polyglot file generators.

  • File upload vulnerability scanners.


CVE Examples

  • CVE-2020-13942: Apache Unomi RCE via file upload.

  • Numerous web shell upload CVEs.


References

  1. MITRE. "CWE-646: Reliance on File Name or Extension of Externally-Supplied File." https://cwe.mitre.org/data/definitions/646.html

  2. OWASP. "Unrestricted File Upload."