File upload vulnerabilities: how uploads go wrong, how testers prove it, how to secure them
A file upload vulnerability lets an attacker store a file your application should have rejected, which can lead to code execution, stored XSS, file overwrite, denial of service or storage abuse. Secure uploads with an extension allow-list, content checks, random names, storage outside the web root, size limits and no execution. Testers prove impact with a benign marker file.
A file upload vulnerability is a flaw that lets an attacker store a file your application should have rejected. CWE-434 describes it as a product that "allows the upload or transfer of dangerous file types that are automatically processed within its environment." The result ranges from a defaced page to code running on your server, depending on what the app does with the file next.
Upload handling is part of web application penetration testing and API penetration testing. Testing needs written authorization from the system owner.
What is a file upload vulnerability, and what can it cause?#
A file upload vulnerability exists when an application accepts a file and then stores, serves or processes it in a way the attacker can abuse. The same weakness is called unrestricted file upload, arbitrary file upload or insecure file upload. Five outcomes matter:
| Outcome | How it happens | Example |
|---|---|---|
| Remote code execution | A script file lands in a folder the server executes | A server-side script saved under the web root |
| Stored XSS | The app serves an HTML or SVG file from its own origin | An SVG avatar that runs script when opened |
| File overwrite | The attacker controls the saved path or name | ../ in the file name replaces a config file |
| Denial of service | No size, count or decompression limit | A very large file, or a zip that expands to gigabytes |
| Storage abuse | Free hosting of any content | Your domain serves malware or phishing pages |
PortSwigger's Web Security Academy describes the worst case as a web shell, a script that lets an attacker run commands through HTTP requests. The attacker needs two things: a way to upload the file, and a way to make the server run it.
How do uploads go wrong?#
Uploads go wrong when the application trusts something the client controls: the file name, the Content-Type header, the file's apparent type, or the upload folder's behavior. Each check below fails in a different way.
Vulnerable avatar upload (fictional app)
This is a demonstration. PicBoard is a fictional app on app.example.com, and no real customer system was tested.
The app accepts an avatar. It checks only the Content-Type header and saves the file under the web root with the original name.
<?php
// Vulnerable: trusts the client's header and file name
if ($_FILES['avatar']['type'] === 'image/png') {
move_uploaded_file($_FILES['avatar']['tmp_name'],
'/var/www/html/uploads/' . $_FILES['avatar']['name']);
}
A tester with authorization uploads a text file named probe.php that contains one line of PHP printing a unique string. The tester sets the Content-Type part of the multipart request to image/png. The app accepts it. A request for https://app.example.com/uploads/probe.php returns the unique string.
The string in the response proves that the server executed the file. A "200 OK" on the upload request would prove only that the file was stored.
Which bypass classes should you expect?#
Attackers beat weak validation by changing the part of the upload that the check does not look at. This matrix lists the classes with the defense each one defeats. It leaves out payload strings, which the PortSwigger labs document.
| Bypass class | What the attacker changes | Weak defense it beats | Strong defense |
|---|---|---|---|
| Content-Type spoofing | The header in the multipart part | Header check | Ignore the header for security |
| Extension tricks | Case, double extensions, alternate executable extensions, trailing characters | Extension blocklist | Extension allow-list, plus renaming |
| Magic bytes and polyglots | A valid image header with other content after it | Check of the first bytes | Re-encode the image, or validate the whole file |
| Server config override | A file that changes how the server treats a folder | Blocklist alone | No uploads in executable folders, config overrides off |
| Path in the file name | ../ in filename |
Saving under the supplied name | Random server-side names. See path traversal |
| SVG and HTML | Script inside a file that browsers render | Image-type check | Serve from another domain, or as a download |
| Parser bugs | A crafted file that a library mishandles | Trusting the library | Update the library, sandbox the processing |
| Race condition | A request that arrives between save and validation | Validate after save | Validate before the file is reachable |
| Archive bombs | A small zip that expands hugely | No limits | Limits on size, entries and ratio |
OWASP's cheat sheet states the rule behind the first row: the Content-Type of an uploaded file "is provided by the user, and as such cannot be trusted, as it is trivial to spoof."
What can go wrong without remote code execution?#
Uploads cause serious harm without any code execution on the server. The brief list:
- Stored XSS through SVG or HTML files served from your origin. See cross-site scripting.
- XML external entity attacks if the app parses uploaded XML, Office files or SVG on the server.
- Server-side request forgery if a converter fetches URLs found inside the file. See SSRF.
- Parser bugs in image, PDF or document libraries. The two CVEs below are of this type.
- Path traversal through the file name or through archive entries.
- Disk, memory or CPU exhaustion from huge files or archive bombs.
How do you secure file uploads?#
Secure uploads in layers. No single check is enough. The OWASP File Upload Cheat Sheet lists the controls, and the sequence below puts them in order.
- Check authorization before accepting the upload.
- Allow only the extensions the feature needs (allow-list).
- Set a maximum size and, for archives, a maximum number of entries.
- Check the file content against the claimed type on the server.
- Generate a random name. Never use the client's name for the stored path.
- Store outside the web root, or in object storage on a separate domain.
- Make sure the storage location cannot execute files.
- Scan with antivirus where the files are shared with other users.
- Serve with a fixed Content-Type,
X-Content-Type-Options: nosniff, andContent-Disposition: attachmentfor downloads.
MDN explains that nosniff tells the browser to follow the declared Content-Type and not guess. OWASP gives two options for storage: keep files outside the web root where only administrative access is allowed, or keep them inside it with write-only permissions and strict read controls.
Node.js (multer and file-type)#
const multer = require("multer");
const crypto = require("node:crypto");
const fs = require("node:fs/promises");
const path = require("node:path");
const upload = multer({ dest: "/srv/uploads/tmp", limits: { fileSize: 5 * 1024 * 1024, files: 1 } });
const ALLOWED = { png: "image/png", jpg: "image/jpeg" };
app.post("/avatar", requireLogin, upload.single("avatar"), async (req, res) => {
const { fileTypeFromFile } = await import("file-type");
const detected = await fileTypeFromFile(req.file.path);
if (!detected || !ALLOWED[detected.ext]) {
await fs.unlink(req.file.path);
return res.status(400).send("Unsupported file");
}
const name = crypto.randomUUID() + "." + detected.ext;
await fs.rename(req.file.path, path.join("/srv/uploads/final", name));
res.json({ id: name });
});
The code uses the detected type for the extension, so the client's name and header are never used.
Python (Flask and python-magic)#
import uuid, magic
from pathlib import Path
ALLOWED = {"image/png": "png", "image/jpeg": "jpg"}
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
@app.post("/avatar")
@login_required
def avatar():
f = request.files["avatar"]
head = f.stream.read(2048)
f.stream.seek(0)
ext = ALLOWED.get(magic.from_buffer(head, mime=True))
if not ext:
abort(400)
name = f"{uuid.uuid4()}.{ext}"
f.save(Path("/srv/uploads/final") / name)
return {"id": name}
Flask raises a 413 error when a request body exceeds MAX_CONTENT_LENGTH.
Java (Spring)#
@PostMapping("/avatar")
public Map<String, String> avatar(@RequestParam("avatar") MultipartFile file) throws IOException {
if (file.getSize() > 5L * 1024 * 1024) throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE);
byte[] head = file.getInputStream().readNBytes(16);
String ext = sniff(head); // returns "png" or "jpg" from the magic bytes, or null
if (ext == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
String name = UUID.randomUUID() + "." + ext;
Files.copy(file.getInputStream(), Paths.get("/srv/uploads/final").resolve(name));
return Map.of("id", name);
}
A library such as Apache Tika can replace the sniff helper. Keep the allow-list on your side.
PHP (finfo)#
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['avatar']['tmp_name']);
if (!isset($allowed[$mime]) || $_FILES['avatar']['size'] > 5 * 1024 * 1024) {
http_response_code(400);
exit;
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
move_uploaded_file($_FILES['avatar']['tmp_name'], '/srv/uploads/final/' . $name);
The PHP manual says move_uploaded_file() checks that the file is a valid upload, meaning PHP received it through its HTTP POST upload mechanism.
Go#
func avatar(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
file, _, err := r.FormFile("avatar")
if err != nil { http.Error(w, "bad upload", 400); return }
defer file.Close()
head := make([]byte, 512)
n, _ := file.Read(head)
ext := map[string]string{"image/png": "png", "image/jpeg": "jpg"}[http.DetectContentType(head[:n])]
if ext == "" { http.Error(w, "unsupported", 400); return }
file.Seek(0, io.SeekStart)
id := uuid.NewString() + "." + ext
out, _ := os.Create(filepath.Join("/srv/uploads/final", id))
defer out.Close()
io.Copy(out, file)
}
For images, re-encode the pixels with a maintained image library and discard the original bytes. That removes appended data and most polyglot tricks. Re-encoding does not protect against bugs in the library, so keep it patched.
Which storage and cloud controls matter?#
Storage decides how much damage a missed bug can do. The strongest design keeps uploaded files away from your application server and your main origin.
| Control | Reason |
|---|---|
| Object storage on a separate domain | A script file or SVG runs in a different origin, so it cannot read your cookies |
| No execute permission on the upload location | A stored script file cannot run |
| Random names and no directory listing | The attacker cannot guess or enumerate paths |
Download headers (Content-Disposition, nosniff) |
The browser does not render or sniff the file |
| Quarantine and scan before release | Other users never open an unscanned file |
| Processing in a sandbox with limits | A parser bug has little to reach |
| Upload size and rate limits | Limits protect disk and CPU |
How do penetration testers detect and prove upload flaws?#
Testers prove an upload flaw by showing what the server did with the file, using a benign marker. Storage alone is not impact. Execution, script running in a browser, or an overwritten record is impact.
Our method follows OWASP WSTG-BUSL-09 and the Excession Labs evidence standard. Upload tests are bounded, the cost is stated in the report, and we use test-owned accounts. We never upload real malware or a persistent web shell.
| Test | Benign proof | Label |
|---|---|---|
| Does the server execute an upload? | A script that prints one unique line, then removed | Observed |
| Is the file served from the app origin? | An HTML file that shows a unique string, opened in a real browser | Browser-observed |
| Is the type check only on the header? | The same marker file with a changed header | Observed |
| Does the file name control the path? | A marker saved under a name with a parent folder element, then read back | Observed |
| Are there size and count limits? | Files up to a small agreed size above the limit | Observed |
| Does another user see the file? | Read-back from a second test account | Observed |
Controls: upload a valid file as a baseline, upload a rejected type and record the error, and repeat the uploads as another account to check access control. Record the stored URL, the response headers, and the time of each request. Delete test files after the test and list them in the report.
Sample finding (fictional)
This is a demonstration. No real customer system was tested.
Finding: unrestricted file upload in POST /avatar on app.example.com. Severity: Critical. A text file named probe.php with a Content-Type of image/png was accepted and saved under /uploads/. A request to that URL returned the unique string PB-PROBE-2219, so the server executed the file. A control upload of probe.txt returned the text unexecuted. Fix: extension allow-list, content check, random names, storage outside the web root, and no execution in the upload folder.
Which real vulnerabilities involved crafted uploads?#
Both of the following CVEs involve a crafted image file reaching a parser, and NVD records each one.
| Date | CVE | What NVD says |
|---|---|---|
| 5 May 2016 | CVE-2016-3714, ImageMagick ("ImageTragick") | Several coders in ImageMagick before 6.9.3-10 and 7.x before 7.0.1-1 allow remote attackers to execute arbitrary code through shell metacharacters in a crafted image |
| 23 April 2021 | CVE-2021-22205, GitLab CE/EE | GitLab versions from 11.9 did not properly validate image files passed to a file parser, which resulted in remote command execution |
Both show that a valid-looking image can attack the library behind the upload. Update image and document libraries, and process uploads with the least privilege you can.
File upload testing checklist#
- List every upload point: forms, APIs, import features, avatar, attachments, and any
PUTmethod. - Confirm scope and written authorization, and agree on file sizes for tests.
- Upload a valid file and note the stored path, name, headers and who can read it.
- Change the extension, the Content-Type and the content independently.
- Check whether the file name controls the stored path.
- Check whether the upload location executes files.
- Open HTML and SVG files in a real browser to see whether script runs on your origin.
- Test size, count and archive limits with small, agreed values.
- Read the file back from a second account.
- Remove test files, list them, and retest after the fix.
When should you get a professional test?#
Get a professional test when your product accepts files from customers, processes them with converters or image libraries, or lets users share files with each other. A scanner can check for a few known cases. It cannot tell what your pipeline does with a file after storage. Excession Labs tests uploads as part of web application penetration testing and API penetration testing, under a written scope.
Related guides: path traversal for file name attacks, and server-side request forgery for converters that fetch URLs.
Questions
Is checking the Content-Type header enough to secure an upload?
No. The client sets the Content-Type header, so an attacker can send any value. OWASP says the header cannot be trusted. Use it as a quick check at most, and validate the file extension against an allow-list and the actual file content on the server.
Are extension blocklists safe?
No. A blocklist misses alternate extensions, case changes and server-specific executable types. Use an allow-list of the few extensions your feature needs, and store files so that the server never runs them.
Can an image upload be dangerous?
Yes. An SVG file can carry script, an image can hide a second format, and image libraries have had code execution bugs. CVE-2016-3714 in ImageMagick and CVE-2021-22205 in GitLab both involved crafted image files.
How do penetration testers test uploads without harming the system?
They upload a benign marker file, such as a text file with a unique string or a script that prints one line, to a test-owned account. They check where it is stored and whether it runs. They avoid real web shells, and they record what they uploaded and remove it afterward.
Where should uploaded files be stored?
Store them outside the web root or in object storage on a separate domain, with random names and no execute permission. Serve them with a fixed Content-Type, a Content-Disposition header for downloads, and X-Content-Type-Options: nosniff.
Sources
- OWASP File Upload Cheat Sheet (checked 9 November 2026)
- OWASP Unrestricted File Upload (checked 9 November 2026)
- OWASP WSTG-BUSL-09: Test Upload of Malicious Files (checked 9 November 2026)
- PortSwigger Web Security Academy: File upload vulnerabilities (checked 9 November 2026)
- CWE-434: Unrestricted Upload of File with Dangerous Type (checked 9 November 2026)
- MDN: X-Content-Type-Options (checked 9 November 2026)
- PHP manual: move_uploaded_file (checked 9 November 2026)
- Flask configuration: MAX_CONTENT_LENGTH (checked 9 November 2026)
- NVD: CVE-2016-3714 (ImageTragick) (checked 9 November 2026)
- NVD: CVE-2021-22205 (GitLab) (checked 9 November 2026)