Volver a todas las publicaciones
Publicado el · por Renaud Deraison

El notebook traía su propio servidor MCP

CVE-2026-75149 es un fallo de inyección de código en marimo: un notebook es un único archivo Python, ese archivo lleva su propia configuración en un bloque de comentarios y, hasta la versión 0.23.15, esa configuración podía declarar un servidor MCP. Bastaba con abrir el notebook en modo edición para que marimo lanzara el comando del atacante como subproceso, antes de que se ejecutara una sola celda. El parche dice el resto. Elimina cinco categorías enteras de entorno que un documento descargado tenía permiso para fijar, con más prioridad que la configuración del operador. En Bromure Agentic Coding la lista de programas que tu agente puede lanzar es un panel del lado del host, escrito en la VM al arrancar, y nada dentro del espacio de trabajo puede añadir nada a ella.

Un repositorio puede reconfigurar tu agente de codificación. Eso ya lo sabíamos. Lo que ha cambiado es el tamaño de la cosa capaz de hacerlo. Ahora es un solo archivo, reenviado por Slack, que abres para echarle un vistazo.

Alguien te envía un notebook. Es un notebook de marimo, así que es un archivo .py corriente y no un paquete de JSON con salidas en base64 dentro, que es casi toda la razón por la que a la gente le gusta el formato. Puedes leerlo, puedes hacerle diff y puedes meterlo en git sin un hook de pre-commit que elimine las salidas.

Así que lo lees, tiene buena pinta, y lo abres en modo edición para toquetear una celda.

Arranca un subproceso. Todavía no has ejecutado ninguna celda; esto es algo que el archivo pidió por su cuenta, antes de que el notebook terminara de cargar.

Eso es CVE-2026-75149, publicada el 19 de agosto y recogida esta semana por The Hacker News. CVSS 8.7, CWE-94, atribuida a Gregory Tan, corregida en marimo 0.23.15. El aviso de VulnCheck resume el mecanismo en una línea: el notebook contiene una entrada de servidor MCP manipulada, y abrirlo en modo edición lanza el comando de esa entrada como subproceso local.

La configuración estaba en un comentario

Los notebooks de marimo llevan sus propios ajustes mediante PEP 723, la convención de Python para metadatos de script en línea. Es una pequeña tabla TOML que vive dentro de un bloque de comentarios al principio del archivo, y existe para que un script pueda declarar sus dependencias y su versión de Python sin un archivo de proyecto al lado. uv la lee. marimo también, y la extiende con una tabla tool.marimo para la configuración del notebook.

Dos propiedades de esa tabla hicieron el daño, y ninguna de las dos es un fallo por sí sola.

La primera: marimo fusionaba los metadatos del notebook con la máxima prioridad, por encima de la configuración que hubiera fijado la persona que ejecuta marimo. Ese es el comportamiento correcto por defecto para una preferencia de formato. Un notebook que quiere una indentación de dos espacios debería ganarle a tu ajuste global, porque el autor sabe qué aspecto tiene su archivo y tú no.

La segunda: la fusión era casi sin filtrar. Antes del arreglo, el saneador de marimo eliminaba exactamente una clave de la configuración suministrada por el notebook: tool.marimo.runtime.auto_instantiate. Todo lo demás de tool.marimo pasaba, incluido tool.marimo.mcp. Ahí es donde se listan los servidores Model Context Protocol, y una lista de servidores MCP es una lista de comandos que lanzar.

Junta las dos cosas y un comentario al principio de un archivo descargado podía nombrar un programa y hacer que marimo lo arrancara. Nada de esto es un exploit. El archivo usó la funcionalidad tal y como fue diseñada, y el diseño daba por supuesto que el archivo lo habías escrito tú.

