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

前置き

会社で1ヶ月ほど技術研修を受けましたので、その整理をするつもりで記事を書き始めましたが、
文にしようとすると自分の分かっていないところが諸々出てくるため、結局深彫りして調べる必要があり、結果書くのに要する時間も文量も予定の4倍くらいになっています・・。まだまだ沢山勉強しなければならないなと感じています。
主目的は自分の勉強ですが、なるべく間違った事を世に発信しないよう心がけております。
最初に1度だけ言い訳をすると、業務で実際に諸々の問題に直面したことがまだない(まだそのレベルに達していない)ため、自分の考えが及んでいないことが多々あると思います(もっと複雑な要件に対してはどうするんだ等)。
ご指摘を真摯に受け止めて、記事自体も正しく修正していきたいと思いますので、ぜひコメントの方に忌憚なくお書きいただけませんでしょうか。よろしくお願いいたします。

0. SQL injection

攻撃可能になる条件

動的にSQL文を作成する、すなわち、外部からの入力をSQL文を埋め込んでいるときに起こり得ます。
例えば、asdfという名前の本をbooksテーブルから検索するSQL文は、例えば次のようになります:

SELECT name, author FROM books WHERE name = 'asdf';

一般的には'asdf'は、例えば検索フォームに入力されて送信されてくるなど、外部から入力される値でしょう。これを、例えばサーバサイドのコードがJavaScriptだったとすれば(Node.jsを想定)、単純に書けばこうなります。

// name はリクエストで受け取った値。(asdf)
// SQL文を動的に生成
var sql = "SELECT name, author FROM books WHERE name = '" + name + "';"
// このSQL文でDBに問い合わせる

そして、こう書いた瞬間にSQL injectionの脆弱性が発生します。

攻撃手法

上の例で、サーバは攻撃者より、nameとして次のような値を受け取りました。

asdf' UNION SELECT name, password FROM users; --

(※最後にスペースが入っています)

すると、合成された結果SQL文はこうなります:

SELECT name, author FROM books WHERE name = 'asdf' UNION SELECT name, password FROM users; -- '

