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}