analysis.py · un archivo, reenviado a ti# /// script# dependencies = ["polars", "altair"]# [tool.marimo.mcp.servers.helper]# command = "…"# ///metadatos PEP 723 · una tabla TOML en un comentarioimport marimo as mo@app.celldef _(): …las celdas · la parte con la que había que tener cuidadofusión de configuración, antes de 0.23.15tu configuración · prioridad más bajala del archivo · prioridad más altauna sola clave quitada: runtime.auto_instantiatemarimo edit analysis.pyentrada de servidor MCP → subproceso localantes de que se ejecute ninguna celdahereda la shell desde la que se abrióLa costumbre que te enseñó el formatoAbrir el notebook, leer las celdas, decidir qué ejecutar. Inspección primero, ejecución después, que es la razónentera de que el formato exista. La entrada MCP se ejecutó durante el paso uno.
De dónde venía el comando. Un notebook de marimo es un único archivo .py cuyos ajustes viven en un bloque de comentarios PEP 723 al principio. Antes de 0.23.15 marimo fusionaba ese bloque por encima de la configuración del propio operador quitando una sola clave, de modo que una sección mcp en el comentario se convertía en un programa que marimo lanzaba al abrir, en modo edición, antes de que se ejecutara ninguna celda.

El parche es la divulgación

El arreglo, PR #10281, se titula “additional pep 723 sanitization”, y cambia una lista de denegación por una lista de permitidos. La configuración suministrada por el notebook queda ahora restringida a secciones cosméticas y de editor: formato, guardado, visualización, atajos de teclado, diagnósticos, lint, snippets, fuentes de datos, servidores de lenguaje, compartición, venv, tiempo de ejecución y gestión de paquetes.

Las secciones eliminadas son la mitad interesante: ai, mcp, completion, secrets, server.

Son cinco secciones enteras, no cinco claves, y un documento que te habías descargado podía fijarlas todas. La justificación del PR detalla además una segunda carga útil que nunca necesitó un subproceso. La sección ai contiene URL base de API, así que un notebook podía reapuntar el endpoint del modelo a un host de su elección, lo que, en palabras de los mantenedores, “podría permitir la exfiltración de las claves de API del operador hacia endpoints controlados por el atacante”.

Cubrimos la variante de URL base de este ataque en julio, cuando un repositorio podía fijar ANTHROPIC_BASE_URL y Claude Code mandaba su propia clave a la dirección que el repositorio eligiera. Es el ataque más barato de esta categoría, porque no se ejecuta código y es el propio cliente de la víctima el que hace el envío. La variante de marimo encoge el vehículo de entrega de un repositorio a un comentario.

La unidad de confianza no para de encoger

Esta clase de fallos tiene una historia corta, y en esa historia el atacante necesita cada vez menos de tu máquina.

En abril, Wiz Research informó de CVE-2026-12957 en Amazon Q Developer, CVSS 8.5, parcheada en mayo y divulgada en junio. Amazon Q leía .amazonq/mcp.json de un espacio de trabajo abierto y lanzaba los servidores que definía. Esos procesos heredaban todo el entorno del desarrollador: claves de AWS, tokens de CLI en la nube, secretos de API, sockets del agente SSH. El arreglo de Amazon fue preguntar antes de ejecutar un servidor MCP desde un espacio de trabajo no confiable.

En agosto, ChainDrop metía configuración de agente en los repositorios que recorría: un hook SessionStart en .claude/settings.json y una tarea folderOpen en .vscode/tasks.json, de modo que clonar el proyecto y abrirlo bastaba.

Los dos necesitaban un repositorio. Alguien tenía que publicar o comprometer un proyecto, tú tenías que clonarlo, y un directorio de dotfiles se quedaba ahí, en el árbol, donde una persona desconfiada podía mirarlo.

Marimo necesita un archivo. Un archivo, que se muestra como un documento y que llega como llegan los documentos: un hilo de Slack, un adjunto de correo, un gist que alguien enlazó en la reunión diaria, una descarga de Kaggle. Un notebook no se clona. Se abre, porque abrirlo es la manera de averiguar qué es.

