Skip to content

CVE-2026-39987: Verified Reproduction

CVE-2026-39987: marimo Pre-Auth RCE via Terminal WebSocket Authentication Bypass /terminal/ws missing validate auth

CVE-2026-39987 is verified against marimo · github. Affected versions: <0.23.0. Fixed in 0.23.0. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00316.

REPRO-2026-00316 marimo · github RCE Jul 30, 2026 CVE entry ↗ .txt
Severity
CRITICAL
Confidence
HIGH
Reproduced in
24m 19s
Tool calls
134
Spend
$2.29
01 · Overview

What Is CVE-2026-39987?

CVE-2026-39987 is a critical-severity RCE vulnerability affecting marimo <0.23.0. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00316).

02 · Severity & CVSS

CVE-2026-39987 Severity

CVE-2026-39987 is rated critical severity.

CRITICAL threat level
Weakness CWE-306 — Missing Authentication for Critical Function

Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.

03 · Affected Versions

Affected marimo Versions

marimo · github versions <0.23.0 are affected.

How to Reproduce CVE-2026-39987

$ pruva-verify REPRO-2026-00316
or curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00316/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-39987

Remote code execution — reproduced
  • reached the target end-to-end
  • full exploit chain demonstrated
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

unauthenticated WebSocket upgrade to /terminal/ws followed by attacker-supplied shell command text written to the spawned PTY

Attack chain
  1. marimo edit server (starlette/uvicorn)
  2. GET /terminal/ws WebSocket endpoint
  3. websocket_endpoint() accepts without validate_auth
  4. pty.fork() interactive shell
Runnable proof: reproduction_steps.sh
Captured evidence: fixed unauth attempt1fixed unauth attempt2fixed auth control
How the agent worked 292 events · 134 tool calls · 24 min
24 minDuration
134Tool calls
54Reasoning steps
292Events
7Dead-ends
Agent activity over 24 min
Policy
1
Support
11
Repro
123
Judge
28
Variant
124
Verify
1
0:0024:19

Root Cause and Exploit Chain for CVE-2026-39987

Versions: all versions < 0.23.0 (verified on 0.22.5).

marimo's interactive terminal WebSocket endpoint (/terminal/ws) completely skipped authentication validation. marimo relies on Starlette's AuthenticationMiddleware, which only marks failed-auth connections as UnauthenticatedUser without actively rejecting WebSocket connections; real enforcement depends on endpoint-level checks. While the main /ws endpoint validates credentials, /terminal/ws had neither a @requires("edit") decorator nor a validate_auth() call, so an unauthenticated attacker could open a WebSocket and be handed a full interactive PTY shell running with the privileges of the marimo process — pre-authentication remote code execution.

  • Package/component: marimo (Python notebook server), marimo/_server/api/endpoints/terminal.py/terminal/ws WebSocket endpoint.
  • Affected versions: all versions < 0.23.0 (verified on 0.22.5).
  • Risk level: Critical (CVSS 9.3, EPSS 0.953, CISA KEV 2026-04-23, exploited in the wild). Consequences: unauthenticated remote attacker obtains an interactive OS shell with the marimo process's privileges (frequently root in Docker deployments), enabling reconnaissance, credential theft (e.g. .env cloud keys), lateral movement, and full host compromise.

Impact Parity

  • Disclosed/claimed maximum impact: pre-authentication remote code execution (interactive PTY shell, arbitrary OS commands).
  • Reproduced impact from this run: identical — from a raw, credential-less WebSocket client we obtained a PTY shell and executed arbitrary commands (echo PRUVA_VULN_A<n>_$(id -u)_$(id -un)), observing marker output PRUVA_VULN_A1_1000_vscode proving execution as the marimo server user (uid 1000).
  • Parity: full.
  • Not demonstrated: nothing material — the claim is fully reproduced, including the fixed-version negative control.

Root Cause

marimo/_server/api/endpoints/terminal.py::websocket_endpoint (vulnerable code at fix-commit parent c24d4806398f30be6b12acd6c60d1d7c68cfd12a^) performed only two checks before websocket.accept() and pty.fork():

  1. app_state.mode != SessionMode.EDIT → close.
  2. supports_terminal() → close.

