Back to Blog
critical SEVERITY11 min read

Critical File Upload Vulnerability Fixed: How Unrestricted Uploads Put Flask APIs at Risk

A critical security vulnerability was discovered and patched in a Flask REST API endpoint that accepted image file uploads without any validation, size limits, or sandboxing. An attacker could exploit this flaw to upload malicious files disguised as images, potentially leading to remote code execution or exploitation of image-parsing libraries. The fix enforces strict file type validation, magic byte inspection, and upload size limits to close this dangerous attack vector.

O
By Orbis AppSec
Published May 3, 2026Reviewed June 3, 2026

Answer Summary

Unrestricted file upload (CWE-434) in Flask allows attackers to upload malicious files disguised as legitimate images, potentially leading to remote code execution. This Flask REST API accepted file uploads without validating file content, checking magic bytes, or enforcing size limits. The fix implements strict file type validation using magic byte inspection with the `python-magic` library, enforces a whitelist of allowed extensions (jpg, jpeg, png, gif), and sets a maximum upload size limit to prevent both malicious file execution and denial-of-service attacks.

Vulnerability at a Glance

cweCWE-434
fixMagic byte inspection, extension whitelist, size limits
riskRemote code execution, malicious file storage, image parser exploits
languagePython (Flask)
root causeNo validation of uploaded file content, type, or size
vulnerabilityUnrestricted File Upload

Critical File Upload Vulnerability Fixed: How Unrestricted Uploads Put Flask APIs at Risk

Severity: 🔴 Critical | CVE Category: Unrestricted File Upload | Affected Component: utils/flask_rest_api/restapi.py


Introduction

File upload functionality is one of the most deceptively dangerous features a web application can offer. What looks like a simple "choose your profile picture" button can become a wide-open door for attackers if not implemented carefully. This week, a critical severity vulnerability was patched in a Flask REST API endpoint that accepted image uploads without performing any meaningful validation.

If you're a developer building APIs that accept user-supplied files — even something as seemingly harmless as images — this post is required reading. Unrestricted file upload vulnerabilities consistently appear in the OWASP Top 10 and are responsible for some of the most devastating real-world breaches. Let's break down exactly what went wrong, how it was fixed, and how you can protect your own applications.


The Vulnerability Explained

What Was the Problem?

The vulnerable endpoint in utils/flask_rest_api/restapi.py (line 25) accepted image file uploads via Flask's request.files['image'] interface. The code would take whatever file a user submitted and pass it directly to an image processing pipeline — likely using PIL (Pillow) or OpenCV — without performing any of the following critical checks:

  • MIME type inspection — Is the file actually an image?
  • Magic byte verification — Do the first bytes of the file match a known image format?
  • Extension allowlisting — Is the file extension .jpg, .png, or .gif?
  • File size limits — Could an attacker upload a 10GB file to exhaust server resources?
  • Sandboxing — Is the file processed in an isolated environment?

In short: the server trusted the client completely. And in security, that's almost always a fatal mistake.

Technical Deep Dive

Here's a simplified representation of what the vulnerable code pattern looked like:

# VULNERABLE CODE (before fix)
from flask import Flask, request
from PIL import Image
import io

app = Flask(__name__)

@app.route('/upload', methods=['POST'])
def upload_image():
    # ⚠️ No validation whatsoever!
    image_file = request.files['image']

    # Directly processing whatever the user sent
    img = Image.open(image_file)
    img.save('/uploads/' + image_file.filename)

    return {'status': 'uploaded'}, 200

This code is dangerous for several compounding reasons:

  1. No type checking: The server assumes the uploaded file is an image simply because it was sent to an image upload endpoint.
  2. Direct filename usage: Using image_file.filename from user input opens the door to path traversal attacks (e.g., uploading a file named ../../etc/cron.d/malicious).
  3. No size restriction: An attacker can send gigabytes of data, causing a Denial of Service (DoS).
  4. Unvalidated parsing: Passing arbitrary data to Image.open() exposes the server to image parser exploits.

How Could This Be Exploited?

Let's walk through several realistic attack scenarios:

