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.
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).
CVE-2026-39987 Severity
CVE-2026-39987 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected marimo Versions
marimo · github versions <0.23.0 are affected.
How to Reproduce CVE-2026-39987
pruva-verify REPRO-2026-00316 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 Proof of Reproduction for CVE-2026-39987
- 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
unauthenticated WebSocket upgrade to /terminal/ws followed by attacker-supplied shell command text written to the spawned PTY
- marimo edit server (starlette/uvicorn)
- GET /terminal/ws WebSocket endpoint
- websocket_endpoint() accepts without validate_auth
- pty.fork() interactive shell
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for CVE-2026-39987
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/wsWebSocket endpoint. - Affected versions: all versions
< 0.23.0(verified on0.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.
.envcloud 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 outputPRUVA_VULN_A1_1000_vscodeproving 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():
app_state.mode != SessionMode.EDIT→ close.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
bundle/repro/reproduction_steps.sh(helper:bundle/repro/ws_exploit_client.py).- The script:
- clones/uses the marimo source checkout and verifies the fix patch hunk;
- creates two virtualenvs:
marimo==0.22.5(vulnerable) andmarimo==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.
- Expected evidence: vulnerable attempts print
RCE_CONFIRMEDwith markerPRUVA_VULN_A<n>_<uid>_<user>in PTY output; fixed attempts printCONNECT_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.log—CONNECT_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 addedvalidate_authlines from the fix commit.bundle/repro/runtime_manifest.json—entrypoint_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,websocketsclient 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 withWebSocketCodes.UNAUTHORIZEDbeforewebsocket.accept()wheneverenable_authis true — for every WebSocket endpoint, not just/ws. - Defense in depth: never expose
marimo editto 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,.envaccess). - Testing: add a regression test asserting unauthenticated
/terminal/wsupgrades are rejected (upstream added one intests/_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 withptysupport — 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-passwordis 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.0is 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.
python3 --version; pip3 --version; which git curl jq; ls /pruva/project-cache 2>/dev/null; curl -sI https://pypi.org/simple/marimo/ | head -3Python 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"
pip index versions marimo 2>/dev/null || curl -s https://pypi.org/pypi/marimo/json | jq -r '.releases | keys[]' | sort -V | tail -25{"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]cd /pruva/project-cache/repo && git log --oneline -3 c24d4806398f30be6b12acd6c60d1d7c68cfd12a && echo ---- && git show c24d4806398f30be6b12acd6c60d1d7c68cfd12a --stat | head -30c24d48063 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
cd /pruva/project-cache/repo && git show c24d4806398f30be6b12acd6c60d1d7c68cfd12a -- marimo/_server/api/endpoints/terminal.py{"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]Artifacts and Evidence for CVE-2026-39987
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-39987
Upgrade marimo · github to 0.23.0 or later.
FAQ: CVE-2026-39987
Is CVE-2026-39987 exploitable?
How severe is CVE-2026-39987?
What type of vulnerability is CVE-2026-39987?
Which versions of marimo are affected by CVE-2026-39987?
Is there a fix for CVE-2026-39987?
How can I reproduce CVE-2026-39987?
Is the CVE-2026-39987 reproduction verified?
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.