
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
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:
CVE39987_PROOF_START
uid=10001(marimo) gid=10001(marimo) groups=10001(marimo)
marimo
<container-hostname>
CVE39987_PROOF_END
That means the WebSocket connection succeeded and the command output came back from the terminal session.
Run:
python poc/poc.py --base-url http://127.0.0.1:8082
Expected result:
[*] Target WebSocket: ws://127.0.0.1:8082/terminal/ws
[*] Sending benign proof command only: id; whoami; hostname
[-] websocket connection rejected/failed: Handshake status 403 Forbidden
[result] not exploitable by this unauthenticated check
This shows the patched service rejects unauthenticated access before creating a terminal session.
The file below is intended for local learning only:
poc/rce_poc.py
It demonstrates the same issue in a more real-time way by connecting to the vulnerable terminal WebSocket and allowing terminal interaction.
Use this only against the local Docker lab.
python poc/rce_poc.py --base-url http://127.0.0.1:8081
Expected behavior:
[+] Connected successfully (Pre-Auth RCE)
✓ Interactive shell is ready to use!
Safe commands to try:
id
whoami
hostname
pwd
python -c 'import marimo; print(marimo.__version__)'
exit
python poc/rce_poc.py --base-url http://127.0.0.1:8082
Expected behavior:
[-] Connection Failed: 403 Forbidden
[!] This is likely the PATCHED version.
The WebSocket endpoint is now protected.
The real-time client exists to help understand how the vulnerable WebSocket behaves. It should not be used against any system outside this local lab.
For portfolio publication, the recommended primary evidence is still poc/poc.py, because it is bounded, repeatable, and least-harm.
The main lab result should be summarized as:
vuln / marimo 0.20.4:
unauthenticated WebSocket handshake to /terminal/ws succeeds
benign command output is observable
patched / marimo 0.23.0:
unauthenticated WebSocket handshake to /terminal/ws fails
server returns 403 Forbidden
no terminal session is created
This is the core proof for the CVE reproduction.
If you want to manually inspect the behavior, use a WebSocket-capable client and connect to:
ws://127.0.0.1:8081/terminal/ws
ws://127.0.0.1:8082/terminal/ws
Expected:
8081 -> connection accepted
8082 -> 403 Forbidden
The PoC scripts are preferred because they produce clearer evidence.
In a real environment, useful indicators include:
- WebSocket requests to /terminal/ws
- unauthenticated access attempts to marimo edit servers
- unexpected terminal sessions spawned by marimo
- commands launched by the marimo process
- access to .env, SSH keys, cloud credentials, or notebook secrets
- outbound network traffic shortly after /terminal/ws connections
Example local artifacts to inspect:
docker compose logs vuln
docker compose logs patched
docker compose ps
docker compose exec vuln ps aux
Stop and remove containers:
docker compose down
Remove volumes as well:
docker compose down -v
Remove local Python virtual environment if desired:
rm -rf .venv
This repository is for local security research and portfolio demonstration only.
Allowed:
- localhost testing
- Docker-only reproduction
- benign proof commands such as id, whoami, hostname
- comparing vulnerable and patched behavior
- documenting root cause and detection ideas
Not allowed:
- testing against public marimo servers
- testing systems you do not own or administer
- reverse shells
- persistence
- credential theft
- destructive commands
- lateral movement
- botnet or malware behavior
This project is intended only for authorized local testing and defensive security education. Do not use these scripts against systems you do not own or have explicit permission to test.
GitHub Security Advisory — GHSA-2679-6mx9-h9xc:
https://github.com/advisories/GHSA-2679-6mx9-h9xc
NVD — CVE-2026-39987:
https://nvd.nist.gov/vuln/detail/CVE-2026-39987
marimo upstream repository:
https://github.com/marimo-team/marimo
Patch commit — add authentication validation to terminal WebSocket:
https://github.com/marimo-team/marimo/commit/c24d4806398f30be6b12acd6c60d1d7c68cfd12a
Patch PR — marimo PR #9098:
https://github.com/marimo-team/marimo/pull/9098
| 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 |