Sandcastles, Not Sandboxes: How One Architectural Flaw Exposed Seven Products

Introduction
Agent frameworks increasingly execute code that comes from a model, a user, or a combination of both. The code may call APIs, inspect files, transform data, or invoke tools. It therefore needs an execution environment with a clearly defined set of permissions. The same need appears outside agent systems: workflow platforms run user-defined transforms, spreadsheets evaluate formulas, CI systems run tests from dependencies, and desktop applications load plugins or embedded code.
For many Python-facing products, Pyodide is an attractive implementation choice. It runs CPython in WebAssembly and can be embedded directly into a JavaScript application. Product teams then commonly restrict imports such as os, subprocess, and js before evaluating user code. n8n, Grist, smolagents, LangChain's sandbox, stlite, and cibuildwheel all used variants of this model.
Our review focused on whether those Python-level restrictions matched the capabilities available to the embedded interpreter. Across the seven targets, they did not.
Key Findings
- This blog is a white paper of sorts for the DEF CON 34 (2026) talk. More information is available at https://info.defcon.org/defcon34/content/66595.
- The Pyodide restrictions were inco tmplete. In each of the seven products we tested, a denylist controlled imports or selected Python APIs, but did not account for ctypes and Emscripten's exported symbols. That left a route from untrusted Python to the embedding runtime.
- The disclosures produced four CVEs and several product changes. CVE-2025-68668 (CVSS 9.9), CVE-2026-61522 (9.3), CVE-2026-24002 (9.1), and CVE-2026-10613 (8.3) were assigned. Two repositories were later archived, and one project removed its WASM executor.
- The affected products serve different use cases. We reproduced related escapes in workflow automation, spreadsheets, AI-agent runtimes, desktop applications, and CI/CD tooling. That breadth is why we examined the deployment pattern rather than treating each report as an isolated implementation mistake.
What Is a Sandbox?
A sandbox allows code to run while limiting the resources it can access. The limit can be enforced at several layers:
- OS-level isolation uses containers, microVMs, gVisor, seccomp, or similar controls. The operating system or hypervisor limits what a process can do: which files it can read, which network connections it can make, and whether it can create other processes.
- Language-level sandboxes restrict APIs within the interpreter or VM. Java's former SecurityManager, .NET Code Access Security, and Lua restricted environments are examples.
- Browser and WASM environments constrain memory and host interaction. WASM provides bounds-checked linear memory, but a module's imports and its JavaScript host still determine its external capabilities.
- Python sandboxing often relies on restricted builtins, import hooks, or an embedding environment such as Pyodide. These controls are difficult to make comprehensive when the interpreter exposes reflection or native interfaces.
Calling something a sandbox does not make it safe. The restriction has to block the ways code can actually reach the host. In these products, Python was prevented from importing some modules, but ctypes could still call functions exposed by Emscripten.
What Is Pyodide?
Pyodide is CPython compiled to WebAssembly through Emscripten. It runs a complete Python 3.x interpreter - with the full standard library - inside any JavaScript runtime: browsers, Node.js, or Deno.
The Compilation Pipeline

