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 tricks —
shell.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/jpegon 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.phpto 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.
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, andnosniff, 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.
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 readMass 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 readInsecure 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