← Back to Feed CACHED · 2026-09-29 20:50:08 · CACHE_KEY CVE-2026-39987
CVE-2026-39987 · CWE-306 · Disclosed 2026-04-09

marimo Pre-Auth RCE via Unauthenticated Terminal WebSocket Endpoint

ASSESSED — NOISGATE
Vendor
—
—
—
Reassessed
—
—
—
Verdict: —
Do you agree?
01 · The Real Story

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.

"Pre-auth root shell via one WebSocket call. KEV-listed. Patch or die."
02 · The Attack Path

5 steps from start to impact.

STEP 01

Discover exposed marimo instance

The attacker scans for internet-facing marimo instances on default port 2718 using Shodan, Censys, FOFA, or direct port scans. Endor Labs identified 186 internet-reachable marimo base URLs in their assessment. Many are hosted on GPU cloud instances (CoreWeave, Coiled, AWS) and Docker containers with default configurations.
Conditions required:
  • Target must be running marimo in edit mode
  • marimo port (default 2718) must be reachable from attacker's network
Where this breaks in practice:
  • 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
Detection/coverage: Shodan dork for marimo HTTP headers; nuclei template by @ritikchaddha; Nmap NSE script in keraattin/CVE-2026-39987 repo
STEP 02

Connect to unauthenticated /terminal/ws

The attacker opens a WebSocket connection to 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).
Conditions required:
  • Network connectivity to target on port 2718
  • marimo running in edit mode (not read-only)
Where this breaks in practice:
  • None — this is a zero-click, zero-credential attack
  • Read-only mode deployments (marimo run) do not expose this endpoint
Detection/coverage: WAF/reverse proxy logs showing WebSocket Upgrade to /terminal/ws; Sysdig observed structured marker strings (---POC-START---, ---POC-END---) in early attacks
STEP 03

Obtain full PTY root shell

Upon WebSocket accept, marimo calls 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.
Conditions required:
  • Successful WebSocket connection from step 2
Where this breaks in practice:
  • 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
Detection/coverage: Process execution telemetry (EDR/auditd) for unexpected shell spawns from the marimo process; look for pty.fork() child processes
STEP 04

Harvest credentials and cloud keys

The attacker reads .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.
Conditions required:
  • Credentials stored on the host (extremely common in ML/AI workloads)
Where this breaks in practice:
  • 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
Detection/coverage: CloudTrail anomalies for unexpected API calls from the host's IAM role; file-access auditing on .env and credential stores
STEP 05

Lateral movement and data exfiltration

Using harvested credentials, the attacker pivots to internal infrastructure. Field Effect documented eight parallel SSH sessions to a downstream bastion server, followed by full PostgreSQL database exfiltration in under 2 minutes. The NKAbuse campaign deployed blockchain-based C2 malware for persistent access. The LLM-agent-driven attack completed the entire chain — from initial access to database exfil — in under one hour.
Conditions required:
  • Harvested credentials grant access to other systems
  • Network connectivity from the marimo host to internal targets
Where this breaks in practice:
  • Network segmentation and zero-trust architectures limit lateral movement
  • Credential rotation shortens the window of opportunity
Detection/coverage: Anomalous SSH sessions, unusual database dump operations, outbound traffic to Cloudflare Workers IPs or NKN blockchain nodes; IoC IP 49.207.56.74 and 38.147.173.172
03 · Compensating Control

1
CRITICAL 9.6→LOW 2.0
SEVERITY REDUCED
Block /terminal/ws at the reverse proxy immediately — Configure Nginx, Caddy, or your cloud load balancer to reject WebSocket Upgrade requests to /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; }
2
CRITICAL 9.6→LOW 1.5
SEVERITY REDUCED
Place marimo behind VPN or authenticated reverse proxy — marimo should never be directly internet-facing. Move it behind a VPN (WireGuard, Tailscale, corporate VPN) or an authenticating reverse proxy (oauth2-proxy, Cloudflare Access, AWS ALB with Cognito). This eliminates the network-accessible attack vector entirely. Deploy within 3 days.
3
CRITICAL 9.6→IGNORE 0.0
SEVERITY REDUCED
Upgrade to marimo ≥ 0.23.0 — The definitive fix. Run 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.
4
CRITICAL 9.6→HIGH 7.5
SEVERITY REDUCED
Run marimo containers as non-root — Modify Dockerfiles to use a non-root user (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.
5
CRITICAL 9.6→MEDIUM 5.0
SEVERITY REDUCED
Rotate all credentials on previously exposed hosts — If marimo was ever internet-facing, assume compromise. Rotate all AWS keys, SSH keys, API tokens, and database credentials accessible from the host. Revoke IAM sessions. Check CloudTrail for unauthorized API calls from the host's role. Complete within 24 hours of discovering exposure.
What doesn't work
  • marimo's built-in auth token — the vulnerability exists precisely because /terminal/ws bypasses 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.
04 · Intelligence Metadata

The supporting signals.

In-the-Wild StatusActively 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 StatusAdded April 23, 2026. FCEB remediation deadline: May 7, 2026.
PoC AvailabilityWeaponized. 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.
EPSS0.37865 — 99th+ percentile. Extremely high predicted exploitation probability.
CVSS VectorCVSS: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 VersionsAll marimo releases ≤ 0.20.4. The vulnerable code is in marimo/_server/api/endpoints/terminal.py.
Fixed Versionsmarimo 0.23.0+ (pip install --upgrade "marimo>=0.23.0"). No known distro backports — marimo is pip-installed.
Exposure DataEndor 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 DateAdvisory published April 8, 2026 (GitHub Security Advisory GHSA-2679-6mx9-h9xc).
Reporting Researcherq1uf3ng (original disclosure). Exploitation analysis by Sysdig Threat Research Team and Field Effect.

Sources.

  1. GitHub Security Advisory GHSA-2679-6mx9-h9xc
  2. Sysdig — Marimo RCE: From Disclosure to Exploitation in Under 10 Hours
  3. The Hacker News — Marimo RCE Flaw Exploited Within 10 Hours
  4. Field Effect — Marimo Flaw Exploited in LLM-Agent-Driven Intrusion
  5. Endor Labs — Root in One Request: Marimo's Critical Pre-Auth RCE
  6. Tevora Threat Advisory — Unauthenticated RCE in Marimo
  7. GitHub Advisory Database — CVE-2026-39987
  8. NVD — CVE-2026-39987
05 · The Call

Final Verdict
= UNCHANGED to CRITICAL (9.6/10)

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 .env secrets 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.

06 · Verification

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.

noisgate-verify.sh
BASHREAD-ONLYSAFE
#!/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