JavaScript
Node.js
CTF
m1z0r3Day 6

チーム内CTFで作問した話 - プロトタイプ汚染攻撃編

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

以前、 Node.jsにおけるプロトタイプ汚染攻撃とは何か - ぼちぼち日記 という記事を読んで「CTF の Web 問で使えそうだなぁ」と思ったので、先日チーム内で開催した CTF に出題してみました。

本記事では、どのような問題を作成したかと、作問にあたり大変だったことを書きます。
なお、ソースコードは下記にあるので、試したい場合は README のようにセットアップしてみてください。

プロトタイプ汚染攻撃とは?

Node.js におけるプロトタイプ汚染攻撃に関しては、以下の記事に詳しく書いてあります。

そもそも JavaScript はプロトタイプベースの言語です。次の例を見てみましょう。

Object.prototype.hello = function() {
  console.log('Hello');
}

const hoge = {}
console.log(hoge instanceof Object) // -> true
console.log(hoge.__proto__ === Object.prototype) // -> true

hoge.hello() // -> Hello

上記では hoge には hello() というメソッドは定義されていませんが、 hoge.hello() によって Hello が表示されます。
実は hoge を定義した時に、自動で hoge.__proto__ というプロパティが生成され、そこに Object.prototype への参照がセットされます。

そして hoge.hello() を呼び出した時、 hogehello というプロパティがない場合には、 hoge.__proto__.hello があるか見に行きます。 hoge.__proto__Object.prototype と同一なので、 Object.prototype.hello が呼び出された、というわけです。

逆にこれを利用して、プロトタイプを汚染することもできます。

const hoge = {}
hoge.__proto__.polluted = 1

const fuga = {}
console.log(fuga.polluted) // -> 1

hoge.__proto__Object.prototype と同一なので、 hoge.__proto__.polluted = 1 というのは Object.prototype.polluted = 1 としたのと同じです。

結果として hoge のプロトタイプを操作しただけで、別のオブジェクトである fuga のプロパティが変わってしまいました。これを プロトタイプ汚染 と言います。

Node.js におけるプロトタイプ汚染攻撃とは、外部からプロトタイプ汚染を起こし、 Node.js 環境の Web サーバーに攻撃することを指します。

問題の概要

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

image.png

SPA でできており、フロントエンドは React、サーバサイドは Express で書かれています。また、ソースコードも公開して解いてもらいました。

2つ目の FLAG に関わるサーバサイドのコードが下記です(見やすさのために一部改変)。

import * as bodyParser from 'body-parser'
import * as express from 'express'
import { clone, hashPassword } from './utils'
...

const app = express()
app.use(bodyParser.json())
...

app.post('/api/login', async (req, res) => {
  const params: LoginQuery = clone(req.body)
  User.findOne({
    attributes: ['id', 'name'],
    where: { name: params.name, password: hashPassword(params.password) },
  })
    .then(instance => {
      if (instance) {
        const user: any = instance.get()
        req.session!.user = user.id
        if (user.name === user.admin) {
          res.send(process.env.FLAG2)
        } else {
          const result: LoginResult = { success: true, data: { name: user.name }, errors: [] }
          res.json(result)
        }
      } else {
        const result: LoginResult = { success: false, data: null, errors: ['Invalid name or password'] }
        res.json(result)
      }
    })
    .catch(_ => {
      const result: LoginResult = { success: false, data: null, errors: ['Invalid name or password'] }
      res.json(result)
    })
})

/api/login というエンドポイントで、POST された name と password からユーザをデータベースから探してきて、存在すればログイン成功、存在しない場合はログイン失敗、というのが基本的な処理です。

そして、ユーザが存在してかつ user.name === user.admin の場合のみ、FLAG が手に入ります。
ただし、 User テーブルに admin というカラムは存在しないため、普通に考えると user.adminundefined のため、どうやっても FLAG は手に入りません。

解答/解説

2 行目の const params: LoginQuery = clone(req.body) の中の clone という関数が Node.jsにおけるプロトタイプ汚染攻撃とは何か - ぼちぼち日記 に出てくるものと同様で、 ディープコピーする際に __proto__ もコピーできてしまいます。

そこで、以下のような JSON を POST すると、 clone により Object.prototype.admin"hoge" となり、 user.name === user.admin === "hoge" となるため FLAG が手に入ります。

{
  "name": "hoge",
  "password": "fuga",
  "__proto__": {
    "admin": "hoge"
  }
}

苦労した点

一度プロトタイプが汚染されると、想定外のところで副作用を起こしてクラッシュする(新規ユーザを作成する際や、テンプレートエンジンを使う場合にはレンダリングする際にクラッシュする)ので、誰かが攻撃に成功すると毎回手動で再起動しなきゃ行けないです。

本格的に作問する前に PoC の段階で気が付いてたのですが、まあチーム内 CTF 程度の規模感なら毎回手動で再起動で大丈夫だろうと思って作問しました。(結果としては全然大丈夫でした)
ただ、オンラインの CTF で出題する際には何か工夫が必要だろうなぁと思いました。

また、最初は user.admin が true の時に FLAG が手に入るようにしようかなと思ったのですが、そうすると誰かが攻撃に成功した瞬間に他の人も( user.admin が true になってしまい) FLAG が手に入ってしまうため、 user.name === user.admin という少し変な条件にしました。

参考