¿Cómo proteger un servidor MCP con autenticación OAuth 2.1?

La nueva especificación de MCP permite proteger los recursos mediante la autenticación OAUTH 2.1.

Si te paras a leerla verás que es compleja de narices. Pero seguramente, si estás trabajando con MCP no te quedará más remedio que entenderla para usarla correctamente. Así que, agarra un café y vamos a estudiarla un rato. Asumo que ya conoces las bases de MCP, así que si no es caso, cierra esta pestaña y lee este post que ya publiqué al respecto. La idea es partir de un servidor MCP (Streamable HTTP) SIN autenticación, que ya implementa dos métodos básicos. A partir de ahí iremos estudiando qué demonios tenemos que implementar para conseguir añadirle autenticación.

Punto de partida

He mantenido el endpoint GET /mcp para servidores MCP con SSE streams. La especificación MCP dice que el soporte http/sse está deprecated (es legacy). Así que únicamente podríamos dejar el endpoint POST /mcp…

NOTE: el código original del server MCP se basa en este código de un lab de Google:
https://codelabs.developers.google.com/codelabs/cloud-run/how-to-deploy-a-secure-mcp-server-on-cloud-run

He dejado el método DELETE para limpiar las estructuras de datos cuando la conexión MCP termina, pero es opcional. Tienes el código del punto de partida en este Gist (ficheros basic.js y package.json)

Puedes lanzarlo así:

$ npm install
$ npm run basic                      

> zoo-animal-mcp-server@1.0.0 basic
> node basic.js

📡 MCP endpoint: http://localhost:3000/mcp

Para probarlo, abre el MCP Inspector.

$ npx @modelcontextprotocol/inspector

En Transport Type elige «Streamable HTTP». En URL, introduce: http://localhost:3000/mcp. Pulsa en «Connect». Luego Pulsa en List Tools. Deberías de poder ver y ejecutar dos herramientas: get_animal_by_species y get_animal_details.
Si no recuerdas bien cómo funciona MCP, de nuevo, échale un vistazo a este post donde lo explicaba.

Añadiendo autenticación OAUTH básica

En streamablemcpserver.js he dejado una versión con autenticación OAUTH básica.
Lo primero que haremos será pasar un nuevo parámetro authInfo a las tools, que llevará info sobre el usuario que la está ejecutando.