There was no authentication check. Because Starlette's AuthenticationMiddleware does not reject unauthenticated WebSocket upgrades (it only attaches an UnauthenticatedUser), the absence of an explicit validate_auth(websocket) call meant anyone could reach the PTY-spawning code.

Fix commit c24d4806398f30be6b12acd6c60d1d7c68cfd12a (PR #9098, released in 0.23.0) adds exactly:

from marimo._server.api.auth import validate_auth
...
if app_state.enable_auth and not validate_auth(websocket):
    await websocket.close(WebSocketCodes.UNAUTHORIZED, "MARIMO_UNAUTHORIZED")
    return

aligning /terminal/ws with the auth validation used by the other WebSocket endpoints. Verified in this run: the patch hunk exists at the fixed commit, the parent commit lacks it, the installed 0.22.5 package lacks validate_auth in terminal.py, and the installed 0.23.0 package contains it.

Reproduction Steps

  1. bundle/repro/reproduction_steps.sh (helper: bundle/repro/ws_exploit_client.py).
  2. The script:
    • clones/uses the marimo source checkout and verifies the fix patch hunk;
    • creates two virtualenvs: marimo==0.22.5 (vulnerable) and marimo==0.23.0 (fixed);
    • starts each real server with token auth enabled (marimo edit --headless --token-password topsecretpw);
    • proves the HTTP auth gate is active (unauthenticated / → HTTP 303 login redirect);
    • as an unauthenticated attacker, opens a raw WebSocket to /terminal/ws (no token/cookie/header) and sends a shell command — twice against the vulnerable build (both yield PTY output with the unique marker) and twice against the fixed build (both rejected with HTTP 403 during the WS upgrade);
    • runs an authenticated positive control on the fixed build (valid access_token → terminal works), proving the fix blocks only unauthenticated access;
    • writes bundle/repro/runtime_manifest.json.
  3. Expected evidence: vulnerable attempts print RCE_CONFIRMED with marker PRUVA_VULN_A<n>_<uid>_<user> in PTY output; fixed attempts print CONNECT_FAILED: InvalidStatus: server rejected WebSocket connection: HTTP 403.

Evidence

  • bundle/logs/reproduction_steps.log — full run transcript (verdict line: vuln RCE attempts OK=2/2, fixed rejects OK=2/2, fixed auth control=1).

  • bundle/logs/server_vuln.log, bundle/logs/server_fixed.log — server startup showing token auth (URL: http://localhost:2718?access_token=topsecretpw).

  • bundle/logs/vuln_unauth_attempt1.log / ...attempt2.log — key excerpt:

    echo PRUVA_VULN_A1_$(id -u)_$(id -un)
    vscode ➜ /tmp $ echo PRUVA_VULN_A1_$(id -u)_$(id -un)
    PRUVA_VULN_A1_1000_vscode
    RESULT: RCE_CONFIRMED marker observed in PTY output
    

    (unauthenticated WS accepted → interactive shell → arbitrary command executed as uid 1000 vscode, the marimo process user).

  • bundle/logs/fixed_unauth_attempt1.log / ...attempt2.logCONNECT_FAILED: InvalidStatus: server rejected WebSocket connection: HTTP 403.

  • bundle/logs/fixed_auth_control.log — authenticated request on the fixed build still obtains the terminal (PRUVA_FIXED_AUTH_1000_vscode).

  • bundle/logs/patch_hunk.txt — the added validate_auth lines from the fix commit.

  • bundle/repro/runtime_manifest.jsonentrypoint_kind=endpoint, service_started=true, healthcheck_passed=true, target_path_reached=true.

  • Environment: Python 3.14.4, pip-installed marimo==0.22.5 / marimo==0.23.0, websockets client library, Linux x86_64. Script verified idempotent by two consecutive successful runs (exit 0 both times).

Recommendations / Next Steps

  • Upgrade to marimo ≥ 0.23.0 immediately (fix: PR #9098 / commit c24d4806398f30be6b12acd6c60d1d7c68cfd12a).
  • Fix approach (already upstream): call validate_auth(websocket) and close with WebSocketCodes.UNAUTHORIZED before websocket.accept() whenever enable_auth is true — for every WebSocket endpoint, not just /ws.
  • Defense in depth: never expose marimo edit to untrusted networks; put it behind an authenticating reverse proxy; run it as an unprivileged user; audit any deployment that ran < 0.23.0 with a reachable port for compromise (unexpected PTY child processes, shell history, .env access).
  • Testing: add a regression test asserting unauthenticated /terminal/ws upgrades are rejected (upstream added one in tests/_server/api/endpoints/test_terminal.py).

Additional Notes

  • Idempotency: the script is fully self-contained (installs its own venvs, manages server lifecycle with bounded waits and process-group cleanup) and passed twice consecutively with exit 0.
  • Edge cases: the vulnerability requires edit mode (SessionMode.EDIT, i.e. marimo edit, the default) and a POSIX platform with pty support — both are the standard deployment shape. Auth must be enabled (non-empty token), which is marimo's default when a token is generated or --token-password is set; with auth disabled the impact is identical but by design.
  • The advisory body text ("<= 0.20.4") understates the range; the structured range < 0.23.0 is correct — 0.22.5 was confirmed vulnerable here.

CVE-2026-39987 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:003:42
0:00
session startedaccounts/fireworks/models/kimi-k3 · CVE-2026-39987 · REPRO-20
0:08
0:09
web search
0:20
1:03
2:02
2:02
extract_facts
no facts extracted
2:06
2:06
supportclaim_contract
2:14
2:15
2:15
2:24
2:24
2:24
2:55
2:56
$python3 --version; pip3 --version; which git curl jq; ls /pruva/project-cache 2>/dev/null; curl -sI https://pypi.org/simple/marimo/ | head -3
0.8s
Python 3.14.4
pip 25.1.1 from /usr/lib/python3/dist-packages/pip (python 3.14)
/usr/local/bin/git
/usr/bin/curl
/usr/bin/jq
repo-mirrors
HTTP/2 200 
etag: "/HUZjvBtNf7OLTiXerKYSg"
3:03
3:04
$pip index versions marimo 2>/dev/null || curl -s https://pypi.org/pypi/marimo/json | jq -r '.releases | keys[]' | sort -V | tail -25
0.9s
{"cwd":"/workspace","duration_ms":538,"exit_code":0,"stderr":"","stdout":"marimo (0.23.15)\nAvailable versions: 0.23.15, 0.23.14, 0.23.13, 0.23.12, 0.23.11, 0.23.10, 0.23.9, 0.23.8, 0.23.7, 0.23.6, 0.23.5, 0.23.4, 0.23.3, 0.23.2, 0.23.1, 0.23.0, 0.22.5, 0.22.4, 0.22.3, 0.22.0, 0.21.1, 0.21.0, 0.20.4, 0.20.3, 0.20.2, 0.20.1, 0.20.0, 0.19.11, 0.19.10, 0.19.9, 0.19.8, 0.19.7, 0.19.6, 0.19.5, 0.19.4, 0.19.3, 0.19.2, 0.19.1, 0.19.0, 0.18.4, 0.18.3, 0.18.2, 0.18.1, 0.18.0, 0.17.8, 0.17.7, 0.17.6, 0.17.5, 0.17.4, 0.17.3, 0.17.2, 0.17.1, 0.17.0, 0.16.5, 0.16.4, 0.16.3, 0.16.2, 0.16.1, 0.16.0, 0.15.5, 0.15.4, 0.15.3, 0.15.2, 0.15.1, 0.15.0, 0.14.17, 0.14.16, 0.14.15, 0.14.14, 0.14.13, 0.14.12, 0.14.11, 0.14.10, 0.14.9, 0.14.8, 0.14.7, 0.14.6, 0.14.5, 0.14.4, 0.14.3, 0.14.2, 0.14.1, 0.14.0, 0.13.15, 0.13.14, 0.13.13, 0.13.11, 0.13.10, 0.13.9, 0.13.8, 0.13.7, 0.13.6, 0.13.3, 0.13.2, 0.13.1, 0.13.0, 0.12.10, 0.12.8, 0.12.7, 0.12.6, 0.12.5, 0.12.4, 0.12.3, 0.12.2, 0.12.1, 0.12.0, 0.11.31, 0.11.30, 0.11.29, 0.11.28, 0.11.27, 0.11.26, 0.11.24, 0.11.23, 0.11.22, 0.11.21, 0.11.20, 0.11.19, 0.11.18, 0.11.17, 0.11.16, 0.11.15, 0.11.14, 0.11.13, 0.11.12, 0.11.11, 0.11.10, 0.11.9, 0.11.8, 0.11.7, 0.11.… [truncated]
3:04
3:15
3:15
3:16
3:31
$cd /pruva/project-cache/repo && git log --oneline -3 c24d4806398f30be6b12acd6c60d1d7c68cfd12a && echo ---- && git show c24d4806398f30be6b12acd6c60d1d7c68cfd12a --stat | head -30
0.4s
c24d48063 fix: properly authenticate terminal route (#9098)
78489d462 feat(plotly): add reactive area chart selection support (#9046)
8649f3b8e feat(plotly): add reactive violin plot selection support (#9011)
----
commit c24d4806398f30be6b12acd6c60d1d7c68cfd12a
Author: Myles Scolnick <myles@marimo.io>
Date:   Wed Apr 8 13:08:56 2026 -0400
3:35
$cd /pruva/project-cache/repo && git show c24d4806398f30be6b12acd6c60d1d7c68cfd12a -- marimo/_server/api/endpoints/terminal.py
0.4s
{"cwd":"/workspace","duration_ms":43,"exit_code":0,"stderr":"","stdout":"commit c24d4806398f30be6b12acd6c60d1d7c68cfd12a\nAuthor: Myles Scolnick <myles@marimo.io>\nDate:   Wed Apr 8 13:08:56 2026 -0400\n\n    fix: properly authenticate terminal route (#9098)\n\ndiff --git a/marimo/_server/api/endpoints/terminal.py b/marimo/_server/api/endpoints/terminal.py\nindex f69d545ea..538ae991d 100644\n--- a/marimo/_server/api/endpoints/terminal.py\n+++ b/marimo/_server/api/endpoints/terminal.py\n@@ -14,7 +14,9 @@ from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict\n from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState\n \n from marimo import _loggers\n+from marimo._server.api.auth import validate_auth\n from marimo._server.api.deps import AppState\n+from marimo._server.codes import WebSocketCodes\n from marimo._server.router import APIRouter\n from marimo._session.model import SessionMode\n from marimo._utils.platform import is_pyodide, is_windows\n@@ -353,6 +355,13 @@ def supports_terminal() -> bool:\n @router.websocket(\"/ws\")\n async def websocket_endpoint(websocket: WebSocket) -> None:\n     app_state = AppState(websocket)\n+\n+    if app_state… [truncated]
3:42
08 · How to Fix

How to Fix CVE-2026-39987

Upgrade marimo · github to 0.23.0 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-39987 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-39987

Is CVE-2026-39987 exploitable?

Yes. Pruva independently reproduced CVE-2026-39987 in marimo and verified the exploit fires end-to-end in a sandboxed environment. A runnable proof-of-concept script and the full agent transcript are on this page (reproduction REPRO-2026-00316).

How severe is CVE-2026-39987?

CVE-2026-39987 is rated critical severity.

What type of vulnerability is CVE-2026-39987?

CVE-2026-39987 is classified as CWE-306 (Missing Authentication for Critical Function), a RCE vulnerability.

Which versions of marimo are affected by CVE-2026-39987?

marimo <0.23.0 is affected by CVE-2026-39987.

Is there a fix for CVE-2026-39987?

Yes. CVE-2026-39987 is fixed in marimo 0.23.0. Upgrading to the fixed version remediates the issue.

How can I reproduce CVE-2026-39987?

Pruva provides a verified reproduction script on this page. Download it and run it inside an isolated environment such as a container or virtual machine — never against production. The reproduction was confirmed end-to-end by Pruva's automated agents.

Is the CVE-2026-39987 reproduction verified?

Yes. Pruva reproduced CVE-2026-39987 with high confidence in a sandboxed environment, capturing the full agent transcript and artifacts as evidence.
11 · References

References for CVE-2026-39987

Authoritative sources for CVE-2026-39987 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.