Auditando un servidor MCP: 2 fallas que se repiten en producción
Un servidor MCP expone tools que un modelo llama con argumentos armados por él mismo, y esos argumentos terminan en el mismo código de siempre: un exec() de shell, un join() de rutas. Ahí caben command injection y path traversal. Les dejo un repo de github para reproducir el laboratorio y saber como corregir cada vulnerabilidad.

Por qué esto importa
El Model Context Protocol estandarizó cómo un agente de IA accede a herramientas externas: un servidor MCP expone una lista de tools, cada una con un nombre, un inputSchema y un handler que corre del lado del servidor. El modelo lee esa lista con tools/list y decide, por su cuenta, cuáles llamar y con qué argumentos.
Dos fallas clásicas de siempre pesan más en este contexto: command injection y path traversal. Clásicas porque llevan décadas en cualquier código que toca el sistema de archivos o una shell; más dañinas acá porque quien decide qué argumento pasarle a la tool ya no es una persona con cabeza, es un modelo que puede haber sido manipulado por el propio contenido que está procesando.
Armé un servidor MCP con las dos fallas a propósito, las reproduje y las corregí. Todo el código está en vulnerable-notes-mcp para que lo clones y lo repitas.
El servidor de prueba
vulnerable-notes-mcp expone dos tools sobre una carpeta notes/ con archivos de texto:
search_notes(query)— busca una palabra dentro de las notas.read_note(path)— devuelve el contenido de un archivo.
Nada exótico: es el tipo de servidor que cualquiera escribiría para darle a un agente acceso a sus propios documentos. Ese es el punto: las dos fallas no dependen de un escenario forzado, aparecen solas si nadie las busca a propósito.
git clone https://github.com/rockysec/vulnerable-notes-mcp
cd vulnerable-notes-mcp
npm install
cp .env.example .env
El .env contiene credenciales de prueba (DATABASE_URL, API_KEY) que no sirven para nada real: son el objetivo del path traversal más adelante.
Para reproducir cada falla no hace falta un cliente MCP, alcanza con curl. El servidor se sirve por HTTP en vez de stdio, el mismo transporte con el que te conectás a un MCP remoto real:
npm run start:http
Con eso queda escuchando en http://127.0.0.1:3939/mcp.
Issue 1: Command Injection
Archivo y línea vulnerable: src/lib/vulnerable-tools.ts:48. El handler de search_notes concatena el argumento del modelo directo en un comando de shell:
exec(`grep -ril "${query}" ${notesDir}`, (error, stdout, stderr) => {
// ...
});
query nunca se sanitiza. Cualquier carácter con significado especial para la shell —;, $(...), comillas— rompe el comando previsto y agrega el que quiera quien controle ese argumento.
No es un riesgo teórico: es exactamente lo que Imperva reportó en julio de 2025 en el MCP oficial de Figma (CVE-2025-53967, CVSS 7.5). El server construía un comando curl interpolando URL y headers sin validar dentro de un string de shell, ejecutado con child_process.exec: la misma causa raíz que acá, con RCE completo como resultado. El fix que recomendaron —pasar a child_process.execFile— es el mismo que aplico más abajo.
PoC. Uso legítimo, para tener un antes:
curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search_notes","arguments":{"query":"staging"}},"id":1}' \
http://127.0.0.1:3939/mcp
{ "result": { "content": [{ "type": "text", "text": "/.../notes/reunion-equipo.txt\n" }] } }
Ahora el mismo argumento, con un comando extra inyectado:
curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search_notes","arguments":{"query":"nada\" ; echo INYECTADO: $(whoami) ; echo \""}},"id":2}' \
http://127.0.0.1:3939/mcp
{ "result": { "content": [{ "type": "text", "text": "...\nINYECTADO: rockysec\n /.../notes\n" }] } }
whoami corrió, sin tener nada que ver con buscar notas. En un caso real ese hueco alcanza para leer cualquier archivo del proceso, hacer una request saliente o instalar persistencia, según qué binarios haya disponibles en el host.
Fix: src/lib/fixed-tools.ts:30. execFile en vez de exec: los argumentos van en un array, nunca pasan por una shell que los reinterprete.
execFile('grep', ['-ril', query, notesDir], (error, stdout, stderr) => {
// ...
});
Confirmarlo contra src/fixed-http.ts (puerto 3940, con npm run start:http:fixed): el mismo curl de arriba responde "Sin resultados.", sin INYECTADO en ningún lado — grep recibe el payload completo como patrón literal y no encuentra nada.
Issue 2: Path Traversal
Archivo y línea vulnerable: src/lib/vulnerable-tools.ts:72. read_note recibe un nombre de archivo y lo concatena al directorio permitido con join:
const target = join(notesDir, path);
const content = await readFile(target, 'utf8');
join no valida nada, solo concatena segmentos de ruta. Si path contiene .., el resultado sale de notesDir sin que el código se entere.
Google reportó la misma clase de falla en su propio MCP Toolbox for Databases (CVE-2026-11720, CVSS 9.3): un parámetro de ruta con ../ sin normalizar permitía escapar del scope configurado y alcanzar endpoints no autorizados en el mismo host, reenviando además las credenciales del toolbox. Sin autenticación ni interacción del usuario.
PoC. Contra el mismo server del issue anterior:
curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"read_note","arguments":{"path":"../.env"}},"id":3}' \
http://127.0.0.1:3939/mcp
{
"result": {
"content": [{
"type": "text",
"text": "DATABASE_URL=postgres://app:s3cr3t-demo-only@localhost:5432/notes\nAPI_KEY=sk-demo-...\n"
}]
}
}
Una tool pensada para leer notas de texto termina devolviendo credenciales.
Fix: src/lib/fixed-tools.ts:54. Resolver la ruta final a absoluta con resolve (que colapsa cualquier ..) y verificar que el resultado siga dentro del directorio permitido antes de tocar el disco.
const target = resolve(notesDir, path);
if (target !== notesDir && !target.startsWith(notesDir + sep)) {
return { content: [{ type: 'text', text: `Ruta fuera de notes/: ${path}` }], isError: true };
}
Confirmarlo contra el puerto 3940: el mismo curl responde isError: true con "Ruta fuera de notes/: ../.env" en vez de filtrar el .env.
Checklist para tu propio servidor MCP
- Cualquier input que llegue a
exec,execSynco un template string de shell: pasalo porexecFile/spawncon argumentos en array, nunca concatenado. - Cualquier input que se use para construir una ruta de archivo: resolvé a absoluta y verificá el prefijo del directorio permitido antes de leer o escribir.
- No asumas que porque el argumento “lo puso el modelo” es más confiable que si lo hubiera puesto un usuario. Es exactamente al revés: el modelo puede haber sido influenciado por contenido externo que procesó antes de decidir ese argumento.
- Antes de auditar el MCP de un tercero con payloads activos, confirmá que esté en el scope de su programa de bug bounty o disclosure.
El código completo, con ambas versiones y los comandos exactos para reproducir todo esto, está en github.com/rockysec/vulnerable-notes-mcp.