🎭 Scenario 1: The Polyglot File Attack

A polyglot file is a file that is simultaneously valid in two different formats. An attacker can craft a file that:
- Passes a naive extension check (it ends in .jpg)
- Is actually a valid PHP or Python script when interpreted by a server

# Attacker crafts a polyglot: valid JPEG header + embedded PHP payload
# The file looks like an image but contains executable code
printf '\xFF\xD8\xFF\xE0<?php system($_GET["cmd"]); ?>' > evil.jpg

# Upload it to the vulnerable endpoint
curl -X POST http://target.com/upload \
  -F "image=@evil.jpg"

If the server later serves this file from a location where scripts can be executed, the attacker achieves Remote Code Execution (RCE).

💣 Scenario 2: Image Parser Exploitation (CVE-style)

Image parsing libraries like PIL/Pillow and OpenCV have had numerous CVEs over the years:

  • CVE-2021-34552 — Pillow buffer overflow via crafted image
  • CVE-2022-22817 — Pillow PIL.ImageMath.eval arbitrary code execution
  • CVE-2023-44271 — Pillow uncontrolled resource consumption

By uploading a specially crafted malformed image, an attacker can trigger these vulnerabilities in the parsing library itself, potentially achieving code execution inside the server process — no extension tricks needed.

📁 Scenario 3: Path Traversal via Filename

# If the server uses the original filename:
image_file.filename  # Attacker supplies: "../../app/templates/index.html"

# The server might overwrite critical application files!
img.save('/uploads/' + image_file.filename)
# Resolves to: /app/templates/index.html  ← Overwritten!

🌊 Scenario 4: Denial of Service via Upload Bomb

Without size limits, an attacker can upload a "zip bomb" or a massive file that exhausts disk space and memory:

# Upload a 10GB file of zeros disguised as an image
dd if=/dev/zero bs=1M count=10240 | curl -X POST http://target.com/upload \
  -F "image=@-;filename=bomb.jpg"

Real-World Impact

The business impact of this vulnerability class is severe:

Impact Description
Remote Code Execution Attacker gains full control of the server
Data Exfiltration Access to databases, credentials, source code
Service Disruption DoS via resource exhaustion
Lateral Movement Server becomes a pivot point into internal network
Compliance Violations GDPR, HIPAA, PCI-DSS breaches

The Fix

What Changes Were Made?

The patch to utils/flask_rest_api/restapi.py introduces multiple layers of defense, following the defense-in-depth principle. Here's what a properly secured version of this endpoint looks like:

# SECURE CODE (after fix)
import os
import io
import uuid
import magic  # python-magic for MIME detection
from flask import Flask, request, abort
from PIL import Image
from werkzeug.utils import secure_filename

app = Flask(__name__)

# Configuration constants
MAX_CONTENT_LENGTH = 5 * 1024 * 1024  # 5MB hard limit
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH

ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp'}
ALLOWED_MIME_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
ALLOWED_MAGIC_BYTES = {
    b'\xFF\xD8\xFF',           # JPEG
    b'\x89PNG\r\n\x1a\n',     # PNG
    b'GIF87a', b'GIF89a',     # GIF
    b'RIFF',                   # WebP (followed by WEBP)
}

UPLOAD_FOLDER = '/secure/uploads'

def allowed_extension(filename: str) -> bool:
    """Check if file extension is in the allowlist."""
    return (
        '.' in filename and
        filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
    )

def allowed_mime_type(file_stream) -> bool:
    """Verify MIME type using python-magic (reads actual file bytes)."""
    header = file_stream.read(2048)
    file_stream.seek(0)  # Reset stream position
    mime = magic.from_buffer(header, mime=True)
    return mime in ALLOWED_MIME_TYPES

def validate_image_integrity(file_stream) -> bool:
    """Attempt to open and verify the file is a valid image."""
    try:
        file_stream.seek(0)
        with Image.open(file_stream) as img:
            img.verify()  # Verify it's not corrupted/malicious
        file_stream.seek(0)
        return True
    except Exception:
        return False

