はまやんはまやんはまやん

hamayanhamayan's blog

Milk [SECCON 2020 Online CTF]

Milk
Sadly not every developer is well posted in the cutting-edge technologies, so sometimes bizarre interface is driven under the hood.
https://milk.chal.seccon.jp/
milk.tar.gz
Update(2020/10/10 17:38): We disclosed a part of our crawler as a hint. crawl.js

調査

適当にログインユーザーを作ってログインしてみると、memoryが残せる機能が提供される。
適当に文字を入れてsubmitしてみると、/note/なんらかのIDに飛ばされてREPORT-TO-ADMINとなる。
ログイン情報はusernameとしてLocal Storageで保存されている。
とりあえず了解。

HTTPレスポンスを見てみる。
X-Powered-By: PHP/7.4.11

Cookie使ってないと思ったら、違うドメインCookie使われてた。

set-cookie: username=evilman2020; path=/; SameSite=None; Secure; httponly
set-cookie: username.sig=X4lZeL2gNWApCNPJqMhaW2whtR3UwFAVSsX6tw6pYQA; path=/; SameSite=None; Secure; httponly

ソースコード見てみる

nginx.conf

add_header Content-Security-Policy "default-src 'none'; base-uri 'none'; style-src * 'unsafe-inline'; font-src *; connect-src https://milk-api.chal.seccon.jp; script-src 'self' https://milk-api.chal.seccon.jp https://code.jquery.com/jquery-3.5.1.min.js 'sha256-xynbUFfxov/jB5OqYtvdEP/YBByczVOIsuEomUHxc0U=';" always;

adminに見せるサイトにはCSPがかかっている。

api/notes.ts

