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
| Impact | Details |
|---|---|
| Integrity | Scope: Code Execution Malicious code executed on server or client. |
| Confidentiality | Scope: Data Breach Uploaded backdoors provide persistent access. |
| Availability | Scope: 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
-
MITRE. "CWE-646: Reliance on File Name or Extension of Externally-Supplied File." https://cwe.mitre.org/data/definitions/646.html
-
OWASP. "Unrestricted File Upload."