Blog/Web Security
Web SecurityAugust 1, 2026 · 7 min read

Secure File Uploads: Stopping the Web Shell Before It Lands

The dangerous file upload isn't the one that stores a big file — it's the one that stores an executable file somewhere the web server will run it. An attacker uploads shell.php disguised as an image, your app saves it under the web root, they browse to /uploads/shell.php, and now they have remote code execution. Every upload feature has to answer one question: can anything a user uploads ever be executed or interpreted? If the answer is no, most of the risk is gone regardless of what they send.

The bypasses that beat naive validation

Uploads get exploited because the common checks all inspect attacker-controlled metadata:

  • Extension tricksshell.php.jpg, shell.pHp, shell.php5, shell.phtml, trailing dots/spaces, and double extensions a misconfigured server still executes.
  • Content-Type spoofing — the client sets Content-Type: image/jpeg on a PHP script; the header is a claim, not a fact.
  • Magic-byte prefixing — prepend real JPEG bytes so a content sniff passes, while the file still contains executable code the server runs.
  • Polyglots and SVG — an SVG is an image and can carry <script>; a valid image can hide a payload (image + HTML/JS polyglot) that triggers stored XSS when served inline.
  • Path traversal in the filename../../var/www/html/shell.php to escape the intended directory (see the path traversal guide).

The fix: validate the bytes, but win on storage and serving

Validation reduces junk; storage and serving are what actually stop code execution. Do all three layers.

const crypto = require('crypto');
const path = require('path');

// Magic-byte signatures — check the real content, not the extension.
const SIGNATURES = {
'image/jpeg': [[0xff, 0xd8, 0xff]],
'image/png':  [[0x89, 0x50, 0x4e, 0x47]],
'application/pdf': [[0x25, 0x50, 0x44, 0x46]],
};

function detectType(buffer) {
for (const [type, sigs] of Object.entries(SIGNATURES)) {
  if (sigs.some((sig) => sig.every((b, i) => buffer[i] === b))) return type;
}
return null;
}

function validateUpload(buffer, declaredType, maxBytes = 5 * 1024 * 1024) {
if (buffer.length > maxBytes) throw new Error('File too large');

const real = detectType(buffer);
// Allowlist of permitted types; the header must MATCH the real bytes.
const ALLOWED = ['image/jpeg', 'image/png', 'application/pdf'];
if (!real || !ALLOWED.includes(real) || real !== declaredType) {
  throw new Error('File type not allowed');
}
// Server-generated random name + extension derived from REAL type.
const ext = { 'image/jpeg': '.jpg', 'image/png': '.png',
              'application/pdf': '.pdf' }[real];
return crypto.randomUUID() + ext;   // never use the client's filename
}

The principles

  • Never trust the extension or Content-Type. Detect the real type from magic bytes and require it to match an allowlist — but treat this as the first layer, not the last.
  • Make uploads non-executable. Store outside the web root or in object storage, with random server-generated names. This is the decisive control.
  • Serve safely, off-origin. Deliver through a handler with a fixed safe type, Content-Disposition: attachment, and nosniff, ideally from a separate cookie-less domain so nothing runs in your site's context.
  • Bound size and scan. Enforce size limits and, for higher-risk apps, run uploads through antivirus/CDR before they're accessible.

The one line: assume every upload is hostile, and design so that even a malicious file can never be executed, interpreted, or served in your origin's context.

Try it in the insecure file upload simulation: sneak a web shell past extension and Content-Type checks, get code execution, then apply byte validation plus non-executable storage and watch it become an inert file.

Share this post

Frequently Asked Questions

Related posts

Race Conditions in Web Apps: When Two Requests Beat Your Check

Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.

Aug 11, 2026 · 7 min read

Mass Assignment: How role=admin Ends Up in Your Update

Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.

Aug 10, 2026 · 6 min read

Insecure Deserialization: How Loading Data Becomes Running Code

Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.

Aug 9, 2026 · 7 min read