最後の-- はこの行のこれ以降をコメントにする意味で(JavaScriptの//と同じ)、最後の'を消して文法エラーを避けています。
asdfという名前の本とともに、usersテーブル全件のname, passwordを引き出していることになります。
(実際にnameやpasswordというキー名ではないかもしれませんが、この前にテーブル情報を引き出す攻撃を行うことができてしまいます)

対策(結論からいうと)
  • 単にプリペアドステートメントを使え
  • 絶対に文字列結合でSQLを構築しようとしてはいけない
  • IPAの「安全なSQLの呼び出し方」を読むこと
http://d.hatena.ne.jp/ajiyoshi/20100409/1270809525
対策

自前でエスケープ処理を作成するべきではありません。
基本的には、最も優先して採るべき方法は次の1つです:
◎Prepared Statementを使う。

私が普段Node.jsを利用していますので今回はNode.js+MySQLを利用したサンプルを挙げていきます。
ただし、ここで挙げる例は正確にはPrepared Statementではありません。書き方は同じです。これについては後述します。
以下はNode.js+MySQLドライバ(GitHub - mysqljs/mysql: A pure node.js JavaScript Client implementing the MySQL protocol.)でPrepared Statementモドキを使った例です。

var name = 'asdf'; // 実際は外部から入力される値
// mysqlClientは、mysqlドライバのcreateConnectionで作成されたオブジェクトとする
// 代入したい部分に、 ? を使う
var preparedStatement = 'SELECT name, author FROM books WHERE name = ?;' ;
mysqlClient.query(preparedStatement, [name], function(err, books) {
  if (err) throw err;
  console.log(books); // [{ name: 'asdf', author: 'qsona' }, {name: 'asdf', author: 'someone' }]
});

複数の値を入れたいときは、?を複数使えます。その時は、mysqlClient.queryの第2引数の配列に複数入れます(入れる順番通りに)。

注意点1: Prepared Statement自体を文字列連結等で作ってはいけない

Prepared Statementは、SQL文においてリテラル以外を自分の手で作っている(外部の入力が入る余地はない)から安全なわけです。
ですからいくらPrepared Statementを使っていても、それ自体を動的に生成してしまっては、そこにSQLインジェクションが入る余地が生じてしまいます。

注意点2: LIKE検索

リテラルの部分、実際のSQL文では'asdf'とシングルクォートで囲まれるところ、Prepared Statementの文では '?' と書かずに単に ? と書きます。
ここで、LIKE文を使う時には少し問題になりそうです。
すなわち、例えば書名がasdfから始まる本を探したいときのクエリは

SELECT name, author FROM books WHERE name LIKE 'asdf%'

となります。これを実現したいとき、PreparedStatementはどう書けばよいでしょうか?次の2つは両方誤りです:

var wrongPreparedStatement1 = "SELECT name, author FROM books WHERE name LIKE ?%;" ;
var wrongPreparedStatement2 = "SELECT name, author FROM books WHERE name LIKE '?%';" ;

最初の例では ? に 'asdf' が入ったことを考えれば、上の2つは誤りであることが理解できると思います。とりあえず動くサンプルとしては次のようになります。

var name = 'asdf';
var preparedStatement = 'SELECT name, author FROM books WHERE name LIKE ?;' ;
// 入れる文字列の方に % をつける
name2 = name + '%';
mysqlClient.query(preparedStatement, [name2], function(err, books) {
  // 略
});

SQLインジェクション対策とは別の問題として、
この例では書名に%が使われたときに困ります(実際には%の他に_も意味を持ちます)。

var name = 'いちご100%';
name2 = name + '%'; // 'いちご100%%' となってしまう。書名の一部であったはずの%も、ワイルドカードと認識されてしまう

上の例は、'いちご100個'などでもひっかかってしまいます。

というわけで、こういう場合はエスケープが必要になります。
MySQLならデフォルトのエスケープ文字は \ とのことですから、エスケープ必要な文字を単に\から始まるものに置換すればよいでしょう。
(なおSQL文ではエスケープ文字を指定できます。上のSQL文に続けて escape '%'とかけば%がエスケープ文字となり、いちご100%%はいちご100%の完全一致となります。)

補足すると、この方法はエスケープの処理如何に関わらず、安全です。すなわち、エスケープ処理を仮にミスって期待通りでない結果を返すことがあったとしても、SQLインジェクションは発生し得ないということです。
PreparedStatement自体に文字列連結等をしているわけではなく、それに代入するパラメータをいじっているだけだからです。

注意点3: 柔軟な検索

次のような状況はどうでしょうか。
(入力フォーム)
著者名 [ ]
書名 [ ]
価格 [ ] 以上 [ ] 以下
[検索]

この時、先と同じように考えれば次の例のようになります:

var preparedStatement =
  'SELECT * FROM books WHERE author = ? AND name = ? AND price BETWEEN ? AND ?;' ;
mysqlClient.query(preparedStatement, [author, name, price_low, price_high], function(err, books) {
  // ...

しかし、これで良いと考えるのはさすがにナイーブすぎます。
一般的に検索において、著者名の欄を空欄にしたら、著者名が本当に空文字列なものを検索したいわけではなく、なんでも良いという意味なはずです。
ですから、著者名が空なときは、そもそもWHERE句自体が不要になるわけです。

ではどうしたら良いかというと、MySQLのエキスパートの方のブログには以下のように記述されています:

そこで、我々は選択を迫られる。プリペアドステートメントの元になる文字列を動的に組み立てるか、プリペアドステートメントを用いずにクエリを動的に組み立てるか、である。

http://nippondanji.blogspot.jp/2013/12/sql.html

これはエキスパートしか採ってはいけない選択肢であって、私のような初心者が採るべき方法ではありません。それを承知で、たとえば前者の例を挙げるなら以下のようになります。

var badPreparedStatement = 'SELECT * FROM books';
var conditionalStatements = [], keys = [];
if (author) {
  conditionalStatements.push('author = ?');
  keys.push(author);
}
if (name) {
  conditionalStatements.push('name = ?');
  keys.push(author);
}
if (price_low) {
  conditionalStatements.push('? <= price');
  keys.push(price_low);
}
if (price_high) {
  conditionalStatements.push('price <= ?');
  keys.push(author);
}
// 各条件をANDで結合して、PreparedStatementを作成
if (keys.length) {
  badPreparedStatement += ' WHERE ' + conditionalStatements.join(' AND');
}
badPreparedStatement += ';' ;

badと書きましたが、おそらくこの段階ではSQLインジェクション脆弱性は発生していないと思いますし、おそらく正しくないSQL分が組み立てられるバグもないと思います。(多分)
しかし、繰り返しになりますがこのような選択肢は最後に考えるべきです。
具体的には、以下のような方法でも実現できないでしょうか。

// price_low, price_highは未入力の場合-1に初期化しておく
var preparedStatement =
  'SELECT * FROM books WHERE (? = '' OR author = ?) AND (? = '' OR name = ?) AND (? = -1 OR ? <= price) AND (? = -1 OR price <= ?);' ;
mysqlClient.query(preparedStatement, [author, author, name, name, price_low, price_low, price_high, price_high], function(err, books) {
// ...略
});

今回は空文字列なら条件指定なし、というふうにしましたが、検索するかどうかのboolean値の変数を別に用意して使っても良いでしょう。
パフォーマンス的には、検証してみないと分かりません。後述する正しい意味でのPreparedStatement(静的プレースホルダ)が使えるなら、キャッシュされるため良いということになると思います。動的プレースホルダの場合は、余計な文字列連結する方法に若干劣るかもしれません(誤差レベルだろうとは思うのですが)。

静的プレースホルダ(PreparedStatement)と動的プレースホルダ

安全なSQLの呼び出し方(pdf)から一部引用します。

静的プレースホルダは、JIS/ISO の規格では「準備された文(Prepared Statement)」と規定されています。これは、プレースホルダのままの SQL 文をデータベースエンジン側にあらかじめ送信して、実行前に、SQL 文の構文解析などの準備をしておく方式です。SQL 実行の段階で、実際のパラメータの値をデータベースエンジン側に送信し、データベースエンジン側がバインド処理します。

http://www.ipa.go.jp/files/000017320.pdf

動的プレースホルダは準備された文(Prepared Statement)とは異なり、プレースホルダを利用するものの、パラメータのバインド処理をデータベースエンジン側で行うのではなく、アプリケーション側のライブラリ内で実行する方式です。

http://www.ipa.go.jp/files/000017320.pdf

そして、静的プレースホルダはセキュリティ的に最も安全であり、一方動的プレースホルダはライブラリの実装に依っては脆弱性があることを否定できないとしています。

この記事ではNode.jsの例を紹介していましたが、使ったドライバの説明には、プレースホルダの説明の後に次のような記述があります:

This looks similar to prepared statements in MySQL, however it really just uses the same connection.escape() method internally.

MySQLのPrepared Statementと同じ書き方だが、実際にはライブラリの中でエスケープしていますよ、ということです。
ソースを見てみると確かに、ライブラリ内で正規表現を利用して置換処理を行っていました。
すなわちこれは動的プレースホルダであり、書き方こそ同じですがこれはPrepared Statementではないということになります。

他の例を挙げれば、JDBCドライバのPreparedStatementでは、設定で静的プレースホルダか動的プレースホルダか変更できるということです。

本物のPrepared Statment(静的プレースホルダ)を利用する場合、それはそもそもエスケープ処理ですらなく、原理的に安全な方法を採っていることになります。
そうでないものを使う場合、ライブラリをしっかり検証してから使う必要があると思います。

Node.js+MongoDBならこんな気を使う必要ないよね

余談ですが、僕は普段自分の用途ではMongoDBを使っています。MongoDBのクエリはそもそもJavaScriptであるため、Node.jsからMongoDBへクエリを発行する際はこのような変換が必要ありません。そういった意味では、この組み合わせはラクなだけでなく安全でもありますよね。蛇足感がすごい。

追記

Node.js+MongoDBなら安心とか書いたところ、いやいやそんなことないという突っ込みを頂きました。で、その内容なのですが、僕が普通に見落としていたもので、つまり最近まで僕は普通に脆弱なプログラムを書いていたということで、穴があったら入りたい系です、辛いです。後できちんと書きます…。

参考文献

[SQLインジェクション対策]Webアプリケーションとかの入門本みたいのを書く人への心からのお願い。 - *「ふっかつのじゅもんがちがいます。」withぬこ
安全なウェブサイトの作り方:IPA 独立行政法人 情報処理推進機構より、安全なウェブサイトの作り方、安全なSQLの呼び出し方
漢(オトコ)のコンピュータ道: SQLインジェクション対策に正解はない
java - SQL prepared statement how to select via multiple possible menu selections? - Stack Overflow
他多数のWeb上のブログを参考にいたしました。
ただし、本記事に誤った記述があったとしても、それらは参考文献によるものではありません。
誤りがある場合、コメントにてご指摘頂けると大変有り難く思います。