Webアプリケーションに対する攻撃手法まとめ(0.5 MongoDB インジェクション)

0.5 MongoDB injection

前回の記事でNode.js+MongoDBなら普通は脆弱性出ないから気にしなくていいですよね、等と書いたのですが、
僕自身が今までフツーに脆弱なコードを書いていました・・・。まずはその話から。

概要

Node.js+MongoDBでMongoDBのドライバを使った時、「SQL文」のように文字列でクエリを投げる事はありません。普通にJavaScriptの文法として、クエリを書く事ができます。
これはすなわち、エスケープ処理のようなものは必要なくなり、
たとえばある文字列で検索するようなクエリを投げるとき、外部からどんな変な文字列を受け取ろうとも、そのままその文字列で検索できる、ということです。

そう、文字列ならね………

脆弱性のある状況

Node.jsはそれがそもそもWebサーバとして動くのですが、
普通は、最低でもExpressという薄いフレームワークをかぶせます。(最近はExpressより厚いフレームワークも出てるみたいですが)
Expressでのコントローラメソッドは、第一引数にRequestをとり(普通、仮引数名はreqとします)、送られてきたデータは以下のように取得できます。

// GETの例
app.get('/', function(req, res) {
  // GETのパラメータはreq.queryに入る
  console.log(req.query);
});
// POSTの例
app.post('/', function(req, res) {
  // POSTのパラメータはreq.bodyに入る
  console.log(req.body);
});

それでは実際に、/loginにPOSTで name, passのパラメータを送って、ログインをするコードを書いてみます。
途中のdbアクセスは、node-mongodb-nativeを使っているつもりです。

// ログイン済の人のみ / を許可。まだの人は/loginにリダイレクト
app.get('/', function(req, res) {
  if (!req.session.user) {
    return res.redirect('/login');
  }
  res.render('index'); // htmlを送る処理
});

// ログイン
// 成功したら / にリダイレクトする
app.post('/login', function(req, res) {
  if (!req.body.name || !req.body.pass) {
    // 必要なパラメータがないため、再度ログイン画面へ
    return res.redirect('/login);
  }
  // このname, passをもつユーザがいるか検索。
  // いればそのユーザでログイン成功。いなければログイン失敗、再度ログイン画面へ。
  db.collection('users').findOne({ name: req.body.name, pass: req.body.pass }, function(err, user) {
    if (err) {
      return res.redirect('/error');
    }
    if (!user) {
      // nameとpassが一致したユーザがいなければ、ログイン不可
      return res.redirect('/login');
    }
    // ログイン成功!
    req.session.user = user;
    res.redirect('/');
  });
});

長々と書きましたが要はここです。

db.collection('users').findOne({ name: req.body.name, pass: req.body.pass }, function(err, user) {

POSTで送られてきたパラメータnameとpassを利用して、ユーザを1件取得するだけです。
先も書いたように、パラメータの値にどんな文字列が来ても、その文字列が値として入るだけなので問題なく動くように見えます。

攻撃手法

MongoDBのクエリでは、次のように、値にオブジェクトを指定することができます。

{ age: { $gt: 25 } } // 25歳より上
{ type: { $ne: 'normal' } } // typeがnormalではないもの 

このようにしてMongoDBは柔軟なクエリを表現することができます。

一方、req.paramやreq.bodyは、送られ方によって中がオブジェクトに展開されます。
例えばリクエストのボディを

name[foo]=1234&name[bar]=5678&pass[baz]=90

のようにすれば、サーバ側でreq.bodyは

{
  name: {
    foo: '1234',
    bar: '5678',
  },
  pass: { baz: '90' }
}

のようにオブジェクトに直してくれるわけです。このオブジェクトに関しては任意のkey・valueの組み合わせを送る事が出来ます。

それでは、req.bodyが以下のような形になっていたらどうでしょうか?

{ name: { $ne: 'どんなユーザとも一致しないユーザ名' }, pass: { $ne: '一致しないぱすわーど' } }

neはnot equalですから、この条件でfindすると、全員と一致することになります。
今回はfindOneなので、誰かしら1人が選ばれ、そのユーザとしてログイン(なりすまし)が可能となってしまうのです。。

つまり、req.body.nameやreq.body.passにオブジェクトが来た場合、それをチェックせずに通過されてしまうとインジェクションになるのです。

これは気付きにくい問題でした。SQLと違って文字列でクエリを組み立てているわけでもなく、クエリ自体がjsのオブジェクトだから安全と思いきや、
さらに中身がオブジェクトで来られると困るとは・・・。

対策

この対策はとりあえず簡単で、文字列であることを確認すればよいだけです(typeof req.body.name === 'string' でとりあえず良いでしょうか)。

しかし、毎度typeofとか_.isStringするのはそれなりに面倒だし、普通に忘れそうですよね…。
うーん、なんか良い方法ないでしょうか?忘れずにやるしかないのかな…