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の投稿時には以下の処理によりユーザー認証を行う。

  1. ユーザーが投稿ボタンを押下する
  2. ブラウザがAPIサーバーからcsrf tokenを取得する(この際、Cookieにはサーバーにより署名(正確にはHMAC-SHA256)されたユーザー名が含まれる)
  3. APIサーバーはCookieの署名を検証し、問題なければユーザー名に紐づいたcsrf tokenを発行する
  4. ブラウザは受け取ったcsrf tokenと投稿内容をAPIサーバーに送信する
  5. 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閲覧時の処理を整理する。

  1. ユーザーがhttps://milk.chal.seccon.jp/notes/{NOTEID}にアクセスする
  2. nginxは内部的にリクエストをhttps://milk.chal.seccon.jp/note.php?id={NOTEID}&_={RAND}に書き換える(RANDはnginxにより生成される)
  3. フロントエンドサーバーは<script src=https://milk-api.chal.seccon.jp/csrf-token?_={RAND} defer></script>を含むhtmlを返す
  4. ブラウザはhttps://milk-api.chal.seccon.jp/csrf-token?_={RAND}からcsrf tokenを取得する
  5. ブラウザはcsrf tokenとURLに含まれるNOTEIDをAPIサーバーに送信する
  6. 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}&_=AAAAAAAAAAAAAAAcsrf 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}