Like leaving a root terminal open on a public kiosk, except the kiosk is on the internet and anyone with a browser can sit down
CVE-2026-39987 is a missing-authentication flaw (CWE-306) in marimo, an open-source reactive Python notebook popular in data-science and AI/ML workflows. The vulnerable endpoint is /terminal/ws, a WebSocket route that spawns a full PTY shell on connection. While other marimo WebSocket endpoints (e.g. /ws) correctly call validate_auth(), the terminal endpoint only checks whether edit mode is active and whether the OS supports PTY — then calls websocket.accept() and pty.fork() with zero credential verification. Any remote user who can reach port 2718 gets an interactive root shell in a single request. All marimo versions through 0.20.4 are affected; the fix landed in 0.23.0. Default Docker images run marimo as root, amplifying impact to full host compromise.
The vendor-assigned CVSS 3.1 score of 9.8 is honest and, if anything, conservative in context. This is a textbook pre-auth RCE with no complexity barriers, no user interaction, and maximum CIA impact. Sysdig documented the first in-the-wild exploitation 9 hours 41 minutes after the advisory dropped — before any public PoC existed — with credential theft completed in under 3 minutes. A second campaign deployed NKAbuse malware via Hugging Face Spaces, and Field Effect documented an LLM-agent-driven intrusion that achieved full database exfiltration in under one hour. CISA added CVE-2026-39987 to the KEV catalog on April 23, 2026. The vendor score matches reality; noisgate holds it at CRITICAL.
5 steps from start to impact.
Discover exposed marimo instance
- Target must be running marimo in edit mode
- marimo port (default 2718) must be reachable from attacker's network
- marimo is a niche data-science tool — most enterprises don't run it at all
- Responsible deployments bind to localhost or sit behind VPN/reverse proxy
- Only ~186 instances found internet-reachable in Endor Labs scan
Connect to unauthenticated /terminal/ws
ws://<target>:2718/terminal/ws. No authentication token, cookie, or header is validated — even if marimo's built-in auth is enabled, the terminal endpoint never calls validate_auth(). The connection is accepted immediately. Tools: websocat, any Python websockets client, or the multiple public PoC scripts (Nxploited, mfahdk, M3PH1569, fevar54, gbuyssens repos).- Network connectivity to target on port 2718
- marimo running in edit mode (not read-only)
- None — this is a zero-click, zero-credential attack
- Read-only mode deployments (
marimo run) do not expose this endpoint
/terminal/ws; Sysdig observed structured marker strings (---POC-START---, ---POC-END---) in early attacksObtain full PTY root shell
pty.fork() and hands the attacker a fully interactive pseudo-terminal. In default Docker images, the marimo process runs as root, granting immediate root-level command execution. The attacker runs id, whoami, explores the filesystem, and enumerates the environment.- Successful WebSocket connection from step 2
- Container images run as root by default — this is the common case, not the exception
- Non-root deployments limit blast radius but still grant process-level access
pty.fork() child processesHarvest credentials and cloud keys
.env files, ~/.aws/credentials, ~/.ssh/id_rsa, and environment variables. Sysdig observed attackers extract AWS_ACCESS_KEY_ID and AWS_DEFAULT_REGION from .env within 3 minutes. The Field Effect case documented retrieval of an SSH private key from AWS Secrets Manager using harvested cloud credentials.- Credentials stored on the host (extremely common in ML/AI workloads)
- Credential-free hosts exist but are rare in ML/data-science environments
- Secret managers help only if the marimo process doesn't have ambient access
.env and credential storesLateral movement and data exfiltration
- Harvested credentials grant access to other systems
- Network connectivity from the marimo host to internal targets
- Network segmentation and zero-trust architectures limit lateral movement
- Credential rotation shortens the window of opportunity
/terminal/ws. This breaks the attack chain at step 2 with zero application changes. Deploy within the noisgate mitigation SLA of ≤3 days (CRITICAL). Example Nginx: location /terminal/ws { return 403; }pip install --upgrade "marimo>=0.23.0". Version 0.23.0 adds validate_auth() to the terminal WebSocket endpoint. This is the noisgate remediation SLA action — complete within 90 days for CRITICAL, but given KEV status and active exploitation, treat as immediate.USER 1000). This limits post-exploitation to process-level privileges, preventing direct root access to the host and reducing credential exposure scope. Deploy within 3 days alongside proxy controls.- marimo's built-in auth token — the vulnerability exists precisely because
/terminal/wsbypasses marimo's own authentication. Enabling auth in marimo's config does nothing to protect this endpoint. - HTTP WAF rules — the exploit uses a WebSocket Upgrade handshake. Many WAFs pass WebSocket traffic uninspected or only examine the initial HTTP Upgrade request, not subsequent frames. A WAF that doesn't understand WebSocket framing will miss the attack entirely.
- Rate limiting — a single WebSocket connection is sufficient for full exploitation. Rate limiting connection attempts is trivially bypassed and the attacker only needs one successful handshake.
- Network ACLs on port 443 alone — marimo defaults to port 2718. If your firewall rules only cover standard ports, the marimo listener is unprotected.
The supporting signals.
| In-the-Wild Status | Actively exploited. First exploitation 9h41m after disclosure (April 9, 2026). 662 exploit events recorded April 11-14 across 11 IPs / 10 countries (Sysdig). NKAbuse malware campaign and LLM-agent-driven intrusion documented (Field Effect, May 2026). |
|---|---|
| KEV Status | Added April 23, 2026. FCEB remediation deadline: May 7, 2026. |
| PoC Availability | Weaponized. Multiple public repos: Nxploited/CVE-2026-39987 (Python mass scanner + shell), mfahdk/CVE-2026-39987_RCE_PoC, M3PH1569/CVE-2026-39987-POC (Python), gbuyssens/CVE-2026-39987 (Go), fevar54/marimo_CVE-2026-39987_RCE_PoC, keraattin/CVE-2026-39987 (Python + Nmap NSE). Nuclei template by @ritikchaddha. Reported by q1uf3ng. |
| EPSS | 0.37865 — 99th+ percentile. Extremely high predicted exploitation probability. |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8. Every metric is worst-case: network-accessible, low complexity, no privileges, no interaction, full CIA impact. CVSS v4.0 score: 9.3. Both scores are accurate. |
| Affected Versions | All marimo releases ≤ 0.20.4. The vulnerable code is in marimo/_server/api/endpoints/terminal.py. |
| Fixed Versions | marimo 0.23.0+ (pip install --upgrade "marimo>=0.23.0"). No known distro backports — marimo is pip-installed. |
| Exposure Data | Endor Labs passive scan found 186 internet-reachable marimo instances, 30 (~16%) accepted unauthenticated terminal WebSocket handshakes. Default port 2718. Common on GPU cloud (CoreWeave, Coiled, AWS). Many run as root in Docker. |
| Disclosure Date | Advisory published April 8, 2026 (GitHub Security Advisory GHSA-2679-6mx9-h9xc). |
| Reporting Researcher | q1uf3ng (original disclosure). Exploitation analysis by Sysdig Threat Research Team and Field Effect. |
Sources.
- GitHub Security Advisory GHSA-2679-6mx9-h9xc
- Sysdig — Marimo RCE: From Disclosure to Exploitation in Under 10 Hours
- The Hacker News — Marimo RCE Flaw Exploited Within 10 Hours
- Field Effect — Marimo Flaw Exploited in LLM-Agent-Driven Intrusion
- Endor Labs — Root in One Request: Marimo's Critical Pre-Auth RCE
- Tevora Threat Advisory — Unauthenticated RCE in Marimo
- GitHub Advisory Database — CVE-2026-39987
- NVD — CVE-2026-39987
Why this verdict
- Zero-friction pre-auth RCE: The exploit requires exactly one WebSocket connection — no credentials, no user interaction, no chaining, no memory corruption. The advisory text alone was sufficient for weaponization within 10 hours; no reverse engineering needed.
- Confirmed mass exploitation and KEV listing: 662 exploit events in 4 days, NKAbuse malware deployment, and an LLM-agent-driven intrusion achieving full DB exfiltration in under 1 hour. CISA KEV-listed April 23, 2026. This is not theoretical.
- Default root execution amplifies impact: Default Docker images run marimo as root. The attacker goes from zero to root shell in one request. Cloud credentials, SSH keys, and
.envsecrets are routinely co-located on ML/AI workstations. - Role multiplier: marimo occupies a CI/CD-adjacent and data-pipeline role in AI/ML workflows. While not a canonical high-value infrastructure component (it's not a domain controller or hypervisor), compromised marimo instances have demonstrated cloud account takeover (AWS credential theft → Secrets Manager → bastion → production DB) and supply-chain pivot (NKAbuse delivered via Hugging Face Spaces). In the Field Effect case, a single marimo instance yielded full production database exfiltration. The blast radius is host → cloud tenant → production data tier. For organizations running marimo on cloud GPU instances with ambient IAM roles, this is functionally equivalent to compromising a CI runner with production credentials.
- Small but fully exposed attack surface: Only ~186 instances found internet-reachable, but 16% of those were immediately exploitable. The population is small, but for any org in it, the severity is maximum. This is a niche-but-lethal pattern — like a hydraulic press with no guard: few people stand near it, but those who do lose limbs.
- Minor score adjustment (9.8 → 9.6): The installed base is genuinely small compared to enterprise infrastructure software. Most enterprises do not run marimo at all. This prevents a blanket 9.8 across all environments but does not change the severity bucket.
Why not higher?
The score cannot go higher than CRITICAL. The 0.2-point reduction from 9.8 to 9.6 reflects that marimo is a niche developer tool with a small internet-exposed footprint (~186 instances), not a ubiquitous enterprise component. For organizations that do run marimo, the per-instance severity is maximum.
Why not lower?
Every friction-lowering argument collapses on contact with the evidence. The exploit is trivially weaponized (advisory text alone sufficed), actively exploited at scale (662 events in 4 days), KEV-listed, delivers root shells by default, and has produced documented real-world compromises including cloud account takeover and production database exfiltration. There is no authentication barrier, no complexity barrier, and no user-interaction requirement. Downgrading below CRITICAL would be malpractice for any organization that has marimo in its environment.
Crowdsourced verification payload.
Run this script on each host where marimo may be installed (or from an auditor workstation against remote targets). Invoke as bash check_cve_2026_39987.sh [optional_target_host:port]. No special privileges required for the local version check; the remote WebSocket probe requires network access to the target.
#!/usr/bin/env bash
# check_cve_2026_39987.sh — Detect CVE-2026-39987 (marimo Pre-Auth RCE)
# Usage: bash check_cve_2026_39987.sh [target_host:port]
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
FIXED_VERSION="0.23.0"
TARGET="${1:-}"
RESULT="UNKNOWN"
version_gte() {
printf '%s\n%s' "$1" "$2" | sort -V | head -n1 | grep -qx "$2"
}
# --- Local version check ---
if command -v marimo &>/dev/null; then
INSTALLED=$(marimo --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
if [ -n "$INSTALLED" ]; then
echo "[*] Installed marimo version: $INSTALLED"
if version_gte "$INSTALLED" "$FIXED_VERSION"; then
echo -e "${GREEN}[PATCHED]${NC} marimo $INSTALLED >= $FIXED_VERSION"
RESULT="PATCHED"
else
echo -e "${RED}[VULNERABLE]${NC} marimo $INSTALLED < $FIXED_VERSION"
RESULT="VULNERABLE"
fi
fi
elif pip show marimo &>/dev/null 2>&1; then
INSTALLED=$(pip show marimo 2>/dev/null | grep -i '^Version:' | awk '{print $2}')
if [ -n "$INSTALLED" ]; then
echo "[*] Installed marimo version (pip): $INSTALLED"
if version_gte "$INSTALLED" "$FIXED_VERSION"; then
echo -e "${GREEN}[PATCHED]${NC} marimo $INSTALLED >= $FIXED_VERSION"
RESULT="PATCHED"
else
echo -e "${RED}[VULNERABLE]${NC} marimo $INSTALLED < $FIXED_VERSION"
RESULT="VULNERABLE"
fi
fi
else
echo "[*] marimo not found locally."
fi
# --- Remote WebSocket probe (non-destructive) ---
if [ -n "$TARGET" ]; then
echo "[*] Probing remote target: $TARGET"
if command -v curl &>/dev/null; then
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
--max-time 5 \
-H 'Upgrade: websocket' \
-H 'Connection: Upgrade' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
-H 'Sec-WebSocket-Version: 13' \
"http://${TARGET}/terminal/ws" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "101" ]; then
echo -e "${RED}[VULNERABLE]${NC} Target accepted WebSocket upgrade on /terminal/ws (HTTP 101)"
RESULT="VULNERABLE"
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "401" ]; then
echo -e "${GREEN}[PATCHED]${NC} Target rejected /terminal/ws (HTTP $HTTP_CODE)"
[ "$RESULT" != "VULNERABLE" ] && RESULT="PATCHED"
elif [ "$HTTP_CODE" = "000" ]; then
echo -e "${YELLOW}[UNKNOWN]${NC} Target unreachable or timed out"
else
echo -e "${YELLOW}[UNKNOWN]${NC} Unexpected HTTP $HTTP_CODE from /terminal/ws"
fi
else
echo "[!] curl not available — skipping remote probe"
fi
fi
echo ""
echo "=== RESULT: $RESULT ==="
case "$RESULT" in
PATCHED) exit 0 ;;
VULNERABLE) exit 1 ;;
*) exit 2 ;;
esac