El bloque mcp se ha extendido a casi todo el instrumental de desarrollo en los últimos dieciocho meses, y esos bloques viven en archivos que viajan entre personas: raíces de repositorio, directorios de editor y ahora metadatos de documento. Cada uno es una lista de programas que algo de tu máquina está dispuesto a arrancar, escrita por quienquiera que tocara el archivo el último. Los escáneres de registro no los leen, los lockfiles no los cubren, y nadie ha propuesto una atestación de procedencia que se aplique a un comentario.

De dónde salen los servidores MCP en un perfil

Bromure Agentic Coding responde a la pregunta de la procedencia donde se puede responder: en el host, antes de que arranque la VM.

Cada perfil tiene un panel MCP en sus ajustes, junto a Agents, Credenciales, Guardrails y Supply Chain. Contiene una lista de servidores. Cada entrada se activa o desactiva de forma independiente y usa uno de dos transportes: HTTP, una URL remota con un token bearer opcional, o stdio, un comando local lanzado dentro de la VM. Bromure traduce esa lista al formato que espera el agente activo, JSON para Claude Code y TOML para Codex, y la inyecta en la VM al arrancar.

Lee la dirección del viaje en esa última frase. La configuración va hacia dentro del invitado, desde el host, al arrancar, a partir de una lista que mantienes en un panel de ajustes. Un archivo que aparece después en el espacio de trabajo no es un sitio del que salgan servidores MCP, así que no hay ninguna fusión cuya prioridad se pueda equivocar ni ningún saneador que mantener sincronizado con un esquema de configuración al que no paran de salirle secciones. Un documento no puede añadir una entrada a una lista que no puede alcanzar.

La configuración viaja con el archivo¿quién escribe la lista de servidores?quien editara el último el archivo que abriste¿cuándo se lee?al abrir, antes de que decidas nada¿qué le gana a qué?la descarga le gana a tu propia configuración¿qué hereda el subproceso?la shell desde la que lo abriste, y con ella~/.aws · ~/.ssh · SSH_AUTH_SOCK · claves de APILa configuración vive en el perfil¿quién escribe la lista de servidores?tú, en el panel MCP del perfil, en el host¿cuándo se lee?al arrancar, traducida al formato del agente¿qué le gana a qué?nada del espacio de trabajo entra en la fusión¿qué hereda el subproceso?el entorno de una VM desechable: claves señuelo,sin claves SSH privadas, salida filtrada en el host
Dos respuestas a la misma pregunta: ¿quién puede añadir un programa a la lista de cosas que tu instrumental va a lanzar? A la izquierda, quienquiera que editara el último el archivo que abriste, fusionado por encima de tu propia configuración con más prioridad, en una máquina que guarda tus credenciales de nube. A la derecha, tú, en un panel de ajustes del host, escrito en una VM desechable al arrancar, donde las credenciales que se ofrecen son señuelos y una tabla que el invitado no puede editar filtra el tráfico saliente.

Ahora abre el notebook igualmente

Nada de eso ayuda el día en que estás ejecutando una versión que tiene el fallo. Así que coge el marimo anterior a 0.23.15, regálale la victoria al atacante y haz pasar un notebook hostil por un perfil.

El subproceso arranca dentro del ordenador de otro. El trabajo de sesión en Bromure Agentic Coding ocurre en pestañas de kitty dentro de la VM Ubuntu del perfil, a un hipervisor de distancia de macOS. marimo edit se ejecuta ahí, y el comando del comentario también. En Recursos → Almacenamiento el perfil son tres capas: un /home/ubuntu por perfil con Borrar home…, un disco de sistema del espacio de trabajo con Restablecer a la base… que vuelve a clonar desde la imagen compartida, y debajo un sistema operativo base de solo lectura que nada del invitado puede tocar. Una opción de menú se lleva por delante todo lo que la entrada instalara.

