
Detection and exploitation toolkit for CVE-2026-39987, a pre-auth RCE in Marimo notebooks. Includes Python scanner and Nmap NSE script to identify vulnerable instances via WebSocket endpoint checks.
A pre-authenticated remote code execution vulnerability in Marimo, an open-source Python notebook for data science and AI/ML. The terminal WebSocket endpoint (/terminal/ws) completely skips authentication validation, while the neighboring notebook endpoint (/ws) correctly enforces it. An unauthenticated attacker can connect to /terminal/ws and obtain a full interactive PTY shell on the host system with zero credentials.
Exploited in the wild within 10 hours of disclosure. Attackers stole AWS credentials in under 3 minutes.
Affects Marimo <= 0.20.4. Fixed in Marimo 0.23.0.
| Field | Detail |
|---|---|
| CVE ID | CVE-2026-39987 |
| Vendor | Marimo Project |
| Product | Marimo (Python Notebook) |
| Affected Versions | <= 0.20.4 |
| CVSS v3.1 | 9.3 (Critical) |
| CWE | CWE-306 — Missing Authentication for Critical Function |
| Attack Vector | Network |
| Authentication | None required |
| User Interaction | None |
| Exploit Maturity | Actively exploited in the wild |
| Time to Exploitation | ~10 hours after disclosure |
| Patched In | Marimo 0.23.0 |
Marimo is an open-source reactive Python notebook designed as a modern alternative to Jupyter. It's built for data science, AI/ML experimentation, and interactive data analysis. Its key features include automatic dependency tracking, reproducible execution, and a cleaner developer experience compared to traditional notebooks.
Marimo is gaining rapid traction in the Python and AI/ML community, especially among practitioners who want more structured notebook workflows than Jupyter provides.
Like all notebook environments, Marimo instances typically have access to sensitive resources: cloud credentials (AWS, GCP, Azure), database connection strings, API keys for AI services (OpenAI, Anthropic, etc.), and internal network access. Unlike traditional web applications, notebooks are designed to execute arbitrary code. That's their core purpose.
This combination makes any authentication bypass in a notebook environment particularly devastating.
Typical Marimo Deployment:
┌──────────────┐ ┌────────────────────────────────┐
│ │ HTTP │ Marimo Server │
│ Browser │────────>│ │
│ (User) │ │ ┌──────────────────────────┐ │
│ │<────────│ │ /ws (Notebook) │ │
└──────────────┘ WS │ │ ✅ validate_auth() │ │
│ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ /terminal/ws │ │
│ │ ❌ NO AUTH CHECK │ │
│ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ Python Environment │ │
│ │ .env files │ │
│ │ AWS credentials │ │
│ │ API keys │ │
│ └──────────────────────────┘ │
└────────────────────────────────┘
Marimo's server implements multiple WebSocket endpoints for different features. The critical difference between the two main endpoints is the presence (or absence) of an authentication check:
Authentication Flow Comparison:
/ws (Notebook WebSocket):
┌─────────┐ ┌───────────────┐ ┌──────────┐ ┌───────────┐
│ Connect │───>│ validate_auth │───>│ Accept │───>│ Notebook │
└─────────┘ └───────┬───────┘ └──────────┘ └───────────┘
│
❌ Reject if
not authenticated
/terminal/ws (Terminal WebSocket):
┌─────────┐ ┌───────────────┐ ┌──────────┐ ┌───────────┐
│ Connect │───>│ Check mode & │───>│ Accept │───>│ PTY Shell │
└─────────┘ │ platform only │ └──────────┘ └───────────┘
└───────────────┘
⚠️ No auth check!
Anyone gets a shell!
The notebook endpoint (/ws) correctly calls validate_auth() to verify the user's identity before accepting WebSocket connections. This is the expected security behavior.
The terminal endpoint (/terminal/ws) only checks whether the server is in running mode and whether the platform supports terminal functionality. It never calls validate_auth(). After passing these basic checks, it accepts the connection and creates a full PTY (pseudo-terminal) session.
# /ws (Notebook) — CORRECT implementation
async def websocket_connect(self, message):
await self.validate_auth() # ✅ Checks authentication
await self.accept()
# ... notebook communication
# /terminal/ws (Terminal) — VULNERABLE implementation
async def websocket_connect(self, message):
if not self.is_running_mode(): # Only checks mode
await self.close()
return
if not self.is_platform_supported(): # Only checks platform
await self.close()
return
await self.accept() # ❌ No auth! Anyone gets a shell
# ... PTY shell creation
This is CWE-306: Missing Authentication for Critical Function. The most dangerous endpoint on the server (the one that provides an interactive shell) has no authentication whatsoever.
The exploitation timeline shows how fast modern threat actors operate: