
Local Docker lab reproducing CVE-2026-39987, a pre-auth RCE in marimo's terminal WebSocket. Compares vulnerable and patched versions with least-harm PoC scripts for educational security research.
Local-only Docker lab for reproducing and understanding CVE-2026-39987 in marimo. This project compares a vulnerable marimo service with a patched marimo service side-by-side, then demonstrates the difference using a least-harm proof script.
CVE-2026-39987 is a critical pre-authentication remote code execution vulnerability in marimo, a reactive Python notebook framework.
The vulnerable behavior exists in the terminal WebSocket endpoint:
/terminal/ws
In affected versions, this endpoint can be reached without valid authentication and can create an interactive terminal session. An unauthenticated attacker who can reach a vulnerable marimo edit server may execute commands with the privileges of the marimo process.
This lab reproduces the vulnerability in a controlled local Docker environment:
| Service | Version | URL | Expected behavior |
|---|---|---|---|
vuln | marimo 0.20.4 | http://127.0.0.1:8081 | /terminal/ws accepts unauthenticated WebSocket connection |
patched | marimo 0.23.0 | http://127.0.0.1:8082 | /terminal/ws rejects unauthenticated WebSocket connection with 403 Forbidden |
The goal is not to provide a weaponized exploit. The goal is to show the observable security difference between vulnerable and patched versions using local-only, reproducible evidence.
The affected component is marimo's terminal WebSocket endpoint:
/terminal/ws
This endpoint is used by marimo's edit environment to provide terminal functionality through a browser-connected WebSocket session.
The vulnerable endpoint accepted WebSocket connections without enforcing the same authentication checks expected for protected marimo edit functionality.
The important difference is:
vulnerable behavior:
unauthenticated client can connect to /terminal/ws
terminal session is created
commands can be sent through the WebSocket
patched behavior:
unauthenticated client is rejected
WebSocket handshake fails with 403 Forbidden
terminal session is not created
The patch adds authentication validation to the terminal WebSocket flow before allowing a terminal session to be established.
If a vulnerable marimo edit server is exposed to a reachable network, an unauthenticated attacker may execute commands as the user running the marimo process.
In this lab, the marimo process is intentionally run as a low-privilege non-root user:
uid=10001(marimo) gid=10001(marimo) groups=10001(marimo)
This keeps the demonstration safer while still proving the vulnerability.
This lab is designed to prove three things:
/terminal/ws.403 Forbidden.The lab intentionally avoids destructive commands, persistence, reverse shells, credential dumping, or any internet-facing target.
.
├── docker-compose.yml
├── vuln/
│ ├── Dockerfile
│ └── notebook.py
├── patched/
│ ├── Dockerfile
│ └── notebook.py
├── poc/
│ ├── poc.py
│ ├── rce_poc.py
│ └── requirements.txt
├── SAFETY.md
├── README.md
└── .gitignore
| File | Purpose |
|---|---|
docker-compose.yml | Defines vulnerable and patched marimo services |
vuln/Dockerfile | Builds the vulnerable marimo service |
patched/Dockerfile | Builds the patched marimo service |
vuln/notebook.py | Minimal marimo notebook file used by the vulnerable service |
patched/notebook.py | Minimal marimo notebook file used by the patched service |
poc/poc.py | Least-harm proof script that runs benign commands only |
poc/rce_poc.py | Real-time learning client for observing terminal behavior in the local lab |
poc/requirements.txt | Python dependencies for PoC scripts |
SAFETY.md | Safety rules and scope boundaries |
Host machine
127.0.0.1:8081 ─────► vuln container
marimo 0.20.4
/terminal/ws accepts unauthenticated WebSocket
127.0.0.1:8082 ─────► patched container
marimo 0.23.0
/terminal/ws rejects unauthenticated WebSocket
Both services expose marimo's internal port 2718, but the host ports are different:
vuln -> 127.0.0.1:8081
patched -> 127.0.0.1:8082
The services are bound to 127.0.0.1 only. They are not intended to be exposed to a LAN or the internet.
The containers are configured with several guardrails where possible:
- bind ports to 127.0.0.1 only
- run as a non-root user
- drop Linux capabilities
- enable no-new-privileges
- use a read-only root filesystem
- provide only limited tmpfs write locations
- isolate services inside a dedicated Docker bridge network
These controls do not remove the vulnerability from the vulnerable service. They reduce the blast radius of the local demonstration.
Tested assumptions:
- Linux x86_64 or macOS with Docker Desktop
- Docker Compose v2
- Python 3.9+
- Localhost-only testing
Required tools:
docker --version
docker compose version
python3 --version
Clone or enter the project directory:
cd cve-2026-39987
Build and start both services:
docker compose up -d --build
Check that both containers are running:
docker compose ps
Expected services:
cve-2026-39987-vuln
cve-2026-39987-patched
Check the vulnerable service version:
docker compose exec vuln marimo --version
Expected:
0.20.4
Check the patched service version:
docker compose exec patched marimo --version
Expected:
0.23.0
Create a Python virtual environment:
python3 -m venv .venv
source .venv/bin/activate
Install dependencies:
python -m pip install -r poc/requirements.txt
The primary proof script is:
poc/poc.py
It attempts to connect to:
/terminal/ws
Then it sends only benign proof commands:
id
whoami
hostname
No reverse shell, file write, persistence, credential access, or destructive command is used.
Run:
python poc/poc.py --base-url http://127.0.0.1:8081
Expected result:
[*] Target WebSocket: ws://127.0.0.1:8081/terminal/ws
[*] Sending benign proof command only: id; whoami; hostname
[+] websocket connected without credentials
[result] VULNERABLE: unauthenticated command execution observed
[proof]
uid=10001(marimo) gid=10001(marimo) groups=10001(marimo)
marimo
<container-hostname>
If the script prints raw terminal output but includes this block, the vulnerability is still confirmed: