SQLインジェクション(2)

実際の攻撃手法とその影響

具体的にSQLインジェクション攻撃はどういうふうに行われるのかを見ていきます。

データが書き換えられる例

ユーザーを検索する実装を以下のように書いていたとする。

$sql = "SELECT * FROM users WHERE name = '$name'";
$ps = $db->query($sql); //クエリ実行処理

ユーザーから名前の入力を受け取って、それをそのままSQL文の中に変数として入れている。

ユーザーが普通に検索したいユーザー名を入力すれば何の問題もないが、たとえば悪意のある攻撃者が以下のような入力をしたとする。

; DELETE FROM users WHERE id >= '1

するとこの入力が埋め込まれて実際に処理されるクエリは以下のようになる。

$sql = "SELECT * FROM users WHERE name = '; DELETE FROM users WHERE id >= '1'";
$ps = $db->query($sql); //クエリ実行処理

元々あったSELECT文が早々に終わってしまい、その後のDELETE文が実行されてしまう。

SQLインジェクションの脆弱性があると既存のデータが全部消去されるなんてことにもなりかねません。

認証を回避される例

認証をチェックするためのSQL文が以下のようにあったとします。

$sql = "SELECT * FROM users WHERE name = '$name' AND password = '$password'";

このSQLで該当レコードがあった場合に認証完了とするロジックだった場合、nameに適当な名前(hoge)、パスワードに以下のようなものを入力したとします。

' OR 'a' = 'a

これだけだと意味不明だが、挿入してみると理解できると思います。
その結果、SQLの全文は以下のようになります。

$sql = "SELECT * FROM users WHERE name = 'hoge' AND password = '' OR 'a' = 'a'";

これはpasswordが何もない or ‘a’ = ‘a’のどちらかを満たす場合に成立するSQL文になっています。

当然’a’ = ‘a’は常に成立します。
これにより、名前とパスワードを知らなくてもこれを入力することで認証が完了してしまいます。

他にも例はありまするが、これがSQLインジェクション攻撃の手法の例です。

解説

ここまでに見てきた脆弱性の原因を探ると、文字列リテラル「’」が適切に処理されていないことが原因と言える。

攻撃手法はいずれも' OR 'a' = 'aのような中途半端な入力になっています。
これによって元々組み込まれている文字列リテラルを無理やり終わらせて、別の意味をもつSQL文を付与させることができてしまいます。

対策について

前項で見てきたようにユーザーの入力値が文字列リテラルをはみ出すことができる仕様だと、SQL文の意味が変わってしまうためまずいことが判ります。
したがってユーザーの入力によってSQL文が変わらない仕組みが必要です。

その方法が以下のようなプレースホルダによるSQL文の組み立てです。

SELECT * FROM users WHERE name = ?;

「?」がプレースホルダで、実際にユーザーが入力したデータは「?」の部分に割り当てられる(バインド)。

結局「?」の部分にバインドされるんだったら同じじゃね?という気もするけど、それは違う。
なぜプレースホルダだと大丈夫なのかを説明するためにSQL文の組み立てとバインドの仕組みを以下で見ていく。

プレースホルダを使わない場合

SQL文組み立てのタイミングとそれがDBエンジンによって処理される流れを図にしたものが以下。

SQL文が確定するのはコンパイルしたタイミングである。
そして上記ではコンパイル前にアプリケーションの方でSQL文が組み立てられてしまうため、
ユーザーの入力によってSQL文の構文そのものが変わってしまう危険性がある。
(これまでに見てきたような文字列リテラルを調整して別のSQLを組み込める)

(静的)プレースホルダ

プレースホルダには静的プレースホルダと動的プレースホルダがあるが、ここでは静的プレースホルダについて説明する。
原理的に静的プレースホルダではSQLインジェクションが起こらないため、セキュリティの文脈では静的プレースホルダの方が望ましい。

静的プレースホルダによってSQL文が組み立てられるタイミングとそれがDBエンジンによって処理される流れを図にしたものが以下。

ポイントはSQL文がまずコンパイルされて、その後にデータがバインドされていること
これによってSQL文そのものがまず確定するので、ユーザーの入力値によってSQLの内容自体が変わることはない。

ちょっとわからなかったのが、本に書いてあった図をパクって作図したんだけど、これだとSQLコンパイル前にSQL文をサーバに送って、その後実パラメータを送っています?
リクエスト2回走るのかな?。。。知ってる人いたら教えてください。

SQL文を動的に変更したい場合

SQL文があらかじめ決まっていなく、ユーザーの入力値によって動的に変更したい場合を考えます。

たとえば、検索フォームで様々な項目の組み合わせでSQL文が変わる場合など。
ユーザーを検索したい場合に、名前で検索するのかメールアドレスで検索するのか、生年月日で検索するのか…
あらゆる項目の組み合わせがあり、これらによってSQL文のwhere句は動的に変わります。

このような場合は、プレースホルダ「?」を含んだSQL文を文字列連結によって動的に組み立てます。
そして、その実パラメータを別で渡してバインドするようにすればよいと思います。

実装的にはパラメータのあるなしによってif文で分岐させて、文字列連結していくイメージです。

Insertの場合のプレースホルダ

SELECT文は上記の通りで実装できます。
ただ、個人でINSERT文をプレースホルダで組み立てようとした時にうまくできなくてハマったので記載しておきます。

環境はNode.js。
mysqlとの接続にはmysql2/promiseを使っている。

ORMを使っていなかったので、以下のようにしてクエリと実パラメータを渡す実装をしています。

const [row] = await connection.query("SELECT * FROM users WHERE user_id = ?", [userId]);

これは問題ないんだけど、バルクインサートで複数データを一気に入れたい場合に詰まっている。
結論、以下のようにするとうまくいきました。

const sql = "INSERT INTO users(name, email) VALUES ?";

const data = [
  ["太郎", "taro@example.com"],
  ["次郎", "jiro@example.com"],
  ["三郎", "saburo@example.com"]
]

await connection.query(sql, [data]);

VALUESの後をそのまま「?」にしてしまって、そこにバインドさせます。
バインドさせる部分は挿入したい”レコードの配列”を配列で持っている入れ子構造にしておきます。

あとはupdateとかdeleteはconnection.executeを使っていますが、insertはconnection.queryでいいらしい。
あと第二引数の[]は必須っぽいです。

これで複数データを一挙に登録できるようになりました。