SQLインジェクションは古くから知られながら、いまも情報漏洩の主要な原因であり続けている脆弱性です。 対策はほぼ確立されていて、プレースホルダ(パラメータ化クエリ)を使えているかどうかでほぼ決まります。
SQLインジェクションとは
SQLインジェクション:ユーザー入力を使ってSQL文を組み立てる箇所に攻撃者が細工した文字列を送り込み、開発者が意図しないSQLを実行させる脆弱性です。 文字列連結でSQLを組む設計が根本原因になります。
攻撃が成立すると、' OR '1'='1 のような入力による認証回避、テーブル全体を対象にした情報漏洩、UPDATEやDELETEを混ぜ込んだデータの改ざんや削除といった被害につながります。
データベースには個人情報や認証情報が集まるため、1箇所の穴が全社的な漏洩に直結します。
UNIONで別テーブルの内容を結果に混ぜ込む、条件の真偽で1文字ずつ抽出するなど手口は幅広く、攻撃を自動化するツールも普及しています。
SQLインジェクションが起きるパターン
典型的な混入箇所は次のとおりです。
- ユーザー入力を文字列連結やテンプレート展開でSQLに埋め込んでいる(
"SELECT * FROM users WHERE name = '" + input + "'") - ORMを使っていても、生SQLを渡すAPI(Railsの
where("... = #{params}")、find_by_sqlなど)に入力を直接差し込んでいる - ORDER BYのカラム名やLIMITの件数など、プレースホルダを使えない箇所に入力をそのまま渡している
- 画面にエラーや結果が出ないブラインドSQLiで、応答時間や真偽の差分から1文字ずつ情報を抜かれる
- 入力を一度エスケープしたつもりでも、多重デコードや文字コードの扱いで無効化されている
共通するのは「入力がデータではなくSQLの構文として解釈される経路がひとつでもあれば成立する」という点です。
対策法
原則は「入力をSQLの文字列に混ぜず、プレースホルダでデータとして渡す」ことです。
- プレースホルダ(パラメータ化クエリ、バインド機構)を使い、値とSQL構文を分離する。これが本命の対策になる
- ORMのクエリビルダを標準経路として使い、生SQLを渡すAPIは使用箇所を洗い出してレビューで管理する
- カラム名や並び順などプレースホルダを使えない箇所は、入力を許可リストの固定値に写像してから使う
- アプリが使うDBユーザーの権限を最小化し、不要なテーブルへのアクセスや管理者権限を与えない(被害軽減策)
- 詳細なSQLエラーを利用者に返さず、攻撃者にスキーマの手がかりを与えない
入力値検証は補助であり、本命はプレースホルダによる構文とデータの分離です。 既存コードでは生SQLと文字列連結の箇所を先に洗い出し、そこから順に潰すのが費用対効果の高い進め方になります。
Rails での事例
Active Record を普通に使っているかぎり、値は自動でエスケープされSQLインジェクションは起きません。 穴が開くのは、条件を文字列で組み立て、そこに入力を式展開したときです。
# 危険:params[:email] が SQL の構文として解釈される
User.where("email = '#{params[:email]}'")
' OR '1'='1 を渡されれば条件が常に真になり、'; DROP ... の類も通ります。
直し方は、プレースホルダに値を渡すことです。
# ? のプレースホルダ、または名前付きプレースホルダ
User.where("email = ?", params[:email])
User.where(email: params[:email]) # ハッシュ条件が最も安全で読みやすい
見落とされやすいのが、プレースホルダを使えない箇所です。
order に渡すカラム名や並び順は値ではないため、? で縛れません。
ここに入力を直接渡すと、カラム名経由のインジェクションになります。
# 危険:ソートキーをそのまま order に渡している
Post.order(params[:sort])
カラム名やソート方向は、許可リストの固定値へ写像してから使います。
SORT_COLUMNS = { "new" => "created_at", "title" => "title" }.freeze
column = SORT_COLUMNS.fetch(params[:sort], "created_at")
Post.order(column => :asc)
find_by_sql や execute で生SQLを書く箇所も同じ原則です。
文字列連結をやめ、バインド変数(sanitize_sql_array や ?)を通します。