router.get('/flag', async (ctx) => {

ここへ誘導して、出力を受け取ればいいらしい。
適当な所でawait api('/notes/flag');してみると、403エラーになる。OK

XSSを試すが、どこでも何もしていない。
でもやってみるとhtmlが発火しない。
よくわからない。
document.getElementById('body').textContent = data.note.body;
jsのこの段階のdata.note.bodyにもタグがそのまま入っている。

Node.textContent - Web API | MDN

そうなのか、textContentを使えばHTMLとして解析されないのか。
ほほう。
適当に<s>test</s>という名前のユーザーを作ってみたが、これもダメ。
んー、どう見ても怪しいLocalStorageを使うんだろうなぁとは思うが…

そもそも、/notes/flagを参照しようにも、トークンが無いからトークン用意から何とかしてやらんといかんな…

Writeups

やっと理解できた…
ちゃんと試すべきなんだけど、試すのめんどいな…

CSRFトークンを奪う

以下の流れで攻撃を行う。

  1. adminにCSRFトークン発行URLを踏ませる
  2. 手順1で踏ませたCSRFトークンを取得して、それを使って/notes/flagを読み取る

「手順1で踏ませたCSRFトークンを取得」の部分だが、これはAPIについてはキャッシュが効いているので、それを利用する。
つまり、adminに踏ませた全く同じURLを再度こちらでも呼べば、同じCSRFトークンが得られる事になる。
手順を細分化しよう。

  1. adminにCSRFトークン発行URLを踏ませる
  2. 手順1で踏ませたURLを再度リクエストして、キャッシュされたCSRFトークンを取得する
  3. 取得したCSRFトークンを使って/notes/flagを読み取る

手順1について

手順1であるが、https://milk-api.chal.seccon.jp/csrf-token?_=XXXXXXXXXXXXXのようなURLを踏ませれば良さそう。
試しに自分で踏んでみると、Referer header is not setと言われてしまう。
誰に止められているんだろうか。

index.tsにバリデーションがあった。
なるほど、これか。
Refererの偽装はできないので、何とかならないか…

// Referer validation
app.use(async (ctx, next) => {
  const referer = ctx.request.headers.get('referer');
  if (!referer) {
    ctx.response.body = 'Referer header is not set';
    ctx.response.status = 400;
    return;
  }

  // @ts-ignore
  const refererUrl = new URL(normalizeUrl(referer));
  if (refererUrl.host !== 'milk.chal.seccon.jp') {
    ctx.response.body = 'Bad Referer header';
    ctx.response.status = 400;
    return;
  }

  await next();
});

手順1改 adminに「意図した」CSRFトークン発行URLを踏ませる

note.phpを見ると、

<script src=https://milk-api.chal.seccon.jp/csrf-token?_=<?= htmlspecialchars(preg_replace('/\d/', '', $_GET['_'])) ?> defer></script>

とあり、GETリクエストで_を指定すると、任意のCSRFトークン発行URLを踏ませることができるようになっていた!!
なので、https://milk.chal.seccon.jp/notes/XXX?_=YYYとすると、
https://milk-api.chal.seccon.jp/csrf-token?_=YYYを踏みに行ってくれるので、
adminにCSRFトークン発行URLを踏ませて、
かつ、そのURLが分かる状況が生まれる。
なるほどー!!!

手順に立ち返ろう

  1. adminにCSRFトークン発行URLを踏ませる https://milk.chal.seccon.jp/notes/XXX?_=YYY
  2. 手順1で踏ませたURLを再度リクエストして、キャッシュされたCSRFトークンを取得する https://milk-api.chal.seccon.jp/csrf-token?_=YYY
  3. 取得したCSRFトークンを使って/notes/flagを読み取る

手順2で直接踏みに行ってReferer大丈夫という感じだが、キャッシュされたものを持ってきているので、バリデーションは走らず、問題ない。
あとは、奪ったCSRFトークンを使ってhttps://milk-api.chal.seccon.jp/notes/flagを踏む。
自分で踏む分にはRefererは偽装し放題なので、これでACだ!(htmlならタグでそんなやつがあったはず)

と思いきや

CSRFトークンはワンタイムパスワードで1度使ったら無効化されてしまう。
よって、手順1で踏ませて発行させてもすぐに使われて無効化されて、手順2で読み込んで手順3で使う頃には使えなくなっている。
なんとかならないか?

やり方1 レースコンディションで無理矢理使う

手順1を発行している裏で手順23を行う。
手順1でCSRFトークンが発行されたら、使われる前に手順23で使っちゃおうという作戦。
posix神はそれでやってる。 ここ

やり方2 CORSを上手く使う

想定解法。
手順1でCSRFトークン発行URLは踏ませるのだけれど、使われないようにする手段。
結論から言うと、https://milk.chal.seccon.jp./notes/XXX?_=YYY crossorigin=use-credentialsを踏ませる。

こうすることでCORSで付けているadd_header Access-Control-Allow-Origin https://milk.chal.seccon.jp always;が反応し、
https://milk.chal.seccon.jp.からのアクセスということで『異なる』と判断し、スクリプトの実行を止めてしまう。
止めるということはJSONPの応答が行われなくなり、結果、CSRFトークン発行URLは踏まれるが、使用されなくなる状況が生まれる。
あとは、ゆっくりキャッシュを拾って、フラグを取るだけ。

他のやり方

公式Writeupには他にもいくつかやり方が紹介されている。
どれも賢い。

  • charsetを指定することで応答されたスクリプトの中身をぐちゃぐちゃにして、実行させなくする方法
  • deferを上手く消して処理順を変更することで、コールバック関数の呼び出しを無効化する

全く違うアプローチ

オーソドックスにXSSする方法もある。
上でCORSを上手く使うやり方でも属性をXSSしていたが、CSPを上手くやり過ごしてjsを実行する方法もある。
公式Writeupここペイロードが紹介されている。
javascriptスキームを使う方法もある。

ちなみにMilk Revengeではソースコードのロジック変更は無く、crawl.jsだけが変更されていて、XSS解法を潰すような問題になっている。