MIME type spoofing: how attackers disguise malicious files
MIME type spoofing is the practice of renaming a malicious file or setting
a false Content-Type header so that an upload endpoint believes
the file is something harmless. An attacker renames a PHP webshell to
profile.jpg, sends it with Content-Type: image/jpeg,
and your server stores it as an image — until something executes it.
This is one of the most common techniques for bypassing naïve upload filters
and it takes only seconds to execute with a tool like Burp Suite or even
a simple curl command.
What you cannot trust
| Source | Controlled by | Trustworthy? |
|---|---|---|
Content-Type HTTP header |
Client (browser, curl, script) | No |
File extension (.jpg, .pdf) |
Client | No |
req.file.mimetype (Multer) |
Derived from Content-Type header — client |
No |
| Magic bytes (first bytes of file content) | Difficult to fake without corrupting the file | Much more reliable |
| ClamAV signature match | Server-side (ClamAV database) | Yes, for known threats |
req.file.mimetype in Multer is read directly from the
Content-Type part of the multipart request — exactly what the
client sent. It is not determined by reading the file bytes.
Never rely on it for security decisions.
Magic bytes explained
Most file formats start with a fixed byte sequence — a "magic number" — that
identifies the format. JPEG files always start with FF D8 FF.
PNG files start with 89 50 4E 47 (the bytes \x89PNG).
An attacker who renames a PHP file to image.jpg cannot change
the magic bytes without corrupting the file and making it unreadable as an
image.
Reading the first 12 bytes of the uploaded file and checking them against a known-good list is fast (a single disk read) and cannot be spoofed by changing headers or filenames.
Common file signatures
| Format | Magic bytes (hex) | ASCII / notes |
|---|---|---|
| JPEG | FF D8 FF |
— |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
\x89PNG\r\n\x1a\n |
| GIF | 47 49 46 38 |
GIF8 |
| WebP | 52 49 46 46 ?? ?? ?? ?? 57 45 42 50 |
RIFF....WEBP |
25 50 44 46 |
%PDF |
|
| ZIP / DOCX / XLSX | 50 4B 03 04 |
PK\x03\x04 |
| OLE2 (legacy .xls, .doc) | D0 CF 11 E0 |
— |
| ELF (Linux executable) | 7F 45 4C 46 |
\x7FELF |
| Windows PE (.exe, .dll) | 4D 5A |
MZ |
The complete defence pipeline
A robust upload endpoint validates the file at multiple layers:
-
File size limit — enforce before writing to disk
(Multer's
limits.fileSize). - Extension allowlist — reject any extension not in your allowed set, even before reading the file.
- Magic byte validation — read the first 12 bytes and confirm the actual format matches your allowlist. Reject if mismatched.
-
Dangerous type blocklist — explicitly reject executables
(
MZ,ELF), scripts, and other types your application should never receive. - Antivirus scan — call pompelmi for known malware signatures, regardless of what the magic bytes say.
Layers 1–4 run in milliseconds and require no external process. Layer 5 catches known threats that passed all other checks.
Complete validation helper
// lib/validateUpload.js
const fs = require('fs');
// Map of allowed types: extension → expected magic bytes
const ALLOWED_TYPES = {
jpeg: { bytes: [0xFF, 0xD8, 0xFF], offset: 0 },
png: { bytes: [0x89, 0x50, 0x4E, 0x47], offset: 0 },
gif: { bytes: [0x47, 0x49, 0x46, 0x38], offset: 0 },
webp: { bytes: [0x57, 0x45, 0x42, 0x50], offset: 8 },
pdf: { bytes: [0x25, 0x50, 0x44, 0x46], offset: 0 },
};
// Types that should always be rejected regardless of claimed extension
const BLOCKED_MAGIC = [
[0x4D, 0x5A], // Windows PE (exe, dll, sys)
[0x7F, 0x45, 0x4C, 0x46], // ELF (Linux executables)
[0x50, 0x4B, 0x03, 0x04], // ZIP / OOXML (archives)
[0xD0, 0xCF, 0x11, 0xE0], // OLE2 (legacy Office macros)
];
function readBytes(filePath, offset, length) {
const fd = fs.openSync(filePath, 'r');
const buf = Buffer.alloc(length);
fs.readSync(fd, buf, 0, length, offset);
fs.closeSync(fd);
return buf;
}
function startsWith(buf, bytes) {
return bytes.every((b, i) => buf[i] === b);
}
/**
* Returns the detected type string, or throws with a human-readable error.
*/
function validateUpload(filePath, originalName, allowedExtensions) {
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
// Extension check
if (!allowedExtensions.includes(ext)) {
throw Object.assign(new Error(`File type .${ext} is not accepted.`), { status: 400 });
}
const header = readBytes(filePath, 0, 12);
// Block dangerous types
for (const magic of BLOCKED_MAGIC) {
if (startsWith(header, magic)) {
throw Object.assign(new Error('Executable or archive files are not accepted.'), { status: 400 });
}
}
// Validate actual format
for (const [type, sig] of Object.entries(ALLOWED_TYPES)) {
const chunk = readBytes(filePath, sig.offset, sig.bytes.length);
if (startsWith(chunk, sig.bytes)) return type;
}
throw Object.assign(new Error('File format not recognised.'), { status: 400 });
}
module.exports = { validateUpload };
Use it in your upload handler before calling pompelmi:
const { scan, Verdict } = require('pompelmi');
const { validateUpload } = require('./lib/validateUpload');
app.post('/upload', upload.single('file'), async (req, res) => {
const tmpPath = req.file.path;
try {
// Step 1 — multi-layer validation (throws on failure)
const type = validateUpload(tmpPath, req.file.originalname, ['jpeg', 'png', 'pdf']);
// Step 2 — antivirus scan
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) return res.status(400).json({ error: 'Malware detected.' });
if (verdict === Verdict.ScanError) return res.status(422).json({ error: 'Scan incomplete.' });
return res.json({ status: 'ok', type });
} catch (err) {
return res.status(err.status ?? 500).json({ error: err.message });
} finally {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
}
});
Next steps
- Specifically securing image uploads? See Scanning image uploads (JPEG, PNG, WebP, GIF) in Node.js.
- Protecting against macro-laden Office files? See Scanning Excel and CSV files for malicious macros.
- Want the full checklist of upload risks? Read Node.js file upload security checklist.