What Are File Upload Vulnerabilities? Unrestricted File Upload Attacks Explained
A complete technical guide to file upload vulnerabilities: how unrestricted uploads lead to RCE, malware distribution, and data theft, and how to detect, verify, and prevent them.
Short answer
A file upload vulnerability occurs when an application accepts a file from the user and does not adequately validate its contents, type, size, or destination before storing or processing it. The result: an attacker can upload a malicious file that the server then executes, serves to other users, or processes in a way that compromises security.
The idea in one minute
Imagine a hotel's package receiving desk. Guests can drop off packages, and staff place them in a back room for pickup. The clerk checks the package's label but never opens it. One day, someone drops off a box labeled "cookies" that is actually filled with lockpicks and a uniform. The clerk places it in the back room. The visitor retrieves it, puts on the uniform, and walks into the hotel's restricted areas unchallenged.
That is unrestricted file upload. The application acts as the clerk — it looks at the label (filename extension, declared MIME type) and decides the file is safe without examining its actual contents. The "cookies" label is the Content-Type: image/png header; the lockpicks are PHP code embedded in what appears to be a JPEG. Once the file is on the server, the attacker has a foothold: a webshell under uploads/shell.php that accepts commands, a polyglot image that exfiltrates data on load, or a ZIP bomb that exhausts disk space during extraction.
How unrestricted file upload actually works
File upload endpoints are everywhere: profile pictures, document attachments, CSV imports, ticket attachments, CMS media libraries. Every one of them reads bytes from a multipart POST request and writes them somewhere — a filesystem directory, an object store, or a database BLOB. The vulnerability appears when the application trusts metadata over contents.
Three upload archetypes cover most attacks:
| Archetype | What the server does | How it's exploited |
|---|---|---|
| Execute-on-upload | Writes the file inside the web root to a directory where the server executes scripts | Upload shell.php → GET /uploads/shell.php?cmd=id → remote code execution |
| Serve-to-users | Stores the file and serves it to other visitors with the original content type | Upload a JavaScript polyglot inside a GIF → stored XSS against every visitor who views the uploaded image |
| Process-on-upload | Opens the file server-side to extract metadata, generate thumbnails, or parse records | Craft a malicious SVG with XXE, a JPEG with a crafted EXIF payload for a buffer overflow in ImageMagick, or a ZIP file with path traversal entries (zip slip) |
Content-type bypass is the simplest case. The server reads request.files['avatar'] and checks file.content_type == 'image/png'. An attacker sends Content-Type: image/png but the body is <?php system($_GET['cmd']); ?>. The server passes the check and writes the file — the check validated the envelope, not the letter.
Double extension exploits filename parsing in web servers. Apache with AddHandler php5-script .php will execute shell.php.jpg as PHP if the configuration is loose (the handler matches on .php in the filename). IIS used to be vulnerable to shell.asp;.jpg — the semicolon truncates the filename for the handler but not for storage. Nginx misconfigurations can pass files to FastCGI based on the first extension in a double-extension filename.
Polyglot files are valid in two formats simultaneously. A GIFAR is both a valid GIF and a valid RAR archive. A PHP-JPEG polyglot passes imagecreatefromjpeg() (which reads it as a JPEG without error) and, when served with Content-Type: image/jpeg, works in browsers — but when accessed directly or included, the embedded PHP payload executes because the interpreter ignores the binary garbage before <?php.
Zip slip occurs when an application extracts user-uploaded archives. A crafted ZIP file contains entries with relative paths like ../../etc/cron.d/malicious. The extraction loop writes each entry to a destination directory using the entry's name. Without path normalization, the ../ sequences escape the destination directory and overwrite arbitrary files. A common target is a cron job or SSH authorized_keys file.
Filesize bombs — small files that expand enormously during processing. A "decompression bomb" ZIP compresses 10 GB of zeros into a few kilobytes. An image resize bomb crafts a tiny BMP with headers declaring 65535x65535 pixels — the server allocates gigabytes of memory attempting to resize it, triggering an OOM kill or a denial-of-service.
The request flow
Attacker ──prepares payload──▶ shell.php
│ (byte content = <?php system($_GET['cmd']); ?>)
▼
POST /upload/profile-picture
Content-Type: multipart/form-data
┌─────────────────────────┐
│ Content-Disposition: ... │
│ filename="avatar.php" │
│ Content-Type: image/png │
│ │
│ <?php system(...); ?> │
└─────────────────────────┘
│
▼
Server checks: extension = .php → reject
or: Content-Type = image/png → accept?
If only Content-Type checked (bypass):
│
▼
Writes to /var/www/uploads/avatar.php
│
▼
Attacker ──GET──▶ /uploads/avatar.php?cmd=id
│
▼
Server executes avatar.php → uid=33(www-data)
Command output returned in HTTP response
As a sequence:
sequenceDiagram
participant Attacker
participant App as Web Application
participant FS as Filesystem
participant Web as Web Server
Attacker->>App: POST /upload (multipart, shell.php disguised as PNG)
App->>App: Checks Content-Type (image/png) — passes
App->>FS: Writes avatar.php to document root
App-->>Attacker: 200 OK, "Upload successful"
Attacker->>Web: GET /uploads/avatar.php?cmd=id
Web->>FS: Reads avatar.php
FS-->>Web: Returns PHP content
Web->>App: Passes to PHP interpreter
App->>App: Executes system("id")
App-->>Attacker: "uid=33(www-data)"
A minimal example
A Flask profile-picture upload that validates only the declared MIME type:
from flask import Flask, request
import os
app = Flask(__name__)
UPLOAD_DIR = "/var/www/uploads"
@app.route("/upload", methods=["POST"])
def upload():
file = request.files.get("avatar")
if not file:
return "No file", 400
# Only check the browser-declared content type — trivially spoofed
if file.content_type not in {"image/png", "image/jpeg", "image/gif"}:
return "Invalid type", 400
ext = file.filename.rsplit(".", 1)[-1].lower()
path = os.path.join(UPLOAD_DIR, f"{hash(file.filename)}.{ext}")
file.save(path)
return "OK"
Sending curl -F "avatar=@shell.php;type=image/png" http://target/upload saves the file with a .php extension. The server writes it, the web server executes it.
A server-side check that opens the file and validates its structure is harder to bypass:
from PIL import Image
import io
@app.route("/upload", methods=["POST"])
def upload_safe():
file = request.files.get("avatar")
if not file:
return "No file", 400
# Verify the file is actually an image by attempting to open it
try:
image = Image.open(io.BytesIO(file.read()))
image.verify()
except Exception:
return "Invalid image", 400
# Reset stream position after read
file.seek(0)
# Reject scripts by not trusting any extension — generate your own
safe_name = f"{uuid4().hex}.webp"
path = os.path.join(UPLOAD_DIR, safe_name)
file.save(path)
return "OK"
This is not bulletproof — ImageMagick vulnerabilities (ImageTragick) have shown that even image-processing libraries can be exploited by crafted files. But it closes the trivial drive-by attacks. The key is never to trust the filename or extension supplied by the client; generate your own name and extension, serve the file with a Content-Disposition: attachment header, and store uploads outside the web root when execution is the concern.
Why the vulnerability exists
- Applications validate the envelope (Content-Type, extension) instead of the contents — a MIME type is a string the client sends, not a property of the bytes
- Filename extensions are treated as authoritative indicators of file type; the underlying OS file-magic signature is never checked
- Upload directories are placed inside the web document root for convenience, making uploaded scripts directly accessible to the web server
- File processing libraries themselves contain vulnerabilities — a "safe" image reprocessor can be the attack vector rather than the mitigation (ImageMagick, libvips, Pillow have all had CVEs for crafted input)
- ZIP extraction loops write entries blindly without normalizing paths against the extraction directory, enabling zip slip
- Temporary files written to shared hosting environments may be readable by other tenants before cleanup
- Application logic needs to preserve the original filename for user-facing display, so sanitization is skipped or applied inconsistently
What attackers look for
- Any upload endpoint: profile pictures, ticket attachments, document uploads, CSV/Excel imports, signature uploads, email attachments, CMS media libraries, theme installers, plugin uploads
- The application's response after upload — if it returns a URL to the uploaded file, and the directory is publicly accessible, the attack surface is even larger
- Endpoints that accept ZIP, TAR, or other archive formats for batch processing
- Image resizing endpoints — these process the uploaded file and write the result somewhere; a polyglot that survives resizing can embed a payload that's never stripped
- SVG uploads — SVGs are XML and can contain
<script>,<foreignObject>, XXE payloads, and<a xlink:href>for open redirect — even in otherwise safe applications - Filename patterns in the response headers or page:
Content-Disposition: inline; filename="user_12345.png"reveals the naming convention
Detection
- Static/code review: locate file upload handlers and trace the validation path. Does the check look at
Content-Type(client-controlled) or the actual file bytes? Isos.path.joinused with user-supplied filenames? Are uploaded files written inside the web root? Are thereunzip,tar, orextractcalls without path traversal guards? - Dynamic/DAST: upload a file with a
.phpor.jspextension andContent-Type: text/plain, then request the returned URL. If the server executes the script (or attempts to), you have an RCE path. Upload a valid image file that also contains<?php phpinfo(); ?>at the end and confirm the payload survives reprocessing. Upload a ZIP file containing../../etc/passwdand check whether the system's passwd file is modified. - Content-type bypass testing: submit the same payload with every combination of extension + declared MIME type that the application claims to accept. Many applications have a MIME-type allowlist that blocks
.phpbut acceptimage/pngwith any content — and they derive the saved extension from the declared type rather than the filename, leaving it as.pngbut still processing the content in a dangerous way. - Zip slip testing: create a ZIP with
python3 -c "import zipfile; z=zipfile.ZipFile('slip.zip','w'); z.writestr('../../../tmp/pwned', 'test'); z.close()"and upload it. Check whether the file appears in/tmp/pwned.
Verification: real vulnerability or false positive?
A finding is confirmed when:
- The uploaded file is accessible at a URL the server returns, and the server processes it as an executable script (the response contains the output of a command embedded in the file —
uid=33(www-data)— not the source code) - The uploaded file is served to other users with its original content type and contains executable JavaScript or HTML that renders in a browser — stored XSS confirmed by a
prompt(1)or a callback to an external domain - A path traversal payload in an archive file creates or overwrites a file outside the intended extraction directory, confirmed by reading the target file's contents after extraction
- The server enters an out-of-memory state or becomes unresponsive after uploading a small file with inflated dimensions or compression ratio — confirmed by observing the resource exhaustion (ideally on a test instance, not production)
A file that is uploaded and stored but never served, never executed, and never processed is not exploitable — but it signals that the application lacks defense in depth. The presence of a single check that looks at Content-Type instead of content bytes is a vulnerability even if other controls prevent its direct exploitation today.
Real-world impact
- Remote code execution: the most severe outcome. An attacker uploads a webshell —
shell.php,cmd.asp,shell.jsp— and gains command execution on the server. From there: lateral movement, data exfiltration, credential dumping, persistence. File upload RCE is the entry point for some of the most damaging breaches on record. - Stored cross-site scripting: an image that contains JavaScript in its EXIF metadata or in a GIF's comment extension is served to every user who views a gallery or a profile page. The XSS fires in the context of the application's origin, bypassing Content Security Policy when the image is hosted on the same domain. No user interaction beyond viewing a page.
- Malware distribution: the application's trusted domain serves as a malware CDN. Attackers upload malicious executables, Office macros, or PDF payloads. The download link appears to be from a legitimate site — email filters and reputation-based blocklists trust the domain. Users download "invoice.pdf.exe" from
trusted.com/uploads/. The application's brand absorbs the reputational damage. - Server-side denial of service: a single image resize bomb, ZIP bomb, or XML bomb (uploaded as an SVG) exhausts CPU, memory, or disk. The server becomes unresponsive. If auto-scaling is in place, the malicious file propagates the DoS to new instances as they spin up and process the same queue.
- Data corruption via zip slip: an attacker crafts a ZIP file with entries targeting
/var/www/html/config.php,/etc/cron.d/malicious, or/home/user/.ssh/authorized_keys. Extraction overwrites these files. An attacker does not need RCE when they can write to the crontab or SSH configuration. - Server-side request forgery (SSRF): file upload endpoints that process uploaded files (PDF parsers, image resizers, document converters) frequently make HTTP requests to fetch external resources embedded in the file — an SVG
<image xlink:href="http://internal-admin:8080/">or a PDF with an external annotation. This turns a file upload into an SSRF vector against internal services.
Prevention
-
Validate the file's content, not its label: read the file's magic bytes — the first few bytes that identify a real PNG (
\x89PNG), JPEG (\xFF\xD8\xFF), or GIF (GIF8). Python'simghdrorpython-magic(libmagic bindings) are more reliable thanContent-Type. For documents, use a library that parses the document structure (e.g.,PyMuPDFfor PDFs,openpyxlfor XLSX) and reject input that fails to parse. -
Never trust the client-provided filename: generate a server-side filename using a UUID or hash, with an extension that matches what you validated (or no extension at all). Preserving the original filename for display is a separate concern — store the original name in a database column, not in the filesystem path.
-
Store uploaded files outside the web root and serve them through a download handler that reads the file and streams it with
Content-Disposition: attachmentand an explicitContent-Type. This prevents the web server from executing uploaded scripts even if the file has an executable extension, and prevents the browser from rendering HTML/JavaScript inline. -
Use an allowlist for file extensions and MIME types at the application layer — and enforce it after content validation, not before.
{".png", ".jpg", ".gif"}is easy to maintain. Blocklists like{".php", ".asp", ".exe"}are bypassed byshell.php5,shell.phtml,shell.asp;.jpg,shell.cer, and every other variation. -
Re-process uploaded images by loading them into a safe library and re-encoding the output. This strips EXIF metadata, comment extensions, and any payload appended after the image trailer.
PIL.Image.open()thensave()as PNG or WebP is a reasonable sanitizer — but keep the library updated; ImageMagick'sconverthad 50+ CVEs in its history. -
Guard against zip slip: when extracting archives, validate each entry's canonical path against the extraction root using
os.path.realpathorpathlib.Path.resolve(). Reject entries whose resolved path does not start with the extraction directory. Python example:import zipfile, os SAFE_DIR = "/var/lib/uploads/extracted" def extract_safe(zip_path: str) -> None: with zipfile.ZipFile(zip_path, "r") as z: for entry in z.infolist(): target = os.path.realpath(os.path.join(SAFE_DIR, entry.filename)) if not target.startswith(os.path.realpath(SAFE_DIR)): raise ValueError(f"Zip slip detected: {entry.filename}") z.extract(entry, SAFE_DIR) -
Enforce filesize limits at multiple layers: set a
Content-Lengthlimit in the reverse proxy (nginxclient_max_body_size), a second limit in the application framework, and a third limit during file processing (e.g., reject images whose declared dimensions exceed 4096x4096 pixels before allocating memory). This defends against both network-level and processing-level bombs. -
Disable script execution on upload directories: serve uploads from a directory with a
.htaccess(RemoveHandler .php .phtml .php5) or nginx config (location /uploads { location ~ \.php$ { deny all; } }). This is defense-in-depth — not a replacement for proper validation.
Related vulnerabilities
- Path traversal — zip slip is a variant of path traversal, and many file upload RCE chains require a path traversal to place the uploaded file in an executable directory
- XXE — SVG uploads and XML-based document formats (Office Open XML, XMP metadata) are common XXE vectors when the XML parser is not hardened
- XSS — stored XSS via uploaded files (SVG, HTML files, polyglot images with JavaScript metadata) is the most common consequence of unrestricted uploads that serve files in-browser
- Command injection — webshells are command injection by another name; the command is injected through the HTTP request instead of a system call
- SSRF — document processors (PDF parsers, image resizers with external resource fetching, SVG loaders) convert an upload endpoint into an SSRF gateway
Testing methodology (do this safely)
- Test on applications you own or have explicit authorization to test. File upload vulnerabilities can modify or destroy server state (files written, data corrupted, services crashed) — never test them on production infrastructure without a rollback plan.
- For RCE testing, use a payload that produces a benign but distinct output:
echo NYXEARA_SECURITY_TESTin a shell, or<?php echo 'NYXEARA_SECURITY_TEST'; ?>in a PHP file. Confirm the output in the HTTP response. Do not usesystem("id"),system("cat /etc/passwd"), or destructive commands unless you are in a dedicated test environment. - For zip slip testing, use a payload that writes to a safe location like
/tmp/nyxeara_test_slip— not to system directories. Verify the file exists after extraction and then clean it up. - Document the exact multipart request body (include the boundary, headers, and file content), the endpoint URL, the server's response, and the outcome (file was executed, file was served, file triggered an error).
- Use a callback-capable testing domain or tool (Burp Collaborator, interactsh, or your own DNS/HTTP server) to confirm outbound connections from file processors — a JPEG that triggers an SSRF to your callback domain confirms the processor is fetching external resources.
Further reading
- PortSwigger Web Security Academy: File upload vulnerabilities
- OWASP: Unrestricted File Upload
- MITRE: CWE-434 — Unrestricted Upload of File with Dangerous Type
- Snyk: Zip Slip Vulnerability
- ImageTragick: Multiple CVEs in ImageMagick
Nyxeara perspective
File upload vulnerabilities are a gift to the attacker and a blind spot for defenders — they sit at the intersection of least-trusted input and most-trusted execution. A scanner that only sends Content-Type: image/png with <?php ... ?> and checks for 200 on the uploaded URL misses the majority of exploitable upload paths. The real signal is in the server's behavior after the file lands: does it resize the image without complaint (polyglot survived), does it serve the file with an executable Content-Type, does it extract the archive to a path outside the intended directory?
Effective automated testing against file uploads requires multi-stage probing: submit a payload, request it back to confirm storage and serving, submit a second payload that depends on the first (a PHP file that includes the first file), and verify execution. The callback from the second stage — an HTTP request to an attacker-controlled domain initiated by the uploaded file — is the only confirmation that counts. Everything else is metadata.