Figure 1: How Pyodide compiles CPython into WebAssembly and JavaScript glue that runs inside a browser, Node.js, or Deno host.
Emscripten does not just compile C to WASM. It generates a JavaScript "glue" layer that gives the compiled code access to the outside world. This includes:
- POSIX and libc emulation. Emscripten implements many C-library and POSIX-style interfaces in JavaScript. For example, filesystem operations may use Emscripten's virtual filesystem instead of making kernel syscalls on the host.
- Virtual filesystem (FS). An in-memory filesystem backed by JavaScript objects. The compiled code thinks it has /tmp and /home - those are JS data structures.
- Memory views. HEAP8, HEAP32, and HEAPU8 are JavaScript typed arrays over the WASM module's linear memory. Emscripten uses them to pass data between JavaScript and compiled code running inside the module.
- Bridge functions. emscripten_run_script(), emscripten_run_script_string(), emscripten_run_script_int() - functions that evaluate arbitrary JavaScript strings from inside WASM. These are part of Emscripten's standard API, exported by default.
The Two-Line Integration
One of Pyodide’s main selling points is how little code an application needs to embed it.
This in-process model is operationally convenient: there is no separate Python service or container to operate. Products often add an import denylist for os, subprocess, sys, and the js bridge module before they run untrusted code.
What Pyodide Contains
Pyodide includes CPython's ctypes module, which provides a Foreign Function Interface to C libraries. The binary can also expose Emscripten symbols unless they are explicitly stripped at link time.
When a product loads Pyodide and runs untrusted Python, the following are available inside the "sandbox":
- ctypes and _ctypes (FFI to native code)
- emscripten_run_script_string (evaluate JS in the host, accessible via ctypes.CDLL(None))
- system, popen, execve (C library functions, also accessible via ctypes)
In the configurations we tested, ctypes remained available and the relevant symbols could be resolved. WASM memory isolation still protects the module's linear memory; it does not itself limit calls that the Emscripten host has intentionally made available.
The Layers of a Pyodide Deployment
The modules and Emscripten interfaces described above do not run on their own: they sit inside a product deployment with three layers. Separating them makes it easier to see where a product applies restrictions and which components are involved at runtime.

Figure 2: The layers of a Pyodide deployment. Product policy controls untrusted Python, while Pyodide, Emscripten, the host runtime, and the execution environment determine the capabilities and data that remain reachable.
The products in this research applied most of their policies at Layer 1. The next section examines the Python and Emscripten interfaces that remained available below those policies.
How the Pyodide Sandbox Is Bypassed
Using ctypes to Access Emscripten Functions
Python's ctypes module provides a Foreign Function Interface to C libraries. ctypes.CDLL(None) loads the main program's symbol table - equivalent to dlopen(NULL) in C. In a normal CPython installation, this gives access to libc.
In Pyodide, "the main program" is the Emscripten-compiled WASM module. CDLL(None) returns the entire Emscripten export table: every C function that was linked into the binary and not stripped. This includes:
The emscripten_run_script functions are the relevant primitives in this chain. They pass a C string to the host JavaScript runtime and run it as JavaScript. Depending on the variant, they can also return a value to the caller. Calling one from Pyodide crosses from the WASM module into the embedding process.

