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
- SECCON 2020 Online CTF - Milk - Author’s Writeup - HackMD
- beginners-capsule.js
- レースコンディションを狙って、SCRFトークンを先に使う
- SECCON 2020 - Web
- CSPを無効化してXSSする
- SECCON 2020 Online CTF の write-up - st98 の日記帳
- javascriptスキームを使用している。なるほど
やっと理解できた…
ちゃんと試すべきなんだけど、試すのめんどいな…
CSRFトークンを奪う
以下の流れで攻撃を行う。
「手順1で踏ませたCSRFトークンを取得」の部分だが、これはAPIについてはキャッシュが効いているので、それを利用する。
つまり、adminに踏ませた全く同じURLを再度こちらでも呼べば、同じCSRFトークンが得られる事になる。
手順を細分化しよう。
- adminにCSRFトークン発行URLを踏ませる
- 手順1で踏ませたURLを再度リクエストして、キャッシュされたCSRFトークンを取得する
- 取得した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が分かる状況が生まれる。
なるほどー!!!
手順に立ち返ろう
- adminにCSRFトークン発行URLを踏ませる
https://milk.chal.seccon.jp/notes/XXX?_=YYY
- 手順1で踏ませたURLを再度リクエストして、キャッシュされたCSRFトークンを取得する
https://milk-api.chal.seccon.jp/csrf-token?_=YYY
- 取得した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解法を潰すような問題になっている。