async ({ species }, { authInfo }) => {

Pero la primera magia está aquí:

app.post("/mcp", authenticateToken, async (req, res) => {

El segundo parámetro, authenticateToken es una función middleware que determinará si el usuario nos ha enviado un token de autenticación válido o no.

function authenticateToken(req, res, next) {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN

  if (!token) {
    return res.status(401).json({ error: "Access token required" });
  }

  const decoded = verifyAccessToken(token);
  if (!decoded) {
    return res.status(403).json({ error: "Invalid or expired token" });
  }

  req.user = {
    id: decoded.sub,
    scopes: decoded.scopes,
  };
  next();
}

Si no hay token (el usuario no se ha autenticado) enviamos un 401, tal y como dicta la especificación

"When authorization is required and not yet proven by the client, servers MUST respond with HTTP 401 Unauthorized."

Y ahí el cliente empieza a bailar al son de OAuth 2.1 IETF DRAFT (un draft del Internet Engineering Task Force que pasará a RFC si todo va bien)

El draft dice que cuando un server MCP emite un código 401, el cliente debe ir a buscar un fichero JSON a esta ruta conocida: /.well-known/oauth-authorization-server

Note: Según la especificación MCP de 2025-06-18, el cliente puede lanzar una petición GET a /.well-known/oauth-protected-resource para obtener la URL del authorization-server. Si no la encuentra, asume que el servidor MCP actúa también como authorization-server. Más fácil (así que es lo que usaremos en esta versión).

En jerga, dice que que «MCP clients MUST follow the OAuth 2.0 Authorization Server Metadata protocol defined in RFC8414.» Y si seguimos la pista del RFC8414 veremos que nos lleva a esa ruta /.well-known/oauth-authorization-server.

Así que nuestro server MCP debe ofrecer un endpoint ahí, con un buen puñado de URLs y parámetros:

// OAuth Authorization Server Metadata (RFC 8414)
app.get("/.well-known/oauth-authorization-server", (req, res) => {
  const base = getBaseUrl(req);
  res.json({
    issuer: base,
    authorization_endpoint: `${base}/authorize`,
    token_endpoint: `${base}/token`,
    jwks_uri: `${base}/.well-known/jwks.json`,
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code", "refresh_token"],
    code_challenge_methods_supported: ["S256"],
    scopes_supported: OAUTH_CONFIG.scopes,
    token_endpoint_auth_methods_supported: ["client_secret_post"],
    subject_types_supported: ["public"],
  });
});

Los servidores no están obligados a ofrecer ese endpoint. Si no lo hacen, el cliente debe ir a buscar los endpoints que necesita siguiendo este patrón:

https://api.example.com/authorize
https://api.example.com/token
https://api.example.com/register

Pero en nuestro caso sí ofrecemos los metadata endpoints. Así que, ¿ahora qué?

Ahora el server podría implementar o no el OAuth 2.0 Dynamic Client Registration Protocol para permitir a los cliente MCP obtener dinámicamente un ID de Cliente (sin preguntar nada al usuario). De momento no lo haremos (el usuario tendrá que
interactuar).

Así que, directamente el cliente irá a por el authorization_endpoint (/authorize).
Ahí podemos servir una página HTML para que el usuario se identifique (vía Google o login&pass) y después redirigirlo a /callback. A /authorize llegará una petición con estos parámetros rellenados por el cliente (este ejemplo proviende de una request generada por el cliente MCP de VS Code)

{
  client_id: 'zoo-animal-mcp-client',
  response_type: 'code',
  code_challenge: 'oBuDWc9aSEWXUCR3GVwLrO1V23WVmhqoAZeuUKkfkgI',
  code_challenge_method: 'S256',
  scope: 'read_animals list_animals',
  redirect_uri: 'http://127.0.0.1:33418',
  state: 'cywD6O8BkkfMpMQJfJ04Jg=='
}

Los valores de scope provienen de los metadatos. El client_id lo hemos tenido que picar a mano en VS Code cuando nos lo ha pedido (por no haber implementado aún el OAuth 2.0 Dynamic Client Registration). El resto de parámetros los ha inyectado VS Code automáticamente.

Ahora habría que identificar al usuario, pero para esta demo, aprobamos la petición directamente, sin login ni gaitas (es una demo, recuerda, estamos intentando simplificar todo el tinglado). Generamos un random code y un random userId y pa’lante.

// In a real implementation, you would show a login/consent screen
// For this demo, we'll auto-approve
  const authCode = generateAuthorizationCode();
  const userId = "demo-user-" + randomUUID(); // In real app, get from authenticated user

  // Store authorization code with PKCE details
  authorizationCodes.set(authCode, {
    clientId: client_id,
    redirectUri: redirect_uri,
    scope: scope || OAUTH_CONFIG.scopes.join(" "),
    userId: userId,
    codeChallenge: code_challenge,
    codeChallengeMethod: code_challenge_method,
    expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
  });

Ese bloque the authorizationCodes, con el codeChallenge, userId, scopes y parámetros OAuth, se lo pasamos al cliente (enviándoselo a la redirect_uri que el cliente nos dijo):

const redirectUrl = new URL(redirect_uri);)

Con ese authorizationCodes, el cliente pedirá cambiarlo por un access token (un token JWT):

app.post("/token", (req, res) => {

El servidor MCP recibirá un token request como este:

{
  grant_type: 'authorization_code',
  code: 'a6e885aa-ba4b-4738-aaa5-380dcbd7a6d8',
  client_id: 'zoo-animal-mcp-client'
}

Ahora, si el código es correcto, no está caducado y no requiere de secretId (o si requiere, el secretId es correcto), entonces generamos un access_token y un refresh_token para el cliente, userId y scope:

    res.json({
      access_token: accessToken,
      token_type: "Bearer",
      expires_in: 3600, // 1 hour
      refresh_token: refreshToken,
      scope: authData.scope,
    });

Note: Un cliente puede enviar un refresh_token al principio de la conversación, indicando que ya fue autenticado en su momento y que lo único que quieres es renovar el auth token sin pasar por el proceso de identificación del usuario. Eso sí, el cliente MCP tendrá que enviar el refresh_token y un GET /authorize con el client_id y scope, para identificar al cliente. Algo así:

🎫 POST /token from ::ffff:127.0.0.1 with grant_type: refresh_token
🎫 Token request received: {
grant_type: 'refresh_token',
code: undefined,
client_id: 'zoo-animal-mcp-client'
}
🔐 GET /authorize from ::1 with params: [Object: null prototype] {
client_id: 'zoo-animal-mcp-client',
response_type: 'code',
code_challenge: 'iWdqZiaenjAVRldq6DHhk02OkNaYk3aAEBHWUS2LGc4',
code_challenge_method: 'S256',
scope: 'read_animals list_animals',
redirect_uri: 'http://127.0.0.1:33418',
state: 'iC32busOJ27LXtnBnGgreA=='
}

Y con ese access_token (JWT), por fin, el cliente MCP ya puede empezar a lanzar peticiones MCP (para empezar, un initialize y a continuación un listado de tools tools/list), enviando siempre en el Bearer el access_token de marras que tanto nos ha costado obtener.

Identificando al usuario

Lo que hemos construido hasta ahora está muy bien pero aceptamos cualquier usuario (¡no hay proceso de login!). Vamos a arreglar eso añadiendo un pequeño form con un dos simples campos de login y password. Por simplificar, de momento solo aceptaremos dos usuarios: johndoe o janedoe. El password será pass para ambos.

Creamos un fichero authorize.html con el formulario y lo llamamos desde GET /authorize.

// OAuth Authorization Endpoint
app.get("/authorize", (req, res) => {
  console.log(`🔐 GET /authorize from ${req.ip} with params:`, req.query);
  // Serve the login form
  res.sendFile(path.join(process.cwd(), "authorize.html"));
});

Nos identificamos y al lanzar la primera tool, podremos ver que el server MCP sabe que el userID que ha lanzado la petición es johndoe (o janedoe).

El server reconoce a John Doe en la llamada al método

He dejado en streamablewithauth.js y authorize.html la nueva versión por si quieres trastear.

Usando el MCP Server desde VS Code

Hemos visto cómo programar todo… pero igual no estás familiarizado con la configuración del server en VS Code. Rápidamente:

Command+Shift+P: abrimos la paleta de comandos. Desde ahí tecleamos «mcp» para ver todos los comandos disponibles relacionados con mcp.

Con Command+Shift+P abrimos la paleta de comandos.

A continuación elegimos HTTP server. Tecleamos la URL del servidor (http://localhost:3000/mcp o si ya hemos desplegado, la url del servidor remoto, https://tudominio/mcp). Si te pide clientId y secretId, consulta tu .env buscando los valores de OAUTH_CLIENT_ID=zoo-animal-mcp-client y OAUTH_CLIENT_SECRET=your-secure-client-secret-here.

Se generará un fichero JSON de configuración de los MCP aquí ~/Library/Application Support/Code/User/mcp.json

Desde VSCode podremos habilitar (start) el server o reiniciarlo (restart)

Si ejecutas los pasos indicados, verás que al principio, VS Code arrojará el siguiente warning:

Problema con Dynamic Client Registration

Esto provoca que el usuario tenga que introducir el clientID de forma manual y después seguir un enlace que nos mostrará VS Code para autorizar al cliente (te devolverá un código que tendrás que pegar en VS Code para seguir). Esto funciona OK, pero es un peñazo para el usuario. Así que tendremos que implementar soporte DCR (Dynamic Client Registration) para poder lanzar nuestro server desde Claude Desktop (o desde VS Code, sin tener que pasar manualmente datos del cliente)

Dynamic Client Registration

Siguiendo la especificación más reciente (2025-06-18), podemos ver que DCR es opcional (should implement, no es un must):

MCP clients and authorization servers SHOULD support the OAuth 2.0 Dynamic Client Registration Protocol RFC7591 to allow MCP clients to obtain OAuth client IDs without user interaction.

Básicamente, añadimos un nuevo endpoint (registration_endpoint) al JSON que informa de los servicios de autenticación:

// OAuth Authorization Server Metadata (RFC 8414)
app.get("/.well-known/oauth-authorization-server", (req, res) => {
  console.log(`📋 GET /.well-known/oauth-authorization-server from ${req.ip}`);
  const base = getBaseUrl(req);
  res.json({
    issuer: base,
    authorization_endpoint: `${base}/authorize`,
    token_endpoint: `${base}/token`,
    registration_endpoint: `${base}/register`,
    jwks_uri: `${base}/.well-known/jwks.json`,
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code", "refresh_token"],
    code_challenge_methods_supported: ["S256"],
    scopes_supported: OAUTH_CONFIG.scopes,
    token_endpoint_auth_methods_supported: ["client_secret_post"],
    subject_types_supported: ["public"],
  });
});

Ese /register (que ya habíamos visto antes) servirá para que el cliente MCP se auto-registre e identifique. El cliente llamará vía POST a /register, enviando un buen puñado de variables:

// OAuth Dynamic Client Registration Endpoint
app.post("/register", (req, res) => {
  console.log(`📝 POST /register from ${req.ip} - Client registration request`);
  console.log("📝 Registration request body:", req.body);

  try {
    const {
      redirect_uris,
      client_name,
      client_uri,
      logo_uri,
      scope,
      grant_types,
      response_types,
      token_endpoint_auth_method,
      contacts,
    } = req.body;

Guardamos la info del cliente, y devolvemos un http/201 con todo lo que hemos guardado:

// Return client information according to OAuth 2.0 Dynamic Client Registration spec
    res.status(201).json({
      client_id: clientId,
      client_secret: clientSecret,
      client_id_issued_at: now,
      client_secret_expires_at: 0, // 0 means no expiration
      redirect_uris: redirect_uris,
      client_name: client_name || "MCP Client",
      client_uri: client_uri,
      logo_uri: logo_uri,
      scope: scope || OAUTH_CONFIG.scopes.join(" "),
      grant_types: grant_types || ["authorization_code"],
      response_types: response_types || ["code"],
      token_endpoint_auth_method:
        token_endpoint_auth_method || "client_secret_post",
      contacts: contacts || [],
    });

Aquí un problema que tuve es que cada cliente pasa una serie de URLs de callback. De momento las he ido metiendo todas en un .env de callbacks autorizados.

Y ahora sí, desde Claude Desktop: Settings / Connectors / Organization Connectors / Add custom connector.

Añadiendo un nuevo conector a Claude Desktop

Ahora, desde «Your Connectors», conectamos el conector (¡nunca mejor dicho!)

Conectamos el conector

Y veremos que Claude lanza una petición de registro con los siguientes valores (los valores POST /authorize son las credenciales por defecto que usamos en este ejemplo)

📋 GET /.well-known/oauth-authorization-server from ::ffff:127.0.0.1
🔐 GET /authorize from ::ffff:127.0.0.1 with params: [Object: null prototype] 
   response_type: 'code',
   client_id: '93a26150-8b99-46f9-a4f1-1ac23b10051a',
   redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
   code_challenge: '7d1a_aDTP6ZYwrD2nvjDvTOy_yPgyK87qNiGmI2N-Ek',
   code_challenge_method: 'S256',
   state: 'Yc7ED6D-op_v2evWmJgMmJ2mhmD9jlczAzcN_fdsE3M',
   scope: 'read_animals list_animals',
   resource: 'https://zoo.ikasten.io/mcp'

 🔐 POST /authorize from ::ffff:127.0.0.1 - Login attempt for: johndoe
 ✅ Valid login for user: johndoe
 ✅ Generated authorization code: 0e11475b-d07a-4ab0-926f-9226d74d3a64 for user: johndoe
 📋 GET /.well-known/oauth-authorization-server from ::ffff:127.0.0.1
 🎫 POST /token from ::ffff:127.0.0.1 with grant_type: authorization_code
 🎫 Token request received: {
   grant_type: 'authorization_code',
   code: '0e11475b-d07a-4ab0-926f-9226d74d3a64',
   client_id: '93a26150-8b99-46f9-a4f1-1ac23b10051a'
 }

¡Y listo! Ya tenemos el conector lanzado para hacerle preguntas:

Claude Desktop con el conector de Zoo

He dejado en este Gist la versión con DCR del servidor MCP.

CODA

Llegar hasta aquí ha sido un laaargo viaje. La idea es que todos los pasos se pueden simplificar usando proveedores de autenticación externos: Keycloak, Auth0, Clerk, Logto, ScaleKit… y frameworks del estilo MCP-Auth, pero quería aprender desde cero cómo funciona este infierno de OAuth 2.1 en MCP 🙂 Es un placer lanzar todo y controlar todo en tu propio servidor con tus propios endpoints. Eso sí, NO es una buena idea usar lo visto en este post directamente en producción. Usad proveedores externos, que han sido probados a conciencia y han tenido en cuenta multitud de detalles de seguridad que en este artículo hemos obviado.

Si te ha gustado este post y quieres aprender más al respecto (por ejemplo, cómo desplegar todo esto en producción), te animo a que te apuntes a la lista de espera del curso que estoy impartiendo sobre Agentes IA en la UPV/EHU, donde vemos en detalle, a través de proyectos reales, cómo trabajar con MCP, LangGraph, OpenAI Agents SDK y el framework de evaluación Arize Phoenix.

ee33 / Solve It 5: En Garde!


Solve It 5: En Garde! : resuelto por 5 equipos

Os dejo con el resumen de Servida, que mi cerebro está pidiendo vacaciones 🙂

Primero vimos que los espacios, puntos y comas no estaban puestos aleatoriamente, y por lo tanto, no podían formar parte del diccionario. Así que asumimos que las palabras estaban separadas correctamente y tenían que formar una frase con sentido.

Antes de seguir con decisiones correctas, hicimos un script para imprimir posibles frases descifradas, generando tablas de equivalencias aleatorias con hill climbing, dando como correctas aquellas frases cuyas k palabras (cuantas más mejor, pero asumimos que no todas las de la frase podían ser palabras que aparecieran en el diccionario) pertenecieran al diccionario de todas las palabras posibles en francés e inglés. Salieron algunas combinaciones con palabras con sentido, pero no ayudó mucho. [código después, pero de una versión mejorada]

Por la coma justo después de la primera palabra, pensamos que podía ser un saludo. Miramos posibles saludos en francés (por «en garde») pero el único que podía tener un poco de sentido («bonne») no encajaba nada, así que tiramos por los otros dos idiomas que pensábamos: inglés y español. Probando mentalmente, vimos que podría ser «Hello», y lo dimos por bueno.

Actualizamos el script para tener un diccionario de esas 4 sustituciones, y volvimos a probar. Este es el código del script (cortesIA):

import argparse
import random
import sys
from wordfreq import zipf_frequency

def parse_args():
    parser = argparse.ArgumentParser(
        description='Decifra una frase cifrada por sustitución usando hill climbing indefinidamente hasta encontrar todas las soluciones con score >= threshold.')
    parser.add_argument('-t', '--text', type=str,
                        default="iLEEB, D1 CPDL H7 hCHJB dBC6B1P. RB5 FHEELM D1 KP6IL8. a8LAP8L 6B MHL. a8yCnLX7p71zU3y7I",
                        help='Texto cifrado')
    parser.add_argument('-k', '--threshold', type=int, default=2,
                        help='Número mínimo de palabras en inglés para considerar buena una solución')
    parser.add_argument('-i', '--iterations', type=int, default=10000,
                        help='Número de intercambios por intento de hill climbing')
    parser.add_argument('-l', '--lang_threshold', type=float, default=3.0,
                        help='Frecuencia Zipf mínima para considerar una palabra perteneciente al idioma')
    return parser.parse_args()

def score_mapping(cipher, mapping, lang_thr):
    plain = ''.join(mapping.get(c, c) for c in cipher)
    count = 0
    for w in plain.split():
        w_clean = w.strip('.,;:?!"').lower()
        if w_clean:
            if zipf_frequency(w_clean, 'en') >= lang_thr:
                count += 1
    return count, plain

def random_fixed_mapping(symbols, known_map):
    remaining = [s for s in symbols if s not in known_map]
    available = [s for s in symbols if s not in known_map.values()]
    rand_mapping = known_map.copy()
    rand_mapping.update(dict(zip(remaining, random.sample(available, len(remaining)))))
    return rand_mapping

def hill_climb(cipher, symbols, known_map, lang_thr, iterations):
    # Empieza con mapping aleatorio que respeta known_map
    current_map = random_fixed_mapping(symbols, known_map)
    current_score, current_plain = score_mapping(cipher, current_map, lang_thr)

    for _ in range(iterations):
        # swap aleatorio entre símbolos no fijos
        m = current_map.copy()
        free = [s for s in symbols if s not in known_map]
        a, b = random.sample(free, 2)
        m[a], m[b] = m[b], m[a]
        sc, pl = score_mapping(cipher, m, lang_thr)
        if sc >= current_score:
            current_score, current_map, current_plain = sc, m, pl
    return current_score, current_map, current_plain

def main():
    args = parse_args()
    cipher = args.text
    threshold = args.threshold
    iterations = args.iterations
    lang_thr = args.lang_threshold

    symbols = sorted({c for c in cipher if c.isalnum()})
    # known mapping: "iLEEB" -> "Hello"
    known_map = {'i': 'H', 'L': 'e', 'E': 'l', 'B': 'o'}

    seen = set()
    attempt = 0
    print(f"Buscando soluciones con score >= {threshold} indefinidamente...")
    while True:
        attempt += 1
        score, mapping, plain = hill_climb(cipher, symbols, known_map, lang_thr, iterations)
        if score >= threshold and plain not in seen:
            seen.add(plain)
            print(f"[Solución {len(seen)} | Intento {attempt}] score={score}")
            print('Mapping:', mapping)
            print('Texto :', plain)
            print('-' * 60)

if __name__ == '__main__':
    main()

[la gracia está en subir el k, que por defecto es solo 2]

Salían más palabras reales, pero aún así no nos sirvió de mucho porque parecía casualidad; no tenían mucho sentido. Sí que llegaron a salir «killed» y «die» (a las que no dimos demasiada importancia). Aún así, lo dejamos corriendo con k=13 por si acaso encontraba algo.

Después nos dimos cuenta (con ayuda de la IA) de que sustituyendo las letras que ya teníamos, en las siguientes palabras, las 3 siguientes palabras podrían tratarse de «my name is», que también dimos por bueno. Casi inmediatamente, al tener ya «Hello, my name is» y con la cadencia de las frases (por los puntos y las comas) nos sonó muchísimo a la frase de Iñigo Montoya, y para nuestra enorme sorpresa, los caracteres encajaban perfectamente.

Antes de seguir con decisiones correctas, ignoramos la última palabra (que luego descubriríamos que sería la contraseña) y nos pusimos a mirar otras frases o mantras de la película, o incluso metiendo el número de veces que la dicen. Pero luego ya la consideramos como contraseña.

Así que a partir de aquí hicimos un diccionario:

cipher = "iLEEBD1CPDLH7hCHJBdBC6B1PRB5FHEELMD1KP6IL8a8LAP8L6BMHL"
clear  = "HellomynameisInigoMontoyaYoukilledmyfatherPreparetodie"

mapping = {}

for c, p in zip(cipher, clear):
    if c not in mapping:
        mapping[c] = p

print(mapping)

resultado:

{'i': 'H',
'L': 'e',
'E': 'l',
'B': 'o',
'D': 'm',
'1': 'y',
'C': 'n',
'P': 'a',
'H': 'i',
'7': 's',
'h': 'I',
'J': 'g',
'd': 'M',
'6': 't',
'R': 'Y',
'5': 'u',
'F': 'k',
'M': 'd',
'K': 'f',
'I': 'h',
'8': 'r',
'a': 'P',
'A': 'p'}

Este diccionario fue el que usamos para la variable `known_map`, y así el script siguió dando combinaciones, esta vez para la única palabra que faltaba. No llegamos a que nos diera una palabra que apareciera en el diccionario, pero luego vimos que tampoco la habría dado. Pero teniendo lo que teníamos hasta ahora, pudimos sustituir de la contraseña: `Pr_n_e_s_sy____sh`. Con ayuda de la IA sacamos que sería «princess as you wish» (frase que se repite mucho en la peli). Probamos «Princessasyouwish» y no era, pero claro, no podía ser, porque las letras que faltarían ya tenían su contrapartida de la clave, y ya se habrían sustituido.

Pensando (en parte) que las equivalencias serían simétricas, salió la idea de que el tercer carácter de la clave («y») correspondería a un «1» (para la «i» de «Princess»). Y aunque no son simétricas, sí nos dio la idea para probar lenguaje leet en las letras que faltaban (además de que la contraseña de otros retos también eran en leet) [¡Y por pura suerte, realmente luego vimos que «y» sí que correspondía a «1»!] Por lo tanto, para los 8 caracteres que faltaban, estas eran las opciones:

Pr_n_e_s_sy____sh
  1 c S A  Ouw1
    C   4  0UW

[la «i», «I», «s», «a» y «o» no podían ser porque ya estaban en la tabla de equivalencias»]

Así que solo tenemos 32 posibles contraseñas, que decidimos probar a mano (generándolas con 5 bucles anidados):

Pr1nceSsAsyOuw1sh
Pr1nceSsAsyOuW1sh
Pr1nceSsAsyOUw1sh
Pr1nceSsAsyOUW1sh
Pr1nceSsAsy0uw1sh
Pr1nceSsAsy0uW1sh
Pr1nceSsAsy0Uw1sh
Pr1nceSsAsy0UW1sh
Pr1nceSs4syOuw1sh
Pr1nceSs4syOuW1sh
Pr1nceSs4syOUw1sh
Pr1nceSs4syOUW1sh
Pr1nceSs4sy0uw1sh
Pr1nceSs4sy0uW1sh
Pr1nceSs4sy0Uw1sh
Pr1nceSs4sy0UW1sh
Pr1nCeSsAsyOuw1sh
Pr1nCeSsAsyOuW1sh
Pr1nCeSsAsyOUw1sh
Pr1nCeSsAsyOUW1sh
Pr1nCeSsAsy0uw1sh
Pr1nCeSsAsy0uW1sh
Pr1nCeSsAsy0Uw1sh  <--- correcta
Pr1nCeSsAsy0UW1sh
Pr1nCeSs4syOuw1sh
Pr1nCeSs4syOuW1sh
Pr1nCeSs4syOUw1sh
Pr1nCeSs4syOUW1sh
Pr1nCeSs4sy0uw1sh
Pr1nCeSs4sy0uW1sh
Pr1nCeSs4sy0Uw1sh
Pr1nCeSs4sy0UW1sh

ee33 / SolveIt 4: From A to B.

Empezamos con un enunciado sencillo:

El rey se siente muy solo y le ha gritado a la reina «¡Tira paquí!», pero la reina ha visto que por el suelo hay monedas en todas las casillas y no piensa llegar hasta el rey sin recogerlas. ¿Le puedes ayudar a llegar hasta el rey lo más rápido posible pasando por todas las casillas? Es la reina y no le gusta pasar dos veces por el mismo sitio, por si mancha.

Y el tablero de la imagen con la dama en f3 y el rey en c6. Lo que va en negrita del enunciado fue una pista metida a posteriori, cuando llevábamos ya unas cuantas horas dándole caña.

Aquí tuvimos muchos problemas. Para empezar, siendo un SolveIt, no nos parecía razonable implementar un sistema de backtracking para probar todas las soluciones (además, sin un sistema pre-calculado de movimientos posibles, no era viable terminarlo a tiempo – como nos comentó el team Insomnia a posteriori, esa idea de movimientos precalculados era una vía muy razonable y posible). Por otra parte, creíamos que «a mano no debía de ser muy complicado» (fueron nuestras últimas palabras 🙂

Bueno, resumiendo muy mucho, enseguida creamos (gracias, Claude) un chess path visualizer para ver la trayectoria de nuestra dama, las casillas atravesadas y el número de movimientos:
https://ikasten.io/images/ee33_chesspath.html

El primer intento, se quedaba un poco lejos del óptimo:

La siguiente (hat-off to Paul) mucho mejor:


f3f4d2g2g6b1h1h8a1a8g8b3b7e7c5c6

¿Pero cómo demonios metemos la posición en la web del HackIt? Esto es algo que la org debe mejorar (hint: Ontza). Algo del estilo: «Right answer. Wrong format» valdría Eso evitaría la frustración de no saber si lo que falla es la solución o el formato.

Otra: no hay una única solución de 15 movimientos. Esta otra también cumple con los requisitos de solución válida (con la sintaxis correcta):

Qe3 Qg5 Qg2 Qc2 Qh7 Qh1 Qa1 Qh8 Qa8 Qa2 Qf7 Qb7 Qb4 Qd6 Qc6

(Gracias a Ricardo, que se cebó con esta prueba hasta resolverla. Debería haber sacado una foto a su cuaderno… os hubiera gustado…)

ee33 / Solve It 3: Universal Language


Solve It 3: Universal Language: Resuelto por 3 equipos

Z+E+R+O \= 0
urefu(jibu) \= 16 (la longitud de la clave es 16)

Bonita prueba que nos ha tenido entretenidos largo tiempo. Especialmente a Owen, que se «pegó» con Ontza para convecerle de que la solución con enteros no existía 🙂

Una gran pista: si buscas la primera ecuación en Google, obtendrás este post en Quora:
https://www.quora.com/How-far-can-you-go-so-that-the-system-z-e-r-o-0-o-n-e-1-t-w-o-2-t-h-r-e-e-3-f-o-u-r-4-f-i-v-e-5-s-i-x-6-etc-has-a-solution-The-letters-in-the-LHS-are-the-unknowns

Así que, lo que buscamos es resolver este sistema de ecuaciones:

Z + E + R + O \= 0
O + N + E \= 1
T + W + O \= 2
T + H + R + E + E \= 3
F + O + U + R \= 4
F + I + V + E \= 5
S + I + X \= 6
S + E + V + E + N \= 7
E + I + G + H + T \= 8
N + I + N + E \= 9
T + E + N \= 10
E + L + E + V + E + N \= 11
T + W + E + L + V + E \= 12
T + H + I + R + T + E + E + N \= 13
F + O + U + R + T + E + E + N \= 14
F + I + F + T + E + E + N \= 15
S + I + X + T + E + E + N \= 16

Aquí la org emitió una pista cuando llevábamos atascados un buen rato:

«PISTA: Begitxo tiene sus años y tiene un 386SX de CPU, tenedlo en cuenta.»

Si pedimos a gpt 4o explicaciones:

«El 386SX, al igual que otros procesadores de la serie 386, no incluye un coprocesador matemático integrado para realizar operaciones en coma flotante. Por lo tanto, las operaciones en coma flotante debían ser manejadas mediante un coprocesador externo, el 80387, o por medio de emulación por software, lo que podía ralentizar las operaciones de este tipo.»

Así que la pista venía a decir que el sistema de ecuaciones tenía que resolverse con enteros. Pero lo curioso es que ese sistema no tiene solución con números enteros. Sí la tiene para números reales. Varias, de hecho.

Aquí una demostración de Owen sobre por qué la solución obligatoriamente debe incluir números reales en alguna de las variables (en N, por ejemplo):


Aquí la org tuvo que reconocer el bug y quitó la pista.

Soooo… una posible solución:
E \= 0
F \= 2.5
G \= 5
H \= -2.5
I \= 0
L \= 4
N \= 4.5
O \= -3.5
R \= 0
S \= 0
T \= 5.5
U \= 5
V \= 2.5
W \= 0
X \= 6
Z \= 3.5

Donde el input habría que ponerlo así:
0 2.5 5 -2.5 0 4 4.5 -3.5 0 0 5.5 5 2.5 0 6 3.5

(orden alfabético de las variables, sólo los valores de la solución)

Bonita prueba.

ee33 / Solve It 2

Born to run es un level que me encantó. Tranquilamente podría haber sido un level de HackIt y no de SolveIt. Nos pasan un fichero level.bin:
http://ikasten.io/images/ee33_level.bin
que a primera vista no nos dice nada:

$ file level.bin
level.bin: data

Pero, como siempre, el comando strings te ayudará:

$ strings level.bin| grep «V» | grep «2»
*Ver S1.20*
UV2C
#V2 x
Xf2V12
xuuvWdVgXueUHFUFWTDT7BB2#»
fUEVE4T4VR4Dd$3$\&DED5%#55BB2#»
+$WVE»XGE$hUV»xdT#xeD#XEF»XV5#wF5″X5D#7E5″722$»
VFV%Fe2#EBT»5%$$E45″T3$$FD
d’d+d/Z’V’R’Z1Z5Z9P1L1H1D1@1\<14\<2?2C4F8H\<H@HCTFWIZL]PRTRXRZ[^[o_ocogokoolrhrdw`wRyFuBu7r4o1l,q(q$q!f
#%$$2TCVdF6EFDddDDC
2V$BbE
XLLRRVV,^#»2\


Solve It 2: Born to Run (resuelto por 25 equipos)

Y si buscamos *Ver S1.20* en Google, cantamos bingo:

Ese string en concreto solo sale en algunas ROM de SNES. Así que estamos hablando de un volcado de un cartucho SNES. Tras unas cuantas vueltas (buscaba un emulador de SNES para macOS) encontré OpenEmu: https://openemu.org/

Cambiamos la extensión de .bin a .smc (por alguna extraña razón OpenEmu no tragaba con level.bin simplemente porque no le gustaba la extensión) y… nos ponemos a jugar al Super Mario Kart 🙂


En Linux podemos usar snes9x

Aquí he de decir que me vicié un poco al juego… pero era por una buena causa! Pensé que si quedaba primero me saldría algún mensaje con la flag. Pero no… quedé primero, pero no hubo flag:

Así que hubo que recurrir a técnicas de HackIt (!). Abriendo la ROM con el editor EpicEdit, ¡sorpresa!:

Addendum
NavarParty usó un plugin de cheating para poder saltarse paredes que les permitió ver la flag mientras jugaban por el level. Brilliant!