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
β€’Technically reviewed by Anupam Medirattaβ€’Published May 3, 2026β€’Reviewed 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

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.


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

Prevention and further reading

Related Articles

medium

path-to-regexp 0.1.12 DoS: Catastrophic Backtracking on Malformed URL

path-to-regexp version 0.1.12 contains a regular expression engine vulnerability where maliciously crafted URL parameters trigger exponential backtracking, causing CPU exhaustion and service unavailability. Upgrading to version 0.1.13 eliminates the vulnerable pattern from the parsing logic.

critical

CVE-2026-59873: node-tar 7.5.11 DoS via Crafted Gzip Bomb

node-tar versions 7.5.11 through 7.5.18 are vulnerable to a denial-of-service attack through maliciously crafted gzip archives that decompress to disproportionately large sizes. An attacker can exploit this to exhaust memory and CPU resources by submitting a small, highly compressed archive that expands beyond configured limits during extraction.

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validationβ€”file size thresholds and string pattern matchingβ€”that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.