Vulnerability Analysis | Mitigation Guide | Security Testing

CVE-2026-39987: Marimo Pre-Auth RCE — Root in One WebSocket Request

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 .env files 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.1 localhost 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 --terminal flag 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/ws handler calls validate_auth() before accepting the connection
  • Verify all WebSocket route handlers follow the same auth pattern as /ws
  • Check --terminal flag is not enabled for production/public-facing deployments
  • Confirm --host is not set to 0.0.0.0 unless behind an authenticated reverse proxy
  • Review server startup scripts and docker-compose.yml for 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/ws without 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/credentials via 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/ws from 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

Latest from the blog

See all →