Figure 3: The ctypes escape path from untrusted Python through exported Emscripten functions into JavaScript host APIs and the resources available to the host process.
ctypes was available in all seven configurations we tested. It is part of CPython's standard library, so a deployment must deliberately remove or restrict it if it does not want user code to use it.
Using Class Hierarchy Traversal to Recover Imports
Import restrictions also need to account for Python's reflective object model. Every object inherits from object, and object.__subclasses__() exposes classes that may retain references to the full __builtins__ namespace:
This expression walks from an empty tuple to object, enumerates subclasses, finds warnings.catch_warnings, and uses that class's module reference to recover __builtins__ and __import__. Depending on how the product implements its import restrictions, that can provide an alternative way to load a module.
catch_warnings is one example, not a complete list of possible routes. Python's runtime object graph is large, and it changes with the Python or Pyodide version and with the modules the application has already loaded. In a particular deployment, another class, function, module reference, or cached global may provide a similar route to imports or builtins. Blocking one known traversal should not be treated as proof that all reflection-based routes are closed.
This is why a denylist for a few module names is difficult to treat as a complete security boundary. The interpreter has multiple module references, reflection APIs, and internal execution paths. A useful short-term control can reduce exposure, but it needs to be combined with a smaller available surface and an independent host-level restriction.
Python Wrappers and the Functions Behind Them
The products blocked some Python modules and functions, but the same actions were still available through lower-level runtime interfaces.
For example, os.system is a Python wrapper around a lower-level command-execution capability. Replacing that wrapper does not prevent code from reaching the same capability through FFI when the underlying symbol remains available.
The available mitigations provide different levels of assurance. An import allowlist and removal of ctypes reduce the Python attack surface. Stripping unneeded Emscripten exports reduces the symbols an FFI caller can resolve. Process isolation, OS controls, and tightly scoped host-runtime permissions provide a separate boundary if the language runtime is bypassed.
How the Host Runtime Affects Impact
The Pyodide-to-host transition was similar across the targets. The consequences varied with the host runtime, the permissions of its process, and the secrets or data accessible from that process.
Node.js: No Permission Model
Node.js applications commonly make filesystem, process, and environment APIs available to their own code. When the escaped JavaScript runs in that context, it can use the same APIs as the embedding process:
Node.js does not normally require a per-API permission prompt. Four targets - n8n, Grist, cohere-terrarium, and stlite - embedded Pyodide in Node.js contexts, so the impact reflected the privileges of their respective processes.
Deno: Permission-Gated Host Access
Deno requires explicit permissions for operations such as subprocess spawning (--allow-run), filesystem access (--allow-read), and network access (--allow-net).
Products may grant some of these permissions to implement their features. For example, smolagents launched Deno with --allow-run so an agent could execute tools. Grist's Deno migration included --allow-env, which made environment variables available to code running with that permission.
These permissions influence what escaped JavaScript can do after it reaches Deno. They do not prevent the transition from Pyodide into the Deno runtime, but they can limit the operations available after that transition.
In Node.js, the embedding process normally has access to APIs such as require(), fs, child_process, and process.env. In Deno, comparable process, filesystem, network, and environment APIs are controlled by the --allow-* permissions granted to the process. The JavaScript runtime and its permissions determine which operations are available after code reaches the host.
Why the Execution Environment Matters
The host runtime determines what an escaped script can do. The surrounding environment determines what that script can reach. A Node.js process running a local development tool and a Node.js process running a release pipeline may expose the same APIs, but they do not hold the same data or credentials.
CI/CD: Release Credentials and Artifacts
Build systems run tests and build steps alongside release infrastructure. Depending on the configuration, the runner may have a source checkout, GITHUB_TOKEN, PyPI upload tokens, GPG signing keys, or artifacts that are about to be published. The Pyodide host may be Node.js or another runtime; the distinctive risk is the sensitive material made available by the CI environment.
A malicious test introduced through a compromised dependency or source contribution could use an escape path to access those credentials and artifacts. If the attacker can obtain publishing credentials or modify a release artifact, the incident may affect downstream users. This is comparable to other build-pipeline compromises, such as the xz-utils compromise, although the entry point and mechanics differ.
Agentic Frameworks: Tool Access and Delegated Credentials
Agent systems often run code on behalf of a user or service. The agent may receive API tokens, database credentials, cloud credentials, access to internal service endpoints, tool outputs, or customer data needed to complete a task. Model output or a prompt-injection payload can influence the code that the agent executes, so an escape from that code-execution environment can expose the same resources that the agent was allowed to use.
This does not make every agent framework equally exposed. The impact depends on the host runtime, its permissions, the tools enabled for the agent, and the credentials supplied to it. smolagents is one example in this research: its WASM executor ran under Deno with --allow-run, so code that reached the Deno host could start processes. The full case study appears below.
CI/CD jobs can expose publishing tokens, signing keys, source checkouts, and release artifacts. Agent systems can expose the credentials, tools, internal APIs, databases, cloud resources, and task data delegated to an agent. In both cases, the practical question is what the escaped host process can reach.
The Runtime Is Only Part of the Picture
Node.js or Deno determines which host APIs escaped code can use. The environment around that runtime determines what those APIs lead to. A local development process with no secrets is a lower-value target than a CI release job or a production agent with cloud credentials and connectors.

