Executive Summary
CVE-2026-39987 is a critical pre-authentication remote code execution (RCE) vulnerability in Marimo, an increasingly popular open-source Python notebook used in AI/data science workflows. An unauthenticated attacker can connect to the unprotected /terminal/ws WebSocket endpoint and instantly obtain a full interactive shell — no credentials required. Exploitation began within 10 hours of public disclosure; attackers have used it to steal cloud credentials, API keys, and deploy malware. Any internet-exposed Marimo instance running version 0.20.4 or earlier should be considered fully compromised until patched or isolated.
1. What Is This Vulnerability?
Marimo ships with an integrated terminal accessible via a WebSocket endpoint at /terminal/ws. This terminal, when enabled, hands the connected client a full PTY (pseudo-terminal) shell running with the same system privileges as the Marimo server process.
The critical flaw: while most Marimo WebSocket endpoints (e.g., /ws) correctly call validate_auth() before accepting a connection, the /terminal/ws handler skips authentication entirely. It only checks whether the server is running in the correct mode and whether the platform supports PTY — and then opens the shell. Any unauthenticated network client that can reach the Marimo port gets root-equivalent code execution in one HTTP upgrade request.
The Vulnerable Code Pattern
# VULNERABLE - /terminal/ws handler (Marimo <= 0.20.4)
@router.websocket("/terminal/ws")
async def terminal_ws(websocket: WebSocket):
if not _terminal_supported():
await websocket.close()
return
# ⚠️ No call to validate_auth() here — connection accepted immediately
await websocket.accept()
await start_pty_session(websocket) # Full shell handed to attacker
Compare this to the properly secured /ws endpoint:
# SECURE - standard notebook WebSocket handler
@router.websocket("/ws")
async def notebook_ws(websocket: WebSocket, session_id: str):
await validate_auth(websocket) # ✅ Auth checked before proceeding
await websocket.accept()
...
The fix introduced in version 0.23.0 adds the missing authentication check to the terminal handler, matching the pattern used by all other protected endpoints.
Attack Vector
Exploitation is trivial: connect to ws://<target>:<port>/terminal/ws using any WebSocket client. No HTTP session, no cookie, no token. The attacker receives a fully interactive PTY shell immediately.
# One-liner proof-of-concept (using wscat)
wscat -c ws://target-host:2718/terminal/ws
# Result: Connected. Attacker now has interactive shell as marimo process user.
From there, an attacker can:
- Read and exfiltrate
.envfiles and cloud credential directories (~/.aws,~/.gcp,~/.config) - Harvest API keys for OpenAI, Anthropic, Google, and other LLM providers
- Pivot laterally using discovered credentials
- Deploy persistent malware (in real-world attacks: NKAbuse, a P2P botnet client)
- Execute arbitrary Python/shell code to interact with connected data sources and databases
Real-World Impact
Exploitation began within 9 hours and 41 minutes of public disclosure. The Sysdig Threat Research Team observed:
- 662 exploit events between April 11–14, 2026
- Credential theft completed in under 3 minutes per compromised host
- Deployment of NKAbuse — a multi-platform malware using the NKN peer-to-peer protocol for C2, making command-and-control traffic difficult to block by IP
- Active targeting of cloud credentials, LLM API keys, and data pipeline credentials
Because Marimo is commonly used in AI/ML development environments, compromised instances frequently exposed:
- OpenAI, Anthropic, and Google AI API keys
- AWS, GCP, and Azure credentials (environment variables, SDK configs)
- Database connection strings for training data
- Private model weights and research data
2. Who Is Affected?
| Component | Vulnerable Versions | Fixed Version |
|---|---|---|
| Marimo Python Notebook | All versions ≤ 0.20.4 | 0.23.0+ |
At-risk configurations:
- Marimo instances accessible from the internet (public IP, cloud VM, container with exposed port)
- Marimo running in team/collaborative mode with the terminal feature enabled
- Marimo served behind a reverse proxy that does not strip or block WebSocket paths
- Dockerized or Kubernetes-deployed Marimo without network policy restrictions
- Marimo instances started with
--host 0.0.0.0(binds to all interfaces)
Lower risk (but still patch):
- Marimo running strictly on
127.0.0.1localhost with no network exposure - Marimo behind a VPN or firewall that blocks external WebSocket access
Note: Even local instances can be exploited via CSRF attacks or malicious websites that initiate WebSocket connections from the victim's browser.
3. How to Detect It (Testing)
Manual Testing Steps
Step 1 — Check your Marimo version:
pip show marimo | grep Version
# If Version: 0.20.4 or lower, you are vulnerable
Step 2 — Test WebSocket endpoint accessibility:
# Install wscat if needed: npm install -g wscat
wscat -c ws://localhost:2718/terminal/ws --no-auth
Step 3 — Interpret results:
- Connected + shell prompt received → Vulnerable: The endpoint is unprotected and functional
- Connection refused → Terminal feature may be disabled or port is incorrect
- HTTP 401 / 403 returned → Likely patched (authenticated endpoint)
- Connection closed immediately → Terminal mode may be off; try
--terminalflag when launching Marimo
Step 4 — Check for signs of prior exploitation:
# Look for suspicious processes spawned by marimo
ps aux | grep -E "(nc |ncat|curl.*pipe|bash -i|python.*socket)"
# Check for NKAbuse indicators
find / -name "*.nkn" -o -name "nkabuse" 2>/dev/null
ls -la ~/.config/ ~/.aws/ ~/.gcp/ # Check access/modification timestamps
# Review shell history if accessible
cat ~/.bash_history | grep -E "(curl|wget|chmod \+x|/tmp/)"
Automated Scanning
Nuclei template (community-contributed):
nuclei -u http://target:2718 -t cves/2026/CVE-2026-39987.yaml
Shodan/Censys search for exposed instances:
shodan search 'http.title:"marimo" port:2718'
censys search 'services.http.response.html_title="marimo"'
Python-based automated check:
import asyncio
import websockets
async def check_cve_2026_39987(host, port=2718):
uri = f"ws://{host}:{port}/terminal/ws"
try:
async with websockets.connect(uri, open_timeout=5) as ws:
# If we connect without error, endpoint is unprotected
print(f"[VULNERABLE] {host}:{port} — /terminal/ws accepts unauthenticated connections")
await ws.close()
return True
except Exception as e:
print(f"[NOT VULNERABLE or UNREACHABLE] {host}:{port} — {e}")
return False
asyncio.run(check_cve_2026_39987("your-target-host"))
Code Review Checklist
When auditing Marimo deployments or forks:
- Confirm
/terminal/wshandler callsvalidate_auth()before accepting the connection - Verify all WebSocket route handlers follow the same auth pattern as
/ws - Check
--terminalflag is not enabled for production/public-facing deployments - Confirm
--hostis not set to0.0.0.0unless behind an authenticated reverse proxy - Review server startup scripts and
docker-compose.ymlfor exposed ports and auth settings
4. How to Fix It (Mitigation)
Step-by-Step Remediation
1. Upgrade Marimo immediately:
pip install --upgrade "marimo>=0.23.0"
# Verify the upgrade
pip show marimo | grep Version
# Expected: Version: 0.23.0 (or higher)
2. Verify the fix is in place by re-running the detection test:
wscat -c ws://localhost:2718/terminal/ws
# Should now return: Unauthorized / connection rejected
3. If immediate upgrade is not possible — disable terminal access:
# Start Marimo without the --terminal flag (default behavior; do NOT pass --terminal)
marimo edit notebook.py # No --terminal flag
# Or if using a config file, set:
# terminal: false (in marimo.toml)
4. Restrict network access at the firewall/proxy level:
# Block WebSocket upgrade requests to /terminal/ws at your nginx/Apache reverse proxy
# nginx example:
location /terminal/ws {
return 403;
}
# Or restrict Marimo to localhost only
marimo edit notebook.py --host 127.0.0.1
5. Rotate all credentials potentially exposed on affected systems:
# Identify what credentials were accessible
ls ~/.aws/credentials ~/.gcp/application_default_credentials.json
env | grep -E "(API_KEY|SECRET|TOKEN|PASSWORD|OPENAI|ANTHROPIC|AWS|AZURE|GOOGLE)"
cat .env 2>/dev/null
# Rotate everything found:
# - AWS: aws iam create-access-key, then delete old key
# - OpenAI/Anthropic/Google AI: regenerate API keys in provider console
# - Database passwords, OAuth tokens: rotate in respective services
6. Scan for NKAbuse and other malware indicators:
# Check for NKAbuse persistence mechanisms
crontab -l
ls -la ~/.config/systemd/user/ /etc/systemd/system/ /etc/cron.d/
# Look for unusual network connections
ss -tulnp | grep -v "127\.\|::1"
netstat -an | grep ESTABLISHED
Code Fix Example
The patch in Marimo 0.23.0 adds validate_auth() to the terminal WebSocket handler:
# BEFORE (vulnerable, <= 0.20.4):
@router.websocket("/terminal/ws")
async def terminal_ws(websocket: WebSocket):
if not _terminal_supported():
await websocket.close()
return
await websocket.accept()
await start_pty_session(websocket)
# AFTER (patched, >= 0.23.0):
@router.websocket("/terminal/ws")
async def terminal_ws(websocket: WebSocket):
if not _terminal_supported():
await websocket.close()
return
await validate_auth(websocket) # ✅ Authentication now enforced
await websocket.accept()
await start_pty_session(websocket)
Configuration Hardening
Even on patched versions, apply these settings to reduce attack surface:
# marimo.toml
[server]
host = "127.0.0.1" # Never bind to 0.0.0.0 for internet-facing servers
terminal = false # Disable terminal unless explicitly needed
token = true # Always require authentication token
5. How to Test the Fix (Validation)
Regression Test Scenarios
Scenario A — Unauthenticated WebSocket connection is rejected (core fix validation):
wscat -c ws://localhost:2718/terminal/ws
# Expected after fix: HTTP 401 Unauthorized or immediate close
# Fail if: shell prompt received
Scenario B — Authenticated connection still works (no functionality broken):
# Connect with valid auth token (if terminal feature intentionally enabled)
wscat -c "ws://localhost:2718/terminal/ws" -H "Authorization: Bearer <valid-token>"
# Expected: Shell session established
Scenario C — Other WebSocket endpoints unaffected:
# Notebook websocket should still work with auth
wscat -c "ws://localhost:2718/ws?session_id=test" -H "Authorization: Bearer <valid-token>"
# Expected: Normal notebook communication
Security Test Cases
Test Case 1: Verify /terminal/ws requires authentication
- Precondition: Upgrade to Marimo >= 0.23.0; start server normally
- Steps: Attempt WebSocket connection to
/terminal/wswithout any auth header or token - Expected Result: Connection rejected with HTTP 401 or 403; no shell is granted
Test Case 2: Verify attacker cannot execute OS commands
- Precondition: Upgrade to Marimo >= 0.23.0
- Steps: Using the PoC script above, attempt unauthenticated connection and issue
id; whoami - Expected Result: Connection fails before any commands can be sent
Test Case 3: Verify credential files are no longer readable by unauthenticated clients
- Precondition: Upgrade to Marimo >= 0.23.0
- Steps: Attempt to read
~/.aws/credentialsvia unauthenticated terminal session - Expected Result: No terminal session established; file remains inaccessible
Automated Tests
import pytest
import asyncio
import websockets
MARIMO_URL = "ws://localhost:2718"
@pytest.mark.asyncio
async def test_terminal_ws_requires_auth():
"""CVE-2026-39987: /terminal/ws must reject unauthenticated connections."""
try:
async with websockets.connect(
f"{MARIMO_URL}/terminal/ws",
open_timeout=3,
additional_headers={} # No auth headers
) as ws:
# If we get here, the endpoint accepted us — this is the vulnerability
await ws.close()
pytest.fail(
"VULNERABLE: /terminal/ws accepted unauthenticated connection. "
"Upgrade to Marimo >= 0.23.0 immediately."
)
except websockets.exceptions.InvalidStatusCode as e:
# 401 or 403 means auth is enforced — this is the expected behavior
assert e.status_code in (401, 403), f"Unexpected status: {e.status_code}"
except Exception:
pass # Connection refused / timeout also acceptable (endpoint disabled)
6. Prevention & Hardening
Best Practices
Practice 1 — Never expose development tooling to the internet. Marimo, Jupyter, and similar notebook servers are designed for local or VPN-only access. If you must expose them publicly, place them behind an authenticated reverse proxy (e.g., nginx with OAuth2 Proxy or Cloudflare Access) rather than relying solely on built-in auth.
Practice 2 — Apply the principle of least privilege. Run Marimo as a dedicated low-privilege user, not as root or your personal user account. This limits the blast radius if exploitation occurs:
useradd -m -s /bin/bash marimo-svc
sudo -u marimo-svc marimo edit notebook.py
Practice 3 — Disable features you don't use. The terminal feature is not needed for most notebook workflows. Disable it explicitly and only enable it when actively needed:
marimo edit notebook.py # No --terminal flag by default
Practice 4 — Secret management hygiene. Never hardcode API keys or cloud credentials in notebooks or .env files on servers running Marimo. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager) and inject credentials at runtime.
Practice 5 — Keep dependencies current. Subscribe to security advisories for every tool in your AI/ML toolchain. The Marimo GitHub advisory feed and PyPI security advisories are good starting points.
Monitoring & Detection
Set up alerting for suspicious activity around notebook servers:
# Detect unexpected outbound connections from the Marimo process
# Using auditd (Linux):
auditctl -a always,exit -F arch=b64 -S connect -k network_connect_marimo
# Monitor /terminal/ws access in nginx logs
tail -f /var/log/nginx/access.log | grep "/terminal/ws"
# Alert on new processes spawned by marimo (parent PID detection)
# Using falco rule:
# - rule: Marimo Terminal Shell Spawned
# condition: spawned_process and proc.pname = "marimo" and proc.name in (shells)
# output: "Shell spawned from Marimo process (pid=%proc.pid user=%user.name)"
# priority: CRITICAL
Key indicators of compromise (IoCs) to monitor:
- WebSocket connections to
/terminal/wsfrom unexpected IP ranges - Outbound connections from the Marimo host to unfamiliar IPs (especially on NKN ports)
- New files written to
/tmp,/var/tmp, or user home directories by the Marimo process - Sudden reads of credential files (
~/.aws/credentials,.env) by the Marimo process - New cron jobs or systemd services created after Marimo startup
References
- CVE Entry: CVE-2026-39987 — NVD
- Original Research: Root in One Request: Marimo's Critical Pre-Auth RCE — Endor Labs
- Active Exploitation Report: Marimo RCE Flaw Exploited Within 10 Hours of Disclosure — The Hacker News
- BleepingComputer Coverage: Critical Marimo pre-auth RCE flaw now under active exploitation
- Cloud Security Alliance Analysis: Marimo Pre-Auth RCE: AI Development Toolchain Under Attack
- Resecurity Technical Writeup: Marimo Pre-Auth RCE via Unauthenticated WebSocket Terminal
- Patch & Release Notes: Marimo GitHub Releases
- SentinelOne Vulnerability DB: CVE-2026-39987 — SentinelOne