@app.route('/upload', methods=['POST'])
def upload_image():
    # ✅ Check file is present in request
    if 'image' not in request.files:
        abort(400, description="No image file provided")

    image_file = request.files['image']

    if image_file.filename == '':
        abort(400, description="Empty filename")

    # ✅ Validate file extension against allowlist
    if not allowed_extension(image_file.filename):
        abort(400, description="File type not permitted")

    # ✅ Validate actual MIME type (not just Content-Type header)
    if not allowed_mime_type(image_file.stream):
        abort(400, description="File content does not match an allowed image type")

    # ✅ Validate image integrity (catches malformed/exploit images)
    if not validate_image_integrity(image_file.stream):
        abort(400, description="Invalid or corrupted image file")

    # ✅ Sanitize filename to prevent path traversal
    safe_filename = secure_filename(image_file.filename)

    # ✅ Generate a random filename to prevent enumeration/overwrite
    file_ext = safe_filename.rsplit('.', 1)[1].lower()
    random_filename = f"{uuid.uuid4().hex}.{file_ext}"

    # ✅ Save to designated upload directory
    save_path = os.path.join(UPLOAD_FOLDER, random_filename)
    image_file.stream.seek(0)
    image_file.save(save_path)

    return {'status': 'uploaded', 'id': random_filename}, 200

@app.errorhandler(413)
def too_large(e):
    return {'error': 'File too large. Maximum size is 5MB.'}, 413

How Does the Fix Solve the Problem?

Let's map each fix to the attack scenarios we discussed:

Defense Layer What It Prevents
MAX_CONTENT_LENGTH = 5MB DoS via upload bombs
Extension allowlist Obvious malicious file types
magic MIME type check Polyglot files with wrong extensions
img.verify() integrity check Malformed images targeting parser CVEs
secure_filename() Path traversal via crafted filenames
uuid4() random filenames Filename enumeration and targeted overwrites

Why Magic Bytes Matter More Than Extensions

This is a critical concept worth emphasizing. File extensions are user-controlled and completely untrustworthy. A file named evil.jpg can contain anything. Magic bytes — the first few bytes of a file that identify its format — are much harder to fake while maintaining a valid attack payload.

# These are the first bytes of real image files:
JPEG:  FF D8 FF E0 ...
PNG:   89 50 4E 47 0D 0A 1A 0A ...
GIF:   47 49 46 38 39 61 ...  ("GIF89a")

# python-magic reads these bytes directly from the file content
# NOT from the filename or Content-Type header
mime = magic.from_buffer(header, mime=True)

Why img.verify() Adds Another Layer

Even if a file passes MIME type checking, it might still contain embedded exploit payloads. Calling Image.verify() forces PIL to parse the image structure and raises an exception if anything looks malformed. This doesn't guarantee safety against all parser bugs, but it significantly raises the bar for attackers.


Prevention & Best Practices

Checklist for Secure File Upload Endpoints

Use this checklist whenever you implement file upload functionality:

 VALIDATION
 Allowlist permitted file extensions (never use a denylist)
 Check MIME type using magic bytes, not just Content-Type header
 Validate file integrity by attempting to parse/open it
 Reject files with multiple extensions (e.g., evil.php.jpg)

 STORAGE
 Store uploads outside the web root (not publicly accessible by default)
 Generate random filenames  never use user-supplied filenames
 Use secure_filename() or equivalent to sanitize any filename components
 Consider a dedicated object storage service (S3, GCS, Azure Blob)

 SIZE & RATE LIMITING
 Enforce maximum file size at the framework level
 Implement rate limiting on upload endpoints
 Consider total storage quotas per user

 PROCESSING
 Process files in an isolated environment (container, sandbox)
 Use up-to-date versions of image processing libraries
 Consider re-encoding images (strip metadata, re-render) to neutralize payloads
 Scan uploaded files with antivirus/malware detection

 SERVING
 Serve user-uploaded files from a separate domain (prevents same-origin attacks)
 Set Content-Disposition: attachment for downloads
 Set proper Content-Type headers when serving files
 Implement access controls  who can access what files?