Figure 4: The host runtime determines which operations an escape can perform, while the execution environment determines the credentials, data, and tools exposed to it.
This is not a fixed ranking. The risk depends on the permissions of the process, the tools enabled for it, its network access, the secrets it receives, and the data it can read or modify.
Case Studies
Node.js Targets
n8n - CVE-2025-68668, CVSS 9.9
n8n's Code node allowed users to run Python to transform data between workflow steps. In the affected design, that code ran in Pyodide on Node.js with Python-level restrictions. The disclosed bypasses used ctypes and an internal Pyodide evaluation path to reach command execution as the n8n service process.
An attacker who could edit or create workflows could access the credentials and connected integrations available to that process, including OAuth tokens, database credentials, and API keys. The issue was assigned CVE-2025-68668 with a CVSS score of 9.9. n8n later moved Python execution to an external-runner model. Read the full N8Scape writeup.
Grist - CVE-2026-24002, CVSS 9.1
Grist is an open-source relational spreadsheet whose server-side formula worker ran Python in Pyodide. It restricted direct JavaScript access through jsglobals, but the reported paths included Python class-hierarchy traversal and access to ctypes and Emscripten functions.
Those paths reached the Grist worker's host context and could expose documents, spreadsheet data, and other data or credentials available to that process. The issue was assigned CVE-2026-24002 with a CVSS score of 9.1. Grist addressed it in v1.7.9 by moving the sandbox to Deno with restricted permissions. Read the full Cellbreak writeup.
cohere-terrarium - CVE-2026-61522, CVSS 9.3
Cohere is an AI company that develops language models and tooling for building applications around them. Terrarium was its open-source Python execution sandbox for user- or LLM-generated code. It was intended for low-latency, containerized workloads such as data analysis, with support for common Pyodide packages including NumPy, pandas, and matplotlib.
Terrarium was maintained as an open-source project for roughly two years before Cohere archived it. Terrarium passed a restricted jsglobals object to Pyodide to limit which JavaScript objects sandboxed Python could access. That constrained the direct js module path, but ctypes remained available in the tested configuration. The following is the payload we used to demonstrate that path:
ctypes.CDLL(None) resolved Emscripten functions, and emscripten_run_script then ran JavaScript in the Node.js host context. The available Node APIs reflected the permissions of the container process. In the tested API configuration, submitted code did not require authentication.
We reported this ctypes-based escape to Cohere alongside a separate prototype-chain issue. Cohere fixed the prototype-chain issue as CVE-2026-5752 in Terrarium v1.0.1, but that change did not address the ctypes path described here. Cohere then archived the repository. With help from VulnCheck, this separate finding was assigned CVE-2026-61522; the CVE record lists Terrarium through v1.0.1 as affected and assigns a CVSS v3.1 score of 9.3 (CVSS v4.0: 9.4).
Deno Targets
smolagents - CVE-2026-10613, CVSS 8.3
smolagents is Hugging Face's lightweight framework for building AI agents. Its CodeAgent pattern lets a model write Python and passes that Python to an executor for execution.
smolagents has a substantial public developer audience. As of July 2026, its GitHub repository had more than 28,000 stars, and PyPI reported roughly 586,000 downloads in the preceding month. These are not counts of unique users, but they show that the library has broad developer interest and active use.
Developers can give an agent a model and developer-defined tools, then use it for tasks such as data analysis, research, automation, or calculations. Python execution is useful in these workflows because the agent can use it to combine tool results, transform data, and implement task-specific logic. The default executor was local; executor_type="wasm" was an optional backend intended for lightweight local execution rather than the primary deployment mode.
When developers opted into the WASM executor, smolagents started a Deno subprocess, loaded Pyodide inside it, and sent the model-generated Python to that process. This was intended to keep generated Python separate from the main application process. Deno was the JavaScript host for Pyodide, and its permissions controlled what code could do after it reached that host.
The following payload reached the Deno context:
The default configuration granted Deno --allow-run so that agents could execute tools. As a result, code that reached the Deno context could spawn processes. HuggingFace removed the WasmExecutor in May 2026.
langchain-sandbox
langchain-sandbox was a separate Pyodide-based sandbox project for code execution in LangChain-style applications. It was not the main LangChain package, and not every LangChain deployment used it.
Components like this are useful when an agent or application needs to run generated Python for calculations, data processing, or tool logic without placing that code directly in the application runtime. The intended benefit is a constrained execution environment for code that may be influenced by a model or user input.
langchain-sandbox embedded Pyodide in Deno and exposed configurable Deno permissions. That design was meant to give developers a place to restrict Python execution, but the tested implementation still exposed a ctypes path to system():
In the tested implementation, this call used Emscripten's syscall-emulation path rather than Deno's Command API. It therefore was not governed by Deno's allow_run setting. The maintainers archived the repository after disclosure.
Desktop and CI Targets
stlite
Streamlit is a Python framework for turning scripts into interactive web applications, such as dashboards and data tools. stlite runs those applications through Pyodide/WASM instead of a conventional Python server: in a browser, embedded in a React application, through its online sharing editor, or as an Electron desktop application. stlite also powers Streamlit Preview, a VS Code extension with more than 93,000 Marketplace installs as of July 2026.
Developers use stlite to share prototypes, embed dashboards, and package Streamlit apps as offline .app or .exe applications. This is normally a distribution model for an application written by its developer, not a multi-tenant service that accepts arbitrary code from end users. The relevant attack scenario is therefore a malicious desktop app, or malicious code introduced into an app or its dependencies, being distributed to a user.
stlite runs the Streamlit Python application through Pyodide. By default, nodeJsWorker is false and the Python worker runs in Electron's sandboxed renderer. Setting nodeJsWorker: true moves Pyodide into a Node.js worker so the application can mount directories from the host filesystem. That optional setting changes the JavaScript APIs available after a Pyodide escape:
A malicious stlite application, or code introduced through a dependency, could execute with the permissions of the desktop application when a user runs it. The project characterized the risk as a deployer responsibility.
cibuildwheel
cibuildwheel is Python Packaging Authority tooling for building and testing Python wheels across operating systems and Python versions. Package maintainers add it to CI jobs such as GitHub Actions, GitLab CI, or CircleCI so the built wheel can be tested before it is uploaded. It is widely used release infrastructure: as of July 2026, PyPI had recorded more than 30 million cibuildwheel downloads, including roughly 750,000 in the preceding month.
Pyodide is an experimental cibuildwheel platform. When a maintainer opts into --platform pyodide, cibuildwheel cross-compiles the package, creates a pyodide venv, puts Node.js on the execution path, installs the built wheel, and runs the configured test command against copied test sources. Those tests run as Pyodide Python hosted by Node.js.
This becomes a supply-chain concern when unreviewed or compromised test code runs in a release job that contains credentials or artifacts. A malicious test can look like an ordinary regression check, or live in a helper that processes an innocuous-looking fixture such as a .dat file. Once the job runs that code, a Pyodide escape can reach the CI host and anything the job can access. The impact is similar to the xz-utils compromise: code that entered a trusted build path can affect downstream users. The route is different. The xz backdoor came from a long-term maintainer compromise and malicious build material; here, the payload is a test that escapes its Pyodide environment. A test can use the following pattern to reach that host:
Such code can be hidden in a test suite and can access environment variables and files available to the runner. Depending on the CI configuration, that may include GITHUB_TOKEN, PyPI publishing tokens, GPG keys, or release artifacts. The project also characterized the issue as a deployer responsibility.
Disclosure Outcomes
n8n addressed the finding in October 2025 by moving execution to external runners and received CVE-2025-68668 (CVSS 9.9). In December, Grist moved the relevant worker from Node.js to Deno and received CVE-2026-24002 (9.1). smolagents removed its opt-in WasmExecutor in early 2026; that finding received CVE-2026-10613 (8.3).
The cohere-terrarium repository was archived after the final v1.0.1 security release. Our separate ctypes finding remained unpatched and received CVE-2026-61522 (9.3). The langchain-sandbox repository was also archived. stlite and cibuildwheel did not receive CVEs or ship code changes for these reports; both treated isolation as a deployer responsibility.
Those responses reflect different views of where the boundary belongs. A library can reasonably require deployers to provide process isolation when its documentation clearly states that requirement and its limitations. A product that presents an execution environment as isolated should likewise describe its threat model, host assumptions, and the controls needed for that claim to hold.
Methodology
After the first two discoveries, we used the following checklist for initial triage:
- Identify Pyodide usage. Search for loadPyodide, pyodide.js, pyodide.mjs in the codebase, npm dependencies, or product documentation.
- Check ctypes availability. Attempt import ctypes in the sandbox. If it succeeds, determine whether an attacker can resolve usable symbols.
- Resolve exported symbols. Call ctypes.CDLL(None) and probe for emscripten_run_script_string, system, popen.
- Determine the host runtime. Is it Node.js (no permission model), Deno (permission-gated), or a CI runner (secrets-adjacent)?
- Map the data plane. Identify credentials, files, databases, or services reachable from the host context after the transition.
- Build the exploit chain. ctypes -> host API -> data access. Adapt the JavaScript payload to the runtime (Node.js require vs Deno API vs CI environment variables).
Our GitHub and npm review identified additional repositories that use Pyodide with Python-level restrictions. We did not test each one. The presence of ctypes is a useful indicator, but exploitability still depends on the symbols available in the binary, the host runtime, and the permissions or data exposed to that host.
Remediation
For Product Teams
Reduce the exposed Python surface:
- Restrict ctypes and _ctypes where the intended workload does not require them.
- Use an allowlist for supported modules instead of attempting to enumerate unsafe ones.
- Review reflective paths and internal Pyodide APIs that can bypass an import hook.
Reduce the Emscripten surface:
- Strip emscripten_run_script*, system, popen, and execve from the WASM binary where they are not required.
- Audit the exports in the binary actually shipped to users. An FFI control is only effective if the dangerous symbol is unavailable.
Constrain the host process:
- Run untrusted execution in a separate process, container, or other OS-level isolation boundary with only the required capabilities.
- When using Deno, grant only the permissions needed for the feature and review each --allow-* flag.
- Treat Python-level restrictions as an additional control, not the sole boundary between untrusted code and host resources.

