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
PDF 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:

  1. File size limit — enforce before writing to disk (Multer's limits.fileSize).
  2. Extension allowlist — reject any extension not in your allowed set, even before reading the file.
  3. Magic byte validation — read the first 12 bytes and confirm the actual format matches your allowlist. Reject if mismatched.
  4. Dangerous type blocklist — explicitly reject executables (MZ, ELF), scripts, and other types your application should never receive.
  5. 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