0x55aa
← Back to Blog

🗂️ Insecure File Uploads: Your Upload Feature Is a Backdoor to Your Server

6 min read

🗂️ Insecure File Uploads: Your Upload Feature Is a Backdoor to Your Server

You built a profile picture uploader. Took you two hours. Felt good. Shipped it.

Three days later, a hacker has a shell on your production server, casually browsing your database like it's their home directory.

Welcome to insecure file uploads — the vulnerability hiding in literally every app that lets users submit files.

Why File Uploads Are Terrifying 😱

When you let users upload files, you're essentially saying: "Please hand me some bytes and I'll store them on my server." Sounds innocent. It's not.

What can go wrong:

  • Attacker uploads a PHP/Python/Node.js script disguised as an image
  • File gets stored in a web-accessible directory
  • Attacker visits the URL of their uploaded file
  • Your server executes it — now they have a webshell
  • Game over. They have a shell on your server

This isn't theoretical. It's one of the OWASP Top 10 and shows up in real bug bounty reports constantly. I've seen .php files uploaded as "profile photos" more times than I want to admit.

The Classic Webshell Attack 💀

Here's the simplest attack imaginable. Attacker creates a file called shell.php:

<?php system($_GET['cmd']); ?>

Renames it to shell.php.jpg. Uploads it to your site. Visits:

https://yourapp.com/uploads/shell.php.jpg?cmd=whoami

If your server is Apache/Nginx with PHP and the file got stored in a public directory — it executes. The attacker sees www-data (or worse, root). Your night is about to get very long.

The kicker? The filename still says .jpg. Most naive checks just look at file extension. shell.php.jpg passes. shell.png.php might not, but there are dozens of tricks attackers use to bypass extension filters.

Why "Just Check the Extension" Fails 🚫

Here's what developers usually do first:

// Node.js/Express - DON'T DO THIS
const allowedExtensions = ['.jpg', '.png', '.gif'];
const ext = path.extname(file.originalname).toLowerCase();

if (!allowedExtensions.includes(ext)) {
  return res.status(400).json({ error: 'Invalid file type' });
}

// Store the file... in a publicly accessible directory. 🤦

Bypasses attackers try:

  • shell.php.jpg — extension is .jpg, content is PHP
  • shell.php%00.jpg — null byte injection (older systems)
  • shell.pHp — case variation (on case-insensitive systems)
  • shell.php7 — alternative PHP extension
  • shell.phtml — another PHP extension Apache may execute
  • Changing the Content-Type header to image/jpeg while uploading actual PHP

Extension-only checks are security theater. You need defense in depth.

The Right Way to Handle File Uploads ✅

Here's a multi-layered approach that actually works:

const multer = require('multer');
const sharp = require('sharp'); // Image processing library
const { v4: uuidv4 } = require('uuid');
const path = require('path');

// 1. Store files OUTSIDE the web root
const storage = multer.memoryStorage(); // Keep in memory, process before saving

const upload = multer({
  storage,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB limit
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    // 2. Allowlist MIME types (not denylist)
    const allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
    if (!allowedMimes.includes(file.mimetype)) {
      return cb(new Error('Only JPEG, PNG, and WebP images are allowed'));
    }
    cb(null, true);
  },
});

// 3. Re-process the image with Sharp (strips metadata, validates it's a real image)
async function processAndSaveUpload(buffer, originalName) {
  // This will THROW if the buffer isn't a valid image — no disguised PHP files survive this
  const processedImage = await sharp(buffer)
    .resize(800, 800, { fit: 'inside', withoutEnlargement: true })
    .jpeg({ quality: 85 }) // Always output as JPEG regardless of input
    .toBuffer();

  // 4. Generate a random filename — never trust the original name
  const safeFilename = `${uuidv4()}.jpg`;

  // 5. Save OUTSIDE web root (not in /public)
  const savePath = path.join('/var/app/private-uploads', safeFilename);
  await fs.promises.writeFile(savePath, processedImage);

  return safeFilename;
}

The secret weapon here is Sharp. When you run an uploaded file through Sharp and output it as a new JPEG, you're essentially re-rendering the image from scratch. A PHP file masquerading as an image can't survive that — it'll fail to decode, and you throw an error before anything bad happens.

This technique is called image transcoding and it's the most reliable file upload defense I know. Even if the MIME type check somehow passes, Sharp will choke on fake images.

The Storage Location Problem 📂

Even if you validate the file correctly, storing it in the wrong place is fatal:

❌ WRONG: /public/uploads/profile-pics/avatar.jpg
           ↑ Web server can serve this directly and may execute PHP

✅ RIGHT:  /var/app/private-uploads/abc123.jpg
           ↑ Not accessible via HTTP — must go through your application

When files are outside the web root, your app serves them like this:

// Serve files from private storage through your application
app.get('/avatars/:filename', authenticateUser, async (req, res) => {
  const { filename } = req.params;

  // Validate filename to prevent path traversal
  if (!/^[a-f0-9-]+\.jpg$/.test(filename)) {
    return res.status(400).send('Invalid filename');
  }

  const filePath = path.join('/var/app/private-uploads', filename);
  res.sendFile(filePath);
});

Even if an attacker somehow uploads a bad file, they can't access it directly — they'd have to go through your endpoint, which validates the filename. Much harder to exploit.

Cloud Storage = Extra Safety Net ☁️

The best setup for file uploads in production? Don't store files on your server at all:

  1. Accept upload in your app
  2. Validate + transcode (Sharp for images)
  3. Upload to S3/GCS/Azure Blob with a random key
  4. Never expose the original filename
  5. Generate signed URLs when users need to access files

This means even if an attacker somehow slips through all your validation (unlikely but possible), the file is sitting in S3, not on your server. S3 will serve it as a static file — it won't execute PHP. Webshell attack neutralized.

Quick Security Checklist 📋

Before you ship that file upload feature:

  • Allowlist specific MIME types (not denylist bad ones)
  • Validate file content, not just extension or MIME header
  • Re-process images through a transcoding library (Sharp, Pillow, ImageMagick)
  • Generate random filenames — never use the original
  • Store outside web root or use cloud storage
  • Set file size limits (prevent disk exhaustion)
  • Scan with antivirus for non-image file types (ClamAV works great)
  • Set Content-Disposition: attachment when serving downloads to prevent execution

The Bottom Line 🎯

File uploads are one of those features that look trivial but have huge security implications. The attack surface is wide, the bypasses are creative, and the consequences (remote code execution) are catastrophic.

But the defenses are also well-established:

  • Content validation > extension checking
  • Transcode images to strip evil content
  • Keep files off your web server — use cloud storage
  • Never trust the original filename

Treat every uploaded file like it came from your worst enemy. Because sometimes, it did.


Found this useful? Connect with me on LinkedIn — I talk about security, backend dev, and things I've broken in production!

More security deep-dives on my GitHub. Build safe stuff out there! 🔒