The Nuclear Option: Image Re-encoding

For the highest level of security, don't just validate the uploaded image — re-encode it entirely. This strips any embedded payloads and ensures you're serving a clean, freshly generated image:

from PIL import Image
import io

def sanitize_image(file_stream) -> bytes:
    """
    Re-encode image to strip any embedded payloads.
    The output is a freshly rendered image with no original metadata.
    """
    with Image.open(file_stream) as img:
        # Convert to RGB to strip alpha channel tricks
        if img.mode not in ('RGB', 'L'):
            img = img.convert('RGB')

        # Re-encode to a clean buffer
        output = io.BytesIO()
        img.save(output, format='JPEG', quality=85)
        return output.getvalue()

This approach is used by major platforms like Facebook and Google — they never serve your original upload directly.

Relevant Security Standards

  • OWASP: File Upload Cheat Sheet
  • CWE-434: Unrestricted Upload of File with Dangerous Type
  • CWE-22: Path Traversal
  • CWE-400: Uncontrolled Resource Consumption
  • OWASP Top 10 A04:2021: Insecure Design

Tools to Detect This Vulnerability

# Static Analysis
bandit -r your_flask_app/          # Python security linter
semgrep --config=p/python          # Pattern-based code scanning

# Dynamic Testing
# OWASP ZAP - active scanner with file upload tests
# Burp Suite - manual testing with file upload plugins

# Dependency Scanning (for known parser CVEs)
pip-audit                          # Check for vulnerable Python packages
safety check                       # Alternative dependency scanner

A Note on Defense in Depth

No single validation check is foolproof. New image parser CVEs are discovered regularly. Magic byte checks can theoretically be bypassed in edge cases. img.verify() doesn't catch every malformed image.

That's why defense in depth is the right mental model: each layer of validation independently raises the cost and complexity of an attack. An attacker who bypasses your extension check still faces MIME validation. One who crafts a valid MIME type still faces integrity checking. One who passes all checks still encounters a random, non-executable filename in a non-web-accessible directory.

Security is not a single wall — it's a series of gates.


Conclusion

The unrestricted file upload vulnerability patched in utils/flask_rest_api/restapi.py is a textbook example of how seemingly simple functionality can hide critical security risks. By accepting user-uploaded files without validation, the application was exposed to remote code execution, denial of service, path traversal, and image parser exploitation.

The fix applies multiple independent layers of defense:
- Allowlist-based extension validation to block obvious threats
- Magic byte MIME inspection to catch polyglot files
- Image integrity verification to detect malformed exploit payloads
- Filename sanitization and randomization to prevent path traversal and enumeration
- Hard file size limits to prevent resource exhaustion

Key Takeaways

🔑 Never trust user input — including filenames, MIME types, and file contents

🔑 Validate at multiple layers — extension + MIME + integrity check

🔑 Use magic bytes, not Content-Type headers or extensions, to determine file type

🔑 Isolate and sanitize — store uploads outside the web root, use random filenames

🔑 Keep dependencies updated — image parser CVEs are real and actively exploited

File upload security is not glamorous, but getting it wrong can be catastrophic. Invest the time to implement it correctly from the start — your future self (and your users) will thank you.


This vulnerability was identified and fixed as part of an automated security scanning pipeline. Continuous security scanning helps catch critical issues before they reach production. Consider integrating tools like OrbisAI Security into your CI/CD pipeline for automated vulnerability detection.


Further Reading:
- OWASP File Upload Cheat Sheet
- Portswigger: File Upload Vulnerabilities
- HackTricks: File Upload
- Pillow Security Advisories

Frequently Asked Questions

What is unrestricted file upload?

Unrestricted file upload (CWE-434) is a vulnerability where an application accepts file uploads without properly validating the file type, content, or size. Attackers can upload malicious files (executables, web shells, or specially-crafted files) disguised as legitimate file types, potentially leading to remote code execution, data breaches, or denial of service.

How do you prevent unrestricted file upload in Flask?

