中級では「なぜ入力が命令に混ざるのか」と「どの層で断つか」を見ました。上級では、パラメータ化がプロトコルの中で何をしているか、そしてエラーが返らなくても情報を抜く手口まで踏み込みます。守り全体は Webのセキュリティ(全体像) に、入口の検証は 入力の検証 につながります。
概要 — まず全体をつかむ
詳細 — 1段階ずつ追う
パラメータ化の内部
「パラメータ化すれば安全」——なぜそう言えるのか。鍵は、DBとのやり取りが2段階に分かれている点です。
- 準備(prepare) — アプリは
SELECT * FROM users WHERE name = ?のような骨組みだけをDBに送る。DBはこの時点で構文を解析し、構文木(命令の形)を確定する。?は「ここに後で値が入る」という穴として記憶される - 実行(execute) — 続いて、入力値を別のメッセージとして送る。値は確定済みの穴にバインドされるだけで、構文の再解析は起きない
- だから入力に
' OR '1'='1のような命令の切れ端が混じっていても、それは穴に収まる1個の文字列としてしか扱われない。骨組みはもう変えられない ※1
アニメーション『SQLインジェクション(命令とデータの分離)』を開く
文字列連結は、この2段階を1段階に潰してしまいます。骨組みと値を先に文字列として合体させ、その完成品をDBに構文解析させるので、値の中の記号が構文として解釈される余地が残る。パラメータ化の本質は「構文の確定を、値が届く前に済ませる」ことです。
登場人物メモ:
- ※1 バインド — 確定済みの命令の「穴」に、入力値をデータとして流し込む操作。値は命令として再解釈されない
- ※2 識別子 — 表名・列名・別名など、命令の骨組みを構成する名前。リテラル(値)とは別扱い
アニメーション『パラメータ化の内部(連結 vs プリペアド)』を開く
やさしく言うと(中級)
- パラメータ化クエリ(プレースホルダ) — 命令の骨組みを先に固定し、入力は「値(データ)」として別枠で渡す。命令とデータが分離されるので入力は命令として動かない。これが王道
- 文字列連結で SQL を組み立てない — これが鉄則。自前のエスケープは抜けやすい
- ORMの適切な利用 — ORM も内部でパラメータ化を使うが、生SQLを組み立てる箇所は同じリスクなので油断しない
- DBユーザーの最小権限 — アプリ用ユーザーに削除・全件取得などの過剰な権限を与えない。破られても被害を絞る
- 識別子は許可リストで — 表名・列名・並び順(ORDER BY)はプレースホルダで「値」として渡せない。動的に変えるなら、固定の許可リストから選ぶ(入力をそのまま使わない)
- エラー詳細を返さない — DBのエラーをそのまま画面に出すと、テーブル構造などのヒントを与える
- 入力検証 — 補助として型や形式も確かめる
識別子は値として渡せない
パラメータ化には届かない領域があります。プレースホルダでバインドできるのは、文字列や数値といったリテラル(値)だけです。
- 表名・列名・
ORDER BYの並び順・ASC/DESCなどは、命令の骨組みそのもの ※2。ここに?を置くことはできない - 「並び替える列をユーザーが選べる」画面などでは、入力を識別子として使いたくなる。ここを連結すると、リテラルは守られているのに識別子経由で注入される
- 正解は固定の許可リスト。
{"name": "name", "date": "created_at"}のように、入力キーをあらかじめ用意した安全な列名へ写像し、リストにないキーは拒否する。入力の文字列を直接SQLへ入れない
二次注入(stored / second-order)
「入口で検証したから安全」とは限りません。一度DBに保存された値が、あとで別のSQLに使われるときに牙をむくのが二次注入です。
- 登録時:ユーザー名として
admin'--を保存する。保存処理自体はパラメータ化されていて、値は無害な文字列としてそのまま格納される - 利用時:別の機能がその保存値を読み出して、文字列連結でSQLを組み立てると、そこで初めて命令として解釈される
- 教訓:危険は入力の瞬間ではなく、SQLを組み立てる瞬間に生まれる。DBから読んだ値も「外から来た値」と同じく信頼せず、使う箇所を必ずパラメータ化する
ブラインドSQLi
エラーメッセージを画面に出さないだけでは、根本対策になりません。攻撃者は応答の差を手がかりに、1ビットずつ情報を引き出せます。
- 真偽ベース(boolean-based) —
AND 1=1(真)とAND 1=2(偽)を注入し、ページの表示が変わるか否かで条件の真偽を読む。「パスワードの1文字目はaより大きいか」を繰り返して1文字ずつ確定する - 時間ベース(time-based) — 表示に差が出ないときは、条件が真のときだけ
SLEEP(5)のようにわざと遅延させる。応答時間の長短で真偽を読む - どちらも二分探索で効率化でき、自動化ツールにかかれば表全体が抜かれうる
- だからエラー非表示は補助にすぎない。根本はパラメータ化で、そもそも命令を書き換えさせないこと
ORMの落とし穴
ORMやクエリビルダは内部でパラメータ化を使うので、普通に使う限り安全です。危険は「便利さの隙間」に潜みます。
- 生SQLの断片 —
find_by_sql("... WHERE name = '#{input}'")のように、生SQLに文字列補間すると素のインジェクション。ORMの内側でも連結は連結 - クエリビルダへの補間 —
where("age > #{input}")のように、ビルダのメソッドへ文字列を差し込む書き方は危険。値はプレースホルダ引数として渡す(where("age > ?", input)) - ソート・列指定オプション — ORMの並び替え引数にユーザー入力をそのまま渡すと、識別子の注入になりうる。ここも許可リスト
- 要は、ORMが守ってくれるのは値の部分だけ。骨組みに文字列を差し込む書き方をした瞬間、保護は外れる
スタッククエリと最小権限
- スタッククエリ(積み重ね) —
; DROP TABLE usersのように、;で複数の命令をまとめて実行させる手口。ドライバやDBの設定によっては1回の呼び出しで複数文が通ってしまう。多くのAPIは既定で単文しか許さないが、依存してはいけない - 最小権限 — アプリ用のDBユーザーに、必要以上の権限(
DROP・全表参照・管理者権限)を与えない。注入が成立してもできることを絞れば被害を封じ込められる。参照専用の画面なら参照権限だけ、というように機能ごとに権限を分けるのが理想 - 詳しくは データベースサーバ も参照
WAFの限界
- WAFは万能ではない — WAF(Web Application Firewall)は既知の攻撃パターンを弾くが、パターンマッチングが土台なので、コメント挿入・大文字小文字・エンコード違い・等価な別表現で回避されうる。入口の網であって、根治ではない
- 多層防御の一枚として — WAFは「時間稼ぎと検知」に価値がある。ただしアプリ側のパラメータ化が本丸で、WAFに寄りかかって連結を放置するのは順序が逆
- エラーの露出 — 詳細エラーを隠してもブラインドで抜かれる。非表示は補助と心得る
- 入力検証との役割分担 — 入力の検証 は「形」を入口で確かめる担当。SQLインジェクションの根治は出口(DBへ渡す所)のパラメータ化で、両者は別concernとして両方やる
やさしく言うと(中級)
- エスケープ自作に頼る — 自前の文字置換は抜け穴が残りやすい。パラメータ化が正解
- エラーメッセージの露出 — 詳細を隠しても、応答の真偽や時間差から探るブラインドな手口がある。だからエラー非表示は補助
- ORMでも油断しない — 生SQLや動的な条件組み立ての箇所は同じリスク
- 種類を知る — 結果に差が出るエラーベース、差が見えにくいブラインド(真偽・時間差)など手口は複数あるが、対策の根本は同じ
理解度チェック
そのまま解けます(成績は保存されません)。無料アカウントを作ると、学習の記録と進捗の山登りが始まります。
問1. プリペアドステートメント(パラメータ化)が構造的に安全なのはなぜか?
問2. 表名・列名や並び順(ORDER BY)を入力に応じて動的に切り替えたい。安全な設計は?
問3. 二次注入(second-order)が教えている本質はどれ?
問4. 真偽ベースのブラインドSQLインジェクションの説明として正しいのは?
問5. WAF(Web Application Firewall)の限界として正しいのは?
問6. アプリ用DBユーザーに最小権限を与えることが効く理由は?
問7. セミコロンで複数の命令をまとめて実行させる手口を「○○クエリ」と呼ぶ。○○に入るカタカナは?
問8. 時間ベースのブラインドSQLiで 条件が真のときだけわざと応答を遅らせるために使う代表的なSQL関数は?