
Remote Code Execution in DbGate via functionName injection in the loadReader endpoint — CVSS 8.8
functionName injectionSeverity: High (CVSS 8.8)
CWE: CWE-94 — Improper Control of Generation of Code ('Code Injection')
Affected: dbgate-api ≤ 7.1.8 (patched in 7.1.9)
Advisory: GHSA-hv83-ggc4-v385
NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-48017
Credit: Romain Deperne
The POST /runners/load-reader endpoint takes a functionName parameter and interpolates it
directly into a JavaScript template string that is then executed in a forked process — with no
sanitization or validation. Any authenticated user (no special permission needed) can break out of
the intended call and run arbitrary JavaScript, achieving remote code execution on the server.
The forked runner sets require = null as a sandbox, but that is trivially bypassed:
is still reachable and spawns a real OS process.
process.binding("spawn_sync")I was auditing DbGate's runner subsystem for the gap between guarded and unguarded code-exec
paths. The start() runner at runners.js:292 is properly gated — it calls
testStandardPermission('run-shell-script') and checks platformInfo.allowShellScripting.
loadReader() does the conceptually-similar thing (it builds and runs a JS loader script) but has
none of those checks. I traced the data flow:
runners.js:353 loadReader({ functionName, props })
runners.js:366 loaderScriptTemplate(prefix, functionName, ...)
runners.js:64 `... ${compileShellApiFunctionName(functionName)}(${JSON.stringify(props)});`
packageTools.ts:33 return `dbgateApi.${functionName}` // ← no sanitization
functionName is attacker-controlled and lands inside an executed template string. The dbgateApi.
prefix is the only thing in front of it, and it is escapable: closing the expression with toString();,
injecting the payload, and commenting out the trailing (${props}) with // yields valid JS.
File: packages/api/src/controllers/runners.js (loadReader → loaderScriptTemplate)
File: packages/tools/src/packageTools.ts:33 (compileShellApiFunctionName)
// packageTools.ts:33 — functionName flows in unsanitized
return `dbgateApi.${functionName}`;
// runners.js:64 — interpolated into the executed loader template
`const reader = await ${compileShellApiFunctionName(functionName)}(${JSON.stringify(props)});`
Generated (injected) script:
const reader = await dbgateApi.toString();
process.binding("spawn_sync").spawn({ file:"/bin/sh", args:["/bin/sh","-c","id"], ... });
dbgateApi.toString//({});
compileShellApiFunctionName() was written to turn a short name into a fully-qualified API path
(dbgateApi.<name>), implicitly trusting functionName to be an identifier. The value, however,
comes straight from the HTTP request body. Because it is concatenated into source that is later
eval/forked, the trust assumption is an RCE. The require = null sandbox does not help — Node's
internal process.binding("spawn_sync") bypasses it.
Fix (7.1.9): validate functionName against a strict identifier allow-list before compiling it
into the loader script.
poc/rce_loadreader_functionname_injection.py — drives the real endpoint end-to-end.
# with an existing JWT
python3 poc/rce_loadreader_functionname_injection.py http://localhost:3000 <JWT> 'id > /tmp/pwned'
# or log in first
python3 poc/rce_loadreader_functionname_injection.py http://localhost:3000 --login admin password 'id'
The script builds the functionName payload, sends it to POST /runners/load-reader, and the
injected spawn_sync call executes the command in the forked runner process.
Authenticated RCE on the DbGate API host. DbGate is a database management GUI frequently deployed with broad network reach to internal databases — code execution on it is a strong pivot into the data tier.
Disclosed responsibly. PoC published after the fix shipped, for defenders and detection engineering.