Prevent unrestricted file uploads in Flask by implementing multiple validation layers: verify file content using magic byte inspection (python-magic library), whitelist allowed file extensions, enforce maximum file size limits, store uploads outside the webroot, rename files to prevent path traversal, and never execute or directly serve uploaded content without sanitization.

What CWE is unrestricted file upload?

Unrestricted file upload is classified as CWE-434: "Unrestricted Upload of File with Dangerous Type." This vulnerability occurs when software allows users to upload files without sufficiently verifying that the file type, contents, or size are safe, potentially allowing attackers to introduce malicious code or cause denial of service.

Is checking file extensions enough to prevent unrestricted file upload?

No, checking file extensions alone is insufficient. Attackers can easily rename malicious files (e.g., malware.exe → malware.jpg). Effective protection requires magic byte inspection to verify actual file content, MIME type validation, file size limits, content scanning, and storing uploads in non-executable directories with randomized names.

Can static analysis detect unrestricted file upload?

Yes, static analysis tools can detect unrestricted file upload vulnerabilities by identifying file upload endpoints that lack validation checks. Tools like Semgrep, Bandit, and specialized security scanners can flag missing validation patterns such as absent magic byte checks, missing size limits, or direct file saves without content verification.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13760

Related Articles

medium

How insecure update manifest parsing happens in C++ UpdateHelper.cpp and how to fix it

TrafficMonitor's software update mechanism in `UpdateHelper.cpp` fetched and parsed update manifests from remote servers without validating the version string or enforcing trusted download URLs, leaving users exposed to man-in-the-middle (MITM) attacks. An attacker on the same network could intercept the update channel and inject a malicious binary under a crafted version string or an HTTP download link pointing to attacker-controlled infrastructure. The fix adds strict version-string sanitizati

high

How integer overflow in malloc happens in C bipartite matching and how to fix it

A high-severity integer overflow vulnerability was discovered in the bipartite matching algorithm implementation where unchecked multiplication operations for memory allocation could wrap around, causing undersized buffer allocations and subsequent heap overflow. The fix replaces vulnerable `malloc(sizeof(int) * V)` patterns with safe `calloc(V, sizeof(int))` calls and adds proper bounds validation to prevent exploitation.

high

How integer truncation heap overflow happens in C++ UEFI ACPI parsing and how to fix it

A high-severity integer truncation vulnerability was discovered in `Mobility.Uefi.Acpi.cpp` where heap allocation sizes were stored in a 16-bit integer (`MO_UINT16`), causing silent truncation when the computed size exceeded 65535 bytes. This led to undersized heap allocations followed by out-of-bounds writes, exploitable by an attacker who can influence ACPI SRAT table contents in virtualized environments. The fix promotes the size variable to `MO_UINTN` (platform-native width) to prevent trunc

critical

How API key exposure in configuration files happens in TOML config and how to fix it

A critical security vulnerability in `commands/webperf.toml` allowed API keys to be hardcoded directly in configuration files, creating a credential exposure risk. The documentation on line 11 suggested developers could provide `CRUX_API_KEY` or `GOOGLE_API_KEY` directly in the config, which could lead to these sensitive credentials being committed to version control or exposed in logs. The fix updated the documentation to explicitly require environment variables and warn against hardcoding cred

high

How path traversal happens in Ruby YARD server and how to fix it

A high-severity path traversal vulnerability (CVE-2026-41493) in YARD versions prior to 0.9.42 allowed attackers to read arbitrary files from servers running `yard server`. This fix upgrades the yard gem from 0.9.26 to 0.9.42 in the Gemfile and Gemfile.lock, closing a dangerous information disclosure vector that could expose configuration files, credentials, and source code.

high

How buffer overflow via sprintf() happens in C networking code and how to fix it

A high-severity buffer overflow vulnerability was discovered in `profile.c` where `sprintf()` was used to format server addresses without any bounds checking. An attacker who could influence the `SERVER_BASE_PORT` value or trigger integer overflow in the port calculation could write beyond the `server_address` buffer. The fix replaces `sprintf()` with `snprintf()` using explicit buffer size limits at both call sites (lines 99 and 220).