加固 MCP stdio 伺服器
Endurecer servidores mcp stdio
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:
-
La línea 2 parece una mitigación y no lo es.
execreemplaza el shell con el primer programa, así que el; echo chainednunca corre. La sustitución de comandos sí, porque el shell expande$(...)antes deexec(línea 3). -
El campo env es su propia inyección. La línea 4 pide
node --versiony obtiene un binario distinto, porquePATHvino de la petición.LD_PRELOADhace lo mismo para cualquier programa enlazado dinámicamente en Linux. -
Nada de eso importa mucho. El campo command acepta cualquier programa. Un
parser perfecto que rechace
$(...)de todos modos correcurl ... | shsi 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:
- Los usuarios escogen un ID. Nunca mandan un comando.
- 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.
- Sin shell.
- El entorno del hijo se construye desde una allowlist (
HOME,LANG,TZ, unPATHfijo) más los valores que un admin puso. El entorno del que llama nunca se mergea con datos de la petición. - 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:
-
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-FOUNDde la auditoría existe para este caso: un typo, un scope sin reclamar, o un paquete que se despublicó. -
La aprobación del
.mcp.jsondel 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. Enclaude -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:
-
serverNameno es un control de seguridad. Es la etiqueta tecleada enclaude mcp add. Cualquiera puede nombrar cualquier servidorgithub. UsaserverCommandyserverUrl. -
serverCommandno comparaenv. Los docs lo dicen directo: "Algunas variables de entorno cambian lo quenodecarga al arrancar". Ese es el problema deLD_PRELOADde Langflow en otro producto. Si el env importa, define el servidor enmanaged-mcp.json, donde el admin también pone su env. -
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.
-
Denyexplícito sobreiam:*,sts:AssumeRole,secretsmanager:GetSecretValue,ssm:GetParameter*ykms:Decrypt, para que unAllowamplio 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
--toolque "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
- 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.
- 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.
- No te parsees para salir de la ejecución de comandos. Quita la capacidad del usuario de escoger el comando.
-
Las variables de entorno son inputs.
PATH,LD_PRELOADyNODE_OPTIONSdeciden qué corre tanto como el comando lo hace. - La falla silenciosa es una superficie de ataque. Un servidor que nunca conectó por meses es un nombre que nadie está vigilando.
- 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