El entorno que hereda es un conjunto de señuelos. El fallo de Amazon Q dolía porque el servidor lanzado era hijo de la shell real del desarrollador y heredaba sus credenciales reales. En un perfil de Bromure no hay nada detrás de esos nombres de variable. Las claves de API genéricas son marcadores de posición brm_… exportados a la VM y cambiados por los valores reales por el proxy del host de camino hacia fuera. El kubeconfig es sintético, con certificados de cliente de usar y tirar. Las peticiones a AWS se vuelven a firmar en el host, de modo que un proceso que esquive el proxy recibe InvalidSignatureException en lugar de acceso. ~/.docker/config.json contiene un blob base64 falso. Las claves SSH privadas nunca están en la VM, porque el host firma a través de un agente por perfil. Activa Requerir aprobación para usar en una credencial concreta y cada cambio se convierte en un diálogo en el host con una concesión acotada en el tiempo: cinco minutos, una hora, el resto de la sesión.

El truco de la URL base recoge un marcador de posición. El proxy sustituye una credencial real solo en peticiones salientes hacia el host propio de esa credencial. Un notebook que reapunte un endpoint de IA a attacker.example recoge lo que hay en el entorno de la VM, que es una cadena brm_… que no abre nada.

El servidor tiene que llegar a la red. Una entrada MCP que lance algo útil para un atacante necesita salida, ya sea para traerse una fase posterior o para devolver lo que encontró. Guardrails → Conexiones salientes es una tabla de reglas al estilo pf: una acción, un protocolo (tcp, udp, web, any), un host o CIDR, una lista de puertos y, para web, una lista de métodos HTTP, evaluadas de arriba abajo y ganando la primera coincidencia, más un ajuste Tráfico no coincidente en Permitir o Denegar. Ponlo en Denegar, lista los hosts que tu trabajo necesita, y el destino de la entrada no está en la lista. La tabla se aplica en el host, en el conmutador virtual y en el proxy, así que nada de lo que el invitado le haga a su propio enrutado cambia la respuesta.

El archivo tiene que estar dentro de la VM, para empezar. Un perfil comparte como mucho ocho carpetas del Mac, cada una elegida a mano en el panel Carpetas y montada en /home/ubuntu/<basename>. Un notebook que aterrizó en ~/Downloads no está en el perfil hasta que lo pongas ahí, y el resto de tu Mac no está a un recorrido de directorios de lo que el notebook arrancara.

Y puedes verlo ocurrir. La ventana Registro de seguridad (Ventana → Registro de seguridad…) es una única tabla cronológica del lado del host: veredictos de paquetes, decisiones del cortafuegos, cambios de credenciales, detecciones de inyección de prompts. El código invitado no puede editarla, porque el código invitado no puede alcanzarla. Una conexión saliente bloqueada desde un proceso que tú no arrancaste aparece ahí como una línea, lo que es mejor que enterarte por una factura de nube seis semanas después.

Actívalo

Actualiza marimo a 0.23.15 o posterior; la lista de permitidos que hay ahí es un buen parche. Después ve a mirar cualquier otro sitio del que tu instrumental lea un bloque mcp, y pregunta quién puede escribir ese archivo.

En un perfil, los ajustes que valen dos minutos son la misma lista corta de siempre. Guardrails → Conexiones salientes, con Tráfico no coincidente en Denegar y una lista de permitidos para los hosts que tu trabajo necesita. Credenciales → Requerir aprobación para usar en todo lo que pueda gastar dinero o borrar datos. Supply Chain → Comprobación de vulnerabilidades OSV, y el filtrado de socket.dev o Delpi si tienes clave, encima de la puerta de edad de dos días activa por defecto. Prompt Injection → el escáner de CLAUDE.md y AGENTS.md, que trata a los archivos que se arrogan autoridad sobre tu agente exactamente como lo que son.

Los notebooks van a seguir reenviándose, y a los formatos de configuración les van a seguir saliendo secciones, porque las dos cosas son útiles. Lo que sí puedes cambiar es qué máquina está escuchando cuando un documento pide que se arranque un programa. Instala Bromure Agentic Coding, guarda tu lista de servidores en el editor de perfiles, y deja que el próximo bloque de comentarios servicial configure una VM que puedes borrar desde un menú.