こんにちは、今回作問したTerranovaです。今回はFlatt Security Developers' Quiz #6にご参加いただきありがとうございました。
⚡️ Flatt Security Developers' Quiz #6 開催! ⚡️
— 株式会社Flatt Security (@flatt_security) 2023年12月29日
解答は年明け1/5(金)11:59まで!Tシャツ獲得を目指して頑張ってください!
デモ環境: https://t.co/hXaNP2Ciwv
ソースコード: https://t.co/ejTKzpAp9D
解答提出フォーム: https://t.co/jnc5Wv2Hi7 pic.twitter.com/uf3ZqHEdTK
今回のクイズの概要
概要
デモ環境にアクセスすると、以下のような投票アプリが開かれます。
右上のログインボタンを押すと、セッションIDが振られて投票ができるようになります。実際にGiraffeに投票してみましょう。
できたようですが、未だ優勝は *1 Catのようで残念です。
さて、Catに投票している不届き者は誰でしょうか?この問題のゴールは他者の投票情報を得ることになります。
ソースコード
ソースコードはGitHubで公開されています。
compose.ymlを開いてみると、このアプリケーションは次のような構成になっていることがわかります。
設定としては「レガシーソフトウェアの前段にプロキシ的に認証機能を拡張して実装されている投票ソフトウェア」になります。実際に以下に説明するように、APIのソースコードは認証が必要な機能についてPOSTされたJSONデータをpeekする形で認証を管理し、もし問題がなければレガシーのバックエンドに改めてPOSTリクエストを行い実際の処理を移譲するものになっています。
以下では、上述の図で名付けられているように、Goで書かれたサービスのことをapi、Rubyで書かれたサービスのことをlegacyと呼ぶことにします。
APIのソースコード
apiのソースコードはGoで書かれています。実装されている機能は
- POST /user: usernameを受け取って、アカウントの登録を行う
- POST /vote: usernameと投票先を受け取ってAuthorizationヘッダーの認証を行い、問題がなければリクエストをlegacyに移譲する
- POST /result: usernameを受け取ってAuthorizationヘッダーの認証を行い、問題がなければリクエストをlegacyに移譲する
- GET /summary: 認証不要で、現在最も投票されている候補者を計算する処理をlegacyに移譲する形で返す
特に以降参照する核心部分は次の通りです。
func waf(data []byte) bool { return bytes.Contains(data, []byte("admin")) } func handleSession(c echo.Context, data []byte) error { if waf(data) { return echo.NewHTTPError(http.StatusBadRequest, "Bad text detected") } sessionID := c.Request().Header.Get("Authorization") username, err := jsonparser.GetString(data, "username") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "username is required") } if _, exists := userSessions[username]; !exists { return echo.NewHTTPError(http.StatusUnauthorized, "Invalid session") } if userSessions[username] != sessionID { return echo.NewHTTPError(http.StatusUnauthorized, "Invalid session") } return nil } func postResult(c echo.Context) error { data, err := io.ReadAll(c.Request().Body) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if err := handleSession(c, data); err != nil { return err } resp, err := http.Post("http://"+legacyHost+"/result", "application/json", bytes.NewBuffer(data)) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSONBlob(resp.StatusCode, body) }
handleSession
関数は、受け取ったPOSTのペイロードをjsonparserライブラリを用いてパースしてusernameを取り出し、取り出したusernameに対応するセッションIDがAuthorizationヘッダーに置かれているかを確認しています。
postResult
関数は、この handleSession
関数を用いてリクエストを送った持ち主が正しいセッションを保持しているかを確認し、その後でlegacyにPOST /resultしています。
legacyのソースコード
また、legacyのソースコードはRubyを用いて実装されています。提供されている機能は以下の通りです
- POST /vote: usernameの投票先をcandidateに変更する。また、必要に応じてcandidateに対する投票数を変更する
- POST /result: usernameに対応するユーザーの現在の投票先を返す *2
- GET /summary: 現在の全体得票数1位の動物を返す
これも以下の説明に必要な部分だけ抜き出してみましょう
candidates = { 'Dog' => 0, 'Cat' => 0, 'Fox' => 0, 'Giraffe' => 0, 'Wolf' => 0 } voting = Mutex.new users = {} flag = ENV['FLAG'] || "DUMMY" users["admin"] = flag # Can you read other's vote? ## 略 ## post '/result' do d = request.body.read data = JSON.parse(d) username = data['username'] candidate = users[username] if candidate json candidate: candidate else status 403 json error: "User not found" end end
legacyのソースコードでは users
ハッシュにユーザーが実際に投票した候補者先の名前が保存されています。POST /result
では単にこの結果を参照して返します。
解法
目標は最初にも書いたように他人の投票、特に"admin"という名前の人が入れた投票先をリークすることです。すなわちlegacyがPOST /result
を処理した際に、usernameが"admin"になっていれば良いのです。
これには以下の2つの障壁があります。
- そもそも正しいセッションIDを持たない場合前段のapiの認証に弾かれる
- 仮に認証を何らかの方法で突破してもWAFにより"admin"という名前が弾かれてしまう
WAFの突破はひとまずおいておいて、どのようにしてlegacyがusername=adminとなるような POST /result
を受け取ってくれるでしょうか?これについて考えます。
今回の問題の核心は「JSONライブラリの挙動差をつくこと」です。JSONというデータ形式は元々は厳格な仕様があるものではなく、その曖昧さの影響でしばしばライブラリがどのように文字列をパースするかの挙動差が存在します。例えば、今回題材にしたのは「複数の同一キーが一つの辞書内に存在するときに何を当該の値として採用するか」(または、パースエラーになるか)というものです。以下のJSON文字列を考えてみましょう。
{"username": "toyojuni", "username": "admin"}
多くのライブラリでは、この文字列をパースして"username"を参照したとき、"admin"が返ることになると思います。試しに今あなたの使っているブラウザの開発者メニューを開いてコンソールから JSON.parse('{"username": "toyojuni", "username": "admin"}')
を実行してみてください。実際に、legacyで使われているRubyのJSONパーサーでは、そのような処理が行われます。
他方で、いくつかのライブラリでは、最初にマッチしたものを"username"のlookup結果にする場合があります。例えば今回のGoのjsonライブラリjsonparserがそうです。すなわち、{"username": "toyojuni", "username": "swallow"}
というJSONをパースすると、apiサーバーではusername=toyojuni、legacyサーバーではusername=swallowとして解釈されるということになります。
以上の方法により、apiとlegacy間で異なるusernameを含むものとして解釈されるJSON文字列の作り方は分かりました。あとは以下のWAFを回避すれば、この問題を解くことができます。
func waf(data []byte) bool { return bytes.Contains(data, []byte("admin")) }
このWAFは、"admin"という文字列が、JSONのデータの中に含まれているかをJSONの構造を見ないで確認しています。これにより、単なる {"username": "toyojuni", "username": "admin"}
のようなデータはこのWAFに引っかかってしまうことになります。目標は、文字面上は"admin"とは並ばないが、JSONパーサーを通した後"admin"という文字列になるようなデータを配置することです。
このWAFを回避するのは色々な方法があります。例えば、apiとlegacyで使われているJSONパーサーの実装の違いを用いる方法です。以下のJSON文字列を考えます。
{"username": "toyojuni", "username": "a\dmin"}
このJSON文字列には、内部に不正なバックスラッシュ文字 \d
が存在します。このような文字があるときに何が起こるかはパーサーの挙動に依存しますが、RubyのJSONパーサーでは単にこのバックスラッシュを無視します。結果として上の"a\dmin"の部分は"admin"としてlegacy側では解釈されて、無事WAFを回避することができます。他の方法としては、ユニコード文字表現"\u..."を用いて、"admin"のかわりに"\u0061dmin"などと書くことなども考えられます。
問題の難易度
事前のレビューでは、バグのシンプルさから難易度を「ふつう」と評価しました。しかし、今回の問題は、ソースコード量が比較的多く、複数の異なる言語で書かれたアプリケーションが協調する形で全体のサービスが実装されているため、全体像を把握することに苦労を要する問題だったと思います。したがって、バグやペイロード自体は過去のクイズと比べても比較的シンプルですが、難しめの問題になっていたと思われます。
Quizのような脆弱性をつくらないために
一般的な問題点として、ユーザーから受け取ったデータをサニタイズせず使いまわしている点は問題と言えるでしょう。
data, err := io.ReadAll(c.Request().Body) if err := handleSession(c, data); err != nil { return err } resp, err := http.Post("http://"+legacyHost+"/result", "application/json", bytes.NewBuffer(data))
ユーザーの入力を信頼せず、必要に応じてバックエンドに送信するJSONのデータを改めて再構築する方が、効率こそ落ちるものの、一般には正しいプラクティスだと言えます。
また、デベロッパーとしてはJSONライブラリ間には実装に違いがありうることを認識しておくことも大切です。特に、上述のように複数の同一キーがJSONに含まれる場合にどのような挙動をするのかなどは知識として知っておくと良い場合がありそうです。
終わりに
Flatt Securityではセキュリティエンジニアを積極採用中です。「参考までに説明だけ聞いてみたい...」といった形でも大丈夫なので、ぜひカジュアル面談などご活用ください。
▼ 採用情報はこちら
▼ カジュアル面談はこちら