Figure 5: Defense in depth for Pyodide deployments. Restrict the Python surface, export only required Emscripten functions, minimize host-runtime permissions, and isolate the process to protect files, secrets, and services.
For Deployers and Users
- Evaluate Pyodide-based execution against the permissions and sensitive data available to its host process. Add process-level isolation when it may execute untrusted code.
- If your product uses one of the affected frameworks, upgrade to the patched version or apply the vendor's recommended mitigation.
- For CI/CD: run Pyodide builds in ephemeral containers with no network access and short-lived tokens.
Conclusion
Across the seven products we tested, Python-level restrictions did not account for the path from ctypes through Emscripten exports into the host runtime. The host runtime determines what an escape can call: in Node.js, it inherits the privileges of the service process; in Deno, it is limited by the permissions granted to that process. The execution environment determines what is at stake. A CI runner may provide release credentials and build artifacts, while an agentic application may provide access to tools and delegated credentials.
For researchers, loadPyodide and related integration code are useful starting points. Establishing an exploit requires more than importing ctypes: the investigation should also verify the exported symbols, host runtime, permissions, and accessible data.
For product teams, the central design question is where enforcement occurs. Import controls can reduce exposure, but a deployment that runs untrusted code also needs to limit the Emscripten exports and host-process capabilities that remain reachable after an interpreter-level control fails.
This research will be presented at DEF CON 34 on Friday, August 7, 2026, at 14:00 Las Vegas time.
Published research:


.avif)


