
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:
┌──────────────────────────────────────────────────────────────┐
│ CVE-2026-39987 Timeline │
├──────────────────────────────────────────────────────────────┤
│ │
│ T+0h Advisory published │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ T+9h First exploit built from advisory │
│ T+10h Exploitation in the wild confirmed │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ T+10h 0m Attacker connects to /terminal/ws │
│ T+10h 1m Full PTY shell obtained │
│ T+10h 2m .env file located and read │
│ T+10h 3m AWS keys exfiltrated │
│ Total attack time: ~3 minutes │
└──────────────────────────────────────────────────────────────┘
The attack itself is trivially simple:
Step 1: Attacker opens WebSocket connection to /terminal/ws
(No authentication needed, no special tools required)
Step 2: Server creates a PTY (pseudo-terminal) session
Attacker now has an interactive shell
Step 3: Attacker runs commands:
$ cat .env
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
DATABASE_URL=postgres://...
OPENAI_API_KEY=sk-...
Step 4: Credentials exfiltrated
Attacker now has cloud access, database access,
and API keys for AI services
Total time: under 3 minutes
Authentication required: none
Tools required: any WebSocket client
No exploit development needed. No shellcode. No memory corruption. Just a WebSocket client and a missing auth check.
Immediate impact on the Marimo host:
Credential exposure (the primary attack goal):
.env files or environment variablesDownstream impact (via stolen credentials):
Risk amplification factors:
| Version | Status |
|---|---|
| Marimo 0.23.0+ | Patched |
| Marimo 0.20.5 to 0.22.x | Likely vulnerable (between advisory range and fix) |
| Marimo <= 0.20.4 | Vulnerable (confirmed range) |
CVE-2026-39987 is not an isolated incident. It's part of a clear pattern that emerged in April 2026:
| CVE | Product | Type | Status |
|---|---|---|---|
| CVE-2026-39987 | Marimo | Pre-Auth RCE (WebSocket) | Exploited in 10 hours |
| CVE-2026-33017 | Langflow | RCE | CISA KEV (March 26) |
| CVE-2026-5059 | aws-mcp-server | Command Injection RCE | Public advisory |
| TorchGeo | TorchGeo | eval() RCE | Public advisory |
Four AI/ML development tools hit with critical RCE vulnerabilities in a single month. The AI/ML development pipeline is becoming the new shadow IT: tools deployed with broad access, minimal security oversight, and rich credential stores.
┌─────────────────────────────────────────────────┐
│ Why AI/ML Tools Are Prime Targets │
├─────────────────────────────────────────────────┤
│ │
│ 1. DESIGNED to execute arbitrary code │
│ (that's literally what notebooks do) │
│ │
│ 2. Run with elevated privileges │
│ (GPU access, cloud SDKs, network access) │
│ │
│ 3. Contain high-value credentials │
│ (AWS keys, API tokens, DB connections) │
│ │
│ 4. Often exposed to the network │
│ (for collaboration and remote access) │
│ │
│ 5. Security hardening is an afterthought │
│ (focus on features and UX, not security) │
│ │
│ 6. Users are researchers, not security experts │
│ (default configs, weak passwords, no VPN) │
└─────────────────────────────────────────────────┘
The Python script detects vulnerable Marimo instances through a multi-step analysis.
How it works:
/api/status, /api/health, and / for Marimo indicators in response content and headers/terminal/ws (no data transmitted through the connection)/terminal/ws (should require auth) vs /ws (known to require auth) to confirm the inconsistencyNo commands are executed on target systems. The WebSocket handshake is tested but no data is sent through the connection. The check is entirely passive and safe for production.
Usage:
# Install dependencies
pip install -r requirements.txt
# Single target (Marimo default port: 2718)
python CVE-2026-39987_Marimo_RCE_detector.py -t http://marimo-host:2718
# HTTPS target
python CVE-2026-39987_Marimo_RCE_detector.py -t https://marimo-host:443
# Bulk scan from file with verbose output
python CVE-2026-39987_Marimo_RCE_detector.py -f targets.txt -o results.json -v
# Increased timeout for slow networks
python CVE-2026-39987_Marimo_RCE_detector.py -t http://10.0.0.5:2718 --timeout 15
Options:
| Flag | Description | Default |
|---|---|---|
-t, --target | Single target URL (e.g., http://host:2718) | — |
-f, --file | File with target URLs, one per line (# comments supported) | — |
-o, --output | Save results to JSON file | — |
--timeout | Connection timeout in seconds | 10 |
--verify-ssl | Enable SSL certificate verification | Disabled |
-v, --verbose | Verbose output with full details | Off |
Example output:
[*] CVE-2026-39987 Marimo Pre-Auth RCE Scanner
[*] Scanning 1 target(s)...
[*] NOTE: This scanner only checks for endpoint exposure.
[*] No commands are executed on target systems.
======================================================================
Target: http://10.0.0.5:2718
Scan Time: 2026-04-14T16:00:00Z
Risk Level: CRITICAL
======================================================================
Is Marimo: YES
Marimo Version: 0.19.2
/terminal/ws Open: YES — UNAUTHENTICATED
Vulnerable: YES
*** CRITICAL: Pre-authenticated RCE is possible! ***
*** An attacker can get a full PTY shell without any auth ***
Details:
- Marimo instance detected via /api/status
- Marimo version: 0.19.2
- WebSocket upgrade accepted — /terminal/ws accessible WITHOUT auth
- CONFIRMED: /terminal/ws accepts unauthenticated connections while
/ws requires auth — classic CVE-2026-39987 signature
- Version 0.19.2 <= 0.20.4 — VULNERABLE to pre-auth RCE
======================================================================
[*] Scan Complete: 1 targets scanned
[*] Marimo Instances: 1 | Vulnerable: 1 | Critical: 1
======================================================================
# Install the NSE script
sudo cp CVE-2026-39987_Marimo_RCE.nse /usr/share/nmap/scripts/
sudo nmap --script-updatedb
# Basic scan (Marimo default port: 2718)
nmap -p 2718 --script CVE-2026-39987_Marimo_RCE <target>
# Scan common ports where Marimo might run
nmap -p 2718,8080,8443,443 --script CVE-2026-39987_Marimo_RCE <target>
# Subnet scan
nmap -p 2718 --script CVE-2026-39987_Marimo_RCE 10.0.0.0/24
# Scan targets from a file
nmap -p 2718 --script CVE-2026-39987_Marimo_RCE -iL targets.txt
# With service version detection
nmap -sV -p 2718 --script CVE-2026-39987_Marimo_RCE <target>
Example Nmap output:
PORT STATE SERVICE
2718/tcp open http
| CVE-2026-39987_Marimo_RCE:
| VULNERABLE:
| Marimo Pre-Auth RCE (CVE-2026-39987)
| State: VULNERABLE
| Risk level: CRITICAL
| Marimo Version: 0.19.2
| /terminal/ws: accessible without authentication
| Description:
| The Marimo /terminal/ws WebSocket endpoint accepts connections
| without authentication, enabling pre-authenticated RCE.
| An attacker can obtain a full PTY shell without any credentials.
| References:
|_ https://nvd.nist.gov/vuln/detail/CVE-2026-39987
If you have access to the Marimo instance:
# Check Marimo version via API
curl -s http://<TARGET>:2718/api/status | python3 -m json.tool
# Test WebSocket upgrade on /terminal/ws (should NOT succeed without auth)
curl -s -o /dev/null -w "%{http_code}" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGVzdC1rZXktMTIzNDU2Nzg=" \
-H "Sec-WebSocket-Version: 13" \
http://<TARGET>:2718/terminal/ws
# If it returns 101 (Switching Protocols), the endpoint is open without auth
# If it returns 401/403, authentication is enforced (patched or hardened)
# For comparison, test /ws (should always require auth)
curl -s -o /dev/null -w "%{http_code}" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGVzdC1rZXktMTIzNDU2Nzg=" \
-H "Sec-WebSocket-Version: 13" \
http://<TARGET>:2718/ws
If /terminal/ws returns 101 while /ws returns 401/403, this is the classic CVE-2026-39987 signature.
Watch for these signs in your environment:
| Indicator | Where to Check | What to Look For |
|---|---|---|
| Unauthorized WebSocket connections | Server/proxy logs | Connections to /terminal/ws from unexpected IPs |
| PTY session creation | Process monitoring | Unexpected shell processes spawned by the Marimo server |
| File access | File audit logs | Reads of .env, credential files, or SSH keys |
| Credential usage | Cloud provider audit logs | API calls using keys that were stored in the Marimo environment |
| Outbound data transfer | Network monitoring | Unusual egress traffic from the Marimo host |
Investigation commands:
# Check for active WebSocket connections
ss -tnp | grep <MARIMO_PORT>
# Review process tree for unexpected shells
ps aux --forest | grep -A5 marimo
# Check if .env or credential files were recently accessed
stat .env
stat ~/.aws/credentials
# Review cloud provider activity logs for unauthorized access
aws cloudtrail lookup-events --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=<KEY_ID>
# Check for unauthorized outbound connections
netstat -tnp | grep ESTABLISHED | grep -v 127.0.0.1
Immediate actions (do these now):
pip install --upgrade marimo)Short-term (this week):
.env files and environment variables for any sensitive data that may have been exposedLong-term:
Kerem Oruç — Cybersecurity Engineer