跳到正文
DEV Community· Franchesco Romero·· 4 小時前精選AI 評分78

加固 MCP stdio 伺服器

Endurecer servidores mcp stdio

AI 導讀

Langflow 的 CVE-2026-105740(CVSS 9.9)允許任何已登入用戶登記由 bash -c 執行的 MCP stdio 命令。文章建議只讓用戶選擇伺服器 ID,將 ID 對應至經審核及固定版本的 argv,停用 shell,並以允許清單建立子程序環境。

推薦理由

文章從 Langflow 命令執行漏洞延伸至本機設定、團隊允許清單和雲端角色權限,呈現最小權限原則如何落實在不同部署層。

正文 · 原文

Un servidor de MCP que usa el transporte stdio es una línea de comandos que tu
agente corre como tú. En octubre de 2026 eso dejó de ser abstracto: el
CVE-2026-105740 (CVSS 9.9) de Langflow dejó que cualquier usuario con sesión
registrara un servidor stdio cuyo comando iba directo a bash -c. Este post
recorre el bug, el arreglo que de verdad aguanta, y una auditoría de una máquina
de desarrollo corriendo Claude Code, que sacó una línea de npx apuntando a un
scope de npm que nadie tiene.

El punto de partida: qué hizo Langflow

El advisory (GHSA-7w94-79vh-5mr2) nombra las líneas. La API acepta un
MCPServerConfig con command, args, env, url y headers de cualquier
usuario autenticado. No hay "ninguna allowlist de comandos permitidos, ninguna
blocklist de comandos peligrosos". Después, el comando se arranca así:

args=["-c", f"exec {command_str} || echo 'Command failed...'"]

El dict de env del usuario se mergea en el entorno del hijo. El trigger no es
"corre este servidor": es GET /api/v2/mcp/servers?action_count=true, la petición
que lista los servidores. Afectado: 1.8.3 según el advisory, parchado en 1.9.0;
una nota de triaje también lista 1.10.3, así que 1.10.3 o posterior cubre las dos
lecturas. Los reportes de intentos de explotación desde honeypots están en los
miles bajos; el que tiene una nota de metodología cuenta 2,023 desde el 19 de
septiembre desde un solo sensor.

Code companion: an MCP stdio server is a shell command

Folder Post section What it is
00-the-bug/ Round 0: the bug
vulnerable_spawn.py: the CVE-2026-105740 pattern reduced to its two lines, with harmless payloads. Kept as a DEAD END on purpose.
01-safe-spawn/ Round 1: the fix
safe_spawn.py: server IDs mapped to pinned argv, no shell, env from an allowlist.
02-audit-your-machine/ Round 2: measure
mcp_audit.py: every MCP server your agents will start, flagged STDIO / UNPINNED / AUTO-YES / NOT-FOUND / ENV / PLAIN-HTTP.
03-managed-policy/ Round 3: fleet policy Claude Code managed settings: exact serverCommand allowlist, locked to managed scope.
04-aws-tool-role/ Round 4: identity IAM role for a tool that runs on AWS: one log group, explicit denies, confused-deputy-safe trust policy.
05-checklist/ Round 5 What to check on your machine today, and what a platform must enforce.

Suggested reading order

Run 02 first; it takes ten seconds and is…

Ronda 0: el bug, reducido

00-the-bug/vulnerable_spawn.py guarda solo las dos líneas que importan y corre
payloads inofensivos:

1: mcp-server-started
2: mcp-server-started
3: mcp-server-started substitution-ran-before-exec
4: not node: PATH from the env field won

Tres detalles no obvios:

  1. La línea 2 parece una mitigación y no lo es. exec reemplaza el shell con el primer programa, así que el ; echo chained nunca corre. La sustitución de comandos sí, porque el shell expande $(...) antes de exec (línea 3).
  2. El campo env es su propia inyección. La línea 4 pide node --version y obtiene un binario distinto, porque PATH vino de la petición. LD_PRELOAD hace lo mismo para cualquier programa enlazado dinámicamente en Linux.
  3. Nada de eso importa mucho. El campo command acepta cualquier programa. Un parser perfecto que rechace $(...) de todos modos corre curl ... | sh si el usuario lo tecleó como el comando. El bug es que un usuario define qué corre, no cómo se parsea el string.

Ronda 1: el arreglo que aguanta

01-safe-spawn/safe_spawn.py:

REGISTRY: dict[str, ServerSpec] = {
    "playwright": ServerSpec(argv=("/usr/local/bin/playwright-mcp", "--headless")),
    "echo-demo": ServerSpec(argv=("/bin/echo", "mcp-server-started")),
}

def start_stdio_server(server_id: str, user_args: list[str] | None = None) -> str:
    spec = REGISTRY.get(server_id)
    if spec is None:
        raise NotAllowed(f"unknown MCP server id: {server_id!r}")
    if user_args:
        raise NotAllowed("arguments are part of the reviewed spec, not the request")
    ...
    out = subprocess.run(list(spec.argv), env=child_env(spec), shell=False, ...)
1: mcp-server-started
refused: unknown MCP server id: "bash -c 'echo pwned'"
refused: arguments are part of the reviewed spec, not the request
refused: binary not installed: /usr/local/bin/playwright-mcp

Las reglas, en orden de cuánto compran:

  1. Los usuarios escogen un ID. Nunca mandan un comando.
  2. Cada ID mapea a un argv exacto que se revisa como una dependencia, con una versión fijada instalada en tiempo de build de la imagen. Nada se jala al arrancar.
  3. Sin shell.
  4. El entorno del hijo se construye desde una allowlist (HOME, LANG, TZ, un PATH fijo) más los valores que un admin puso. El entorno del que llama nunca se mergea con datos de la petición.
  5. Agregar un ID es un cambio de admin con un registro de auditoría. En Langflow, la config persistía en los settings del proyecto que las revisiones de flow no miraban.

Ronda 2: mide tu propia máquina

El mismo riesgo existe sin un servidor vulnerable. Un agente de código lee las
definiciones de servidor stdio de los archivos de config y las arranca como tú.
02-audit-your-machine/mcp_audit.py lista cada servidor de las ubicaciones
usuales (los scopes user y local de ~/.claude.json, el .mcp.json del proyecto,
Kiro, Cursor, Claude Desktop) y marca lo que encuentra. Lee nada más, más una
petición HEAD por paquete al registry de npm o PyPI.

En la máquina que usé para escribir este post:

~/.claude.json (local scope: ~/projects)
  playright            npx @playwrigth/mcp@latest                                   STDIO UNPINNED NOT-FOUND(npm:@playwrigth/mcp)
~/.claude.json (local scope: ~/projects/myapp)
  playright            npx @playwrigth/mcp@latest                                   STDIO UNPINNED NOT-FOUND(npm:@playwrigth/mcp)

2 servers, 2 stdio, 2 with a finding beyond STDIO

playwrigth es un typo, y el scope de npm no existe.

$ curl -s -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org/@playwrigth/mcp
404
$ curl -s https://registry.npmjs.org/-/org/playwrigth/package
{"error":"Scope not found"}

Cualquiera puede crear la org playwrigth en npm y publicar @playwrigth/mcp. Los
servidores de scope local se cargan cuando una sesión arranca en ese directorio, y
@latest significa que el siguiente arranque resuelve lo que sea que se haya
publicado al último. El servidor nunca funcionó, así que nadie lo notó: una conexión
de MCP fallida es una línea en /mcp, no un error que detenga el trabajo.

Dos detalles no obvios:

  1. El lookup del registry es lo que lo encontró. Sin él, la línea se lee como un servidor de Playwright normal. El flag NOT-FOUND de la auditoría existe para este caso: un typo, un scope sin reclamar, o un paquete que se despublicó.
  2. La aprobación del .mcp.json del proyecto no es una respuesta completa. Claude Code pregunta antes de usar servidores de scope de proyecto en sesiones interactivas, e ignora las aprobaciones que un repositorio clonado se commitea a sí mismo. En claude -p, las sesiones del Agent SDK y algunas sesiones de nube, "carga los servidores de scope de proyecto sin preguntar". El scope local, donde vivía este typo, es la propia config del usuario, así que no hay paso de aprobación en absoluto.

El arreglo es un comando por directorio afectado:

claude mcp remove playright
claude mcp add playwright -- npx -y @playwright/mcp@0.0.83

Ronda 3: política para una flota

Un desarrollador puede arreglar un typo. Un equipo necesita la política para hacer
el typo inerte. Claude Code lee allowedMcpServers y deniedMcpServers de los
managed settings (03-managed-policy/managed-settings.json):

{
  "allowManagedMcpServersOnly": true,
  "allowedMcpServers": [
    { "serverUrl": "https://api.githubcopilot.com/*" },
    { "serverUrl": "https://mcp.example.com/*" },
    { "serverCommand": ["npx", "-y", "@playwright/mcp@0.0.83"] }
  ],
  "deniedMcpServers": [
    { "serverCommand": ["npx", "@playwright/mcp@latest"] },
    { "serverUrl": "http://*" }
  ]
}

Con una entrada de serverCommand presente, cada servidor stdio debe empatar un
comando listado exactamente, cada argumento en orden. La línea con el typo no
empata, así que no se carga.

Tres detalles no obvios de los docs:

  1. serverName no es un control de seguridad. Es la etiqueta tecleada en claude mcp add. Cualquiera puede nombrar cualquier servidor github. Usa serverCommand y serverUrl.
  2. serverCommand no compara env. Los docs lo dicen directo: "Algunas variables de entorno cambian lo que node carga al arrancar". Ese es el problema de LD_PRELOAD de Langflow en otro producto. Si el env importa, define el servidor en managed-mcp.json, donde el admin también pone su env.
  3. Sin allowManagedMcpServersOnly, los usuarios ensanchan la lista. Las allowlists se mergean de cada scope, incluyendo ~/.claude/settings.json. Las denylists siempre se mergean, y un deny siempre gana.

Ronda 4: la identidad como la que corre una herramienta

En una laptop, un servidor stdio hereda todo lo que el desarrollador tiene:
~/.aws, el keychain, el SSH agent, el historial de shell. En una plataforma, el
mismo servidor debería ser un servicio remoto con su propia identidad. Ese es el
argumento más fuerte para los transportes remotos: un servidor HTTP corre en su
propio proceso, como su propio rol, detrás de una auth que se puede revocar sin
tocar la laptop de nadie.

04-aws-tool-role/ es la forma de AWS de eso:

  • Leer un log group, leer métricas, nada más.
  • Deny explícito sobre iam:*, sts:AssumeRole, secretsmanager:GetSecretValue, ssm:GetParameter* y kms:Decrypt, para que un Allow amplio agregado después no lo pueda ensanchar.
  • Una trust policy que solo deja a las tareas de ECS en esta cuenta asumir el rol (aws:SourceAccount + aws:SourceArn).

Detalle no obvio: cloudwatch:GetMetricData no tiene scoping a nivel de recurso. Si
los nombres de las métricas son sensibles, la respuesta es una cuenta separada, no
un statement más listo.

Lo que el rol no resuelve es el egreso. Una herramienta de solo-lectura que puede
llamar a cualquier URL puede mandar lo que leyó a cualquier lado. Córrela donde las
únicas rutas sean los endpoints de AWS que necesita.

Cómo se ven los buenos defaults

Azure MCP Server 3.0 pasó a estable la misma semana. Es una referencia útil para
defaults, independiente de la nube:

  • Modo --read-only, que también excluye las herramientas de envío de email y SMS.
  • Filtrado con --tool que "empata los nombres expuestos exactamente, ignorando mayúsculas".
  • Validación de endpoint anti-SSRF extendida a más endpoints de data-plane y de control-plane.
  • Sus herramientas de query de PostgreSQL y MySQL ahora están marcadas como "destructivas, no-idempotentes, y no-de-solo-lectura", y el filtrado por verbo de SQL se quitó. La guía es permisos de base de datos de menor privilegio en lugar de tratar de filtrar el texto de SQL.

El último punto es la misma lección que la Ronda 0 desde el otro lado: no trates de
hacer segura una primitiva poderosa parseando su input. Dale una identidad que no
pueda hacer la cosa peligrosa.

Lecciones

  1. Mide tu propia config antes de leer sobre el CVE de alguien más. La auditoría encontró un paquete reclamable en la máquina que escribió este post.
  2. La definición de un servidor stdio es código. Revísala, fíjala, y decide quién la puede escribir, de la manera en que lo harías para un workflow de CI.
  3. No te parsees para salir de la ejecución de comandos. Quita la capacidad del usuario de escoger el comando.
  4. Las variables de entorno son inputs. PATH, LD_PRELOAD y NODE_OPTIONS deciden qué corre tanto como el comando lo hace.
  5. La falla silenciosa es una superficie de ataque. Un servidor que nunca conectó por meses es un nombre que nadie está vigilando.
  6. La identidad y el egreso son la frontera real. Los flags de solo-lectura y los filtros de herramientas ayudan; un rol que no puede hacer la cosa peligrosa es lo que aguanta.

來源:DEV Community · dev.to