GitHub Advisoryadvisory · PyPI

GHSA-937x-gpqr-72gg Flask-Reuploaded: Extension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of CVE-2026-27641)

HIGH Published 2026-07-17 Modified 2026-07-17 Official page ↗
Aliases
CVE-2026-54567
Ecosystem
PyPI
Packages
Flask-Reuploaded
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
Weaknesses
CWE-178, CWE-434
Reviewed
2026-07-17

Affected packages

• PyPI Flask-Reuploaded: < 1.6.0 → fixed in 1.6.0

Description

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 (`flask_uploads`) | | Affected | ` 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). | Role | Location | |---|---| | Normalizing helper (default path) | `src/flask_uploads/flask_uploads.py:283` — `return lowercase_ext(secure_filename(filename))` | | `save()` entry | `src/flask_uploads/flask_uploads.py:285` | | name-override sanitize | `src/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-check | `src/flask_uploads/flask_uploads.py:345` | | Policy check | `src/flask_uploads/flask_uploads.py:268` — `extension_allowed` | | Containment backstop (path only) | `src/flask_uploads/flask_uploads.py:364` | | Sink | `src/flask_uploads/flask_uploads.py:374` — `storage.save(target)` | | Case-preserving extractor | `src/flask_uploads/extensions.py:101` — `def extension` | | Case-normalizing extractor | `src/flask_uploads/extensions.py:109` — `def lowercase_ext` | | Denylist membership | `src/flask_uploads/extensions.py:97` — `AllExcept.__contains__` | | Documented script denylist | `src/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: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 : 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))`…

Related

Alias · CVE (1)

  • CVE-2026-54567 Flask-Reuploaded provides file uploads for Flask. vulnerability

Weakness · CWE (2)

  • CWE-178 Improper Handling of Case Sensitivity weakness
  • CWE-434 Unrestricted Upload of File with Dangerous Type weakness

References