# File upload vulnerabilities: how uploads go wrong, how testers prove it, how to secure them

Source: https://penetrationtestservices.com/vulnerabilities/file-upload-vulnerabilities/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

File upload vulnerabilities explained: bypass classes, impact beyond web shells, safe proof by testers, and secure upload code for Node.js, Python, Java, PHP and Go.

> **Short answer:** 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](/web-application-penetration-testing/) and [API penetration testing](/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](/vulnerabilities/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](/vulnerabilities/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](/vulnerabilities/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.

1. Check authorization before accepting the upload.
2. Allow only the extensions the feature needs (allow-list).
3. Set a maximum size and, for archives, a maximum number of entries.
4. Check the file content against the claimed type on the server.
5. Generate a random name. Never use the client's name for the stored path.
6. Store outside the web root, or in object storage on a separate domain.
7. Make sure the storage location cannot execute files.
8. Scan with antivirus where the files are shared with other users.
9. Serve with a fixed Content-Type, `X-Content-Type-Options: nosniff`, and `Content-Disposition: attachment` for 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)

```js
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)

```python
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)

```java
@PostMapping("/avatar")
public Map 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)

```php
$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

```go
func avatar(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 5 **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

1. List every upload point: forms, APIs, import features, avatar, attachments, and any `PUT` method.
2. Confirm scope and written authorization, and agree on file sizes for tests.
3. Upload a valid file and note the stored path, name, headers and who can read it.
4. Change the extension, the Content-Type and the content independently.
5. Check whether the file name controls the stored path.
6. Check whether the upload location executes files.
7. Open HTML and SVG files in a real browser to see whether script runs on your origin.
8. Test size, count and archive limits with small, agreed values.
9. Read the file back from a second account.
10. 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](/web-application-penetration-testing/) and [API penetration testing](/api-penetration-testing/), under a written scope.

Related guides: [path traversal](/vulnerabilities/path-traversal/) for file name attacks, and [server-side request forgery](/vulnerabilities/ssrf/) 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](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html)
- [OWASP Unrestricted File Upload](https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload)
- [OWASP WSTG-BUSL-09: Test Upload of Malicious Files](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/10-Business_Logic_Testing/09-Test_Upload_of_Malicious_Files)
- [PortSwigger Web Security Academy: File upload vulnerabilities](https://portswigger.net/web-security/file-upload)
- [CWE-434: Unrestricted Upload of File with Dangerous Type](https://cwe.mitre.org/data/definitions/434.html)
- [MDN: X-Content-Type-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options)
- [PHP manual: move_uploaded_file](https://www.php.net/manual/en/function.move-uploaded-file.php)
- [Flask configuration: MAX_CONTENT_LENGTH](https://flask.palletsprojects.com/en/stable/config/)
- [NVD: CVE-2016-3714 (ImageTragick)](https://nvd.nist.gov/vuln/detail/CVE-2016-3714)
- [NVD: CVE-2021-22205 (GitLab)](https://nvd.nist.gov/vuln/detail/CVE-2021-22205)
