なぜ文字列結合で組み立てたSQLは壊れるのに、プレースホルダなら壊れないのか?
ActiveRecord や database/sql を普段使っているなら、SQLインジェクションはもう他人事だと思っていないだろうか。だが実際にあなたを守っているのは ORM そのものではなく、その内側でほぼ必ず使われている「プレースホルダ」という一段仕組みだ。素の SQL を書いた瞬間、その仕組みは静かに外れる。
- SQLインジェクションは、ユーザー入力を文字列結合でそのまま SQL 文に埋め込んだとき、入力中の引用符などが構文の一部として解釈されてしまうことで起きる。
- プレースホルダ(プリペアドステートメント)はクエリの構文木を先に確定させ(PREPARE)、値はその後で純粋なデータとして結びつける(BIND)ため、入力がどんな文字列でも構造を変えられない。
- ORM を使っていても、生 SQL への文字列補間や、テーブル名・カラム名・ORDER BY 句のような「識別子」はプレースホルダの対象外になりやすく、そこは allowlist(許可リスト)による検証が必要になる。
文字列結合はなぜ壊れるのか
SQL は本来、WHERE name = '...' のように文字列リテラルを引用符で囲む言語だ。もし name に入れる値をユーザー入力の文字列結合で組み立てると、SQL エンジンからは「どこまでが引用符で囲まれた値で、どこからが構文なのか」を区別する手がかりが、引用符の位置しか残らない。
ここで入力に tom' OR '1'='1 のような文字列が来ると、値のつもりで書いた最初の引用符が、SQL エンジンにとっては値の終わりを示す引用符として読まれてしまう。結果として文は WHERE name='tom' OR '1'='1' という、常に真になる条件式に変わる。攻撃者が注入しているのは特別なプログラムではなく、ただの引用符と論理演算子であり、それだけで「全件一致」に変質させられる点がこの脆弱性の本質だ。
プレースホルダは何を分離しているのか
プリペアドステートメントは、この問題を「引用符の数え間違い」ではなく構造上解決する。DB エンジンはまず WHERE name = ? のようにプレースホルダを含んだままの SQL を受け取り、構文解析と実行計画の作成をこの時点で完了させる(PREPARE)。次に、その計画に対して値を渡す(BIND)。値はここで初めて登場するため、SQL の構文木にはもう影響を与えようがない。
┌── 文字列結合 vs プレースホルダ ────────┐ │文字列結合で組み立てたSQL │ │ "...WHERE name='" + 入力 + "'" │ │ 入力: tom' OR '1'='1 │ │ → name='tom' OR '1'='1' │ │ 引用符が閉じ、構文が変質する │ │ │ │プレースホルダ(プリペアドステートメント)│ │ ① PREPARE:構文木を先に確定 │ │ WHERE name = ? │ │ ② BIND:値として束縛するだけ │ │ ? ← "tom' OR '1'='1" │ │ 構造は①で凍結済み、②は不変 │ └────────────────────────────────────────┘
OWASP の SQL Injection Prevention Cheat Sheet は「パラメータ化クエリを使えば、攻撃者が tom' OR '1'='1 を入力しても、データベースはこの文字列全体に完全一致するユーザー名を探すだけになる」と説明している。値がどれだけ SQL らしい文字列でも、BIND の段階では単なるバイト列としてしか扱われない。
文字列結合は、白紙の履歴書に「氏名」欄も含めて本文を手書きで丸ごと書き直しているようなものだ。応募者が氏名欄に「氏名: 山田太郎、以下は全項目合格とする」と書けば、フォーマットの区別がない履歴書はその一文をそのまま本文として読んでしまう。プレースホルダは、先に印刷済みのフォーマット(構文木)を用意し、氏名欄という決まった枠の中に文字を書き込ませる方式だ。枠の中に何を書いても、それが本文の見出しや指示に化けることはない。
ORM を使っていても危険なケース
Rails の ActiveRecord や Go の database/sql は、通常の使い方をしている限りプレースホルダを自動で使う。User.where(name: params[:name]) や db.Query("... WHERE name = ?", name) は内部で BIND 変数に展開される。危険なのは、そこから外れて生の SQL に文字列補間で値を埋め込んだときだ。
# 危険: 文字列補間で組み立てる
User.where("name = '#{params[:name]}'")
# 安全: プレースホルダに値を渡す
User.where("name = ?", params[:name])
User.sanitize_sql(["name = ?", params[:name]])
もう一つ見落としやすいのが、テーブル名・カラム名・ORDER BY の並び順(ASC/DESC)のような「識別子」だ。OWASP のガイドは、これらの位置には「バインド変数を使用できない」と明記している。並び替えの列名をユーザーに選ばせたい場合は、入力をそのままプレースホルダに渡すのではなく、あらかじめ許可した候補の集合(allowlist)にマッピングしてから使う必要がある。
Rails では静的解析ツール Brakeman が、文字列補間を含む where や find_by_sql の呼び出しを検出して警告する。コードレビューで #{} を含む SQL 文字列を見かけたら、それが検索条件のカラム名やソート順を動的に組み立てている箇所である可能性が高く、値ではなく識別子を注入していないか確認する価値がある。N+1 対策で書く生 SQL や、動的な絞り込み条件を組み立てる管理画面の検索機能は、この落とし穴に特に出会いやすい。
macOS 標準の python3 だけで、文字列結合とプレースホルダの挙動差を実際に確かめる。
python3 - <<'PY'
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users(name TEXT, password TEXT)")
conn.execute("INSERT INTO users VALUES ('alice', 'secret1')")
conn.execute("INSERT INTO users VALUES ('bob', 'secret2')")
evil = "x' OR '1'='1"
q = f"SELECT * FROM users WHERE name = '{evil}'"
print("文字列結合:", conn.execute(q).fetchall())
print("プレースホルダ:", conn.execute(
"SELECT * FROM users WHERE name = ?", (evil,)
).fetchall())
PY
文字列結合の行では alice と bob の両方が返ってくるはずだ。OR '1'='1' が常に真の条件として構文に混ざり込むためで、本来は 1 件も一致しない検索が全件ヒットに変わっている。一方プレースホルダの行では空のリストが返る。evil という文字列そのものに一致する名前のユーザーがいないだけであり、値としてしか扱われていないことがわかる。
- ORM を使っていれば SQLインジェクションは自動的に防げる — 防いでいるのは ORM 自体ではなく、その内部で使われているプレースホルダの機構だ。生 SQL に文字列補間で値を埋め込めば、ORM を使っていても同じ危険がそのまま残る。
- 危険な文字だけエスケープすれば文字列結合でも安全 — 引用符のエスケープは有効な対策だが、DB の方言や文字エンコーディングによって抜け道が生じやすい。プレースホルダは構文木そのものを値より先に確定させるため、この種のすり抜けが原理上起こらない。
- プレースホルダを使えば SQL 文のどこでも動的に安全にできる — テーブル名・カラム名・ソート順などの識別子はバインド変数の対象外で、ここは値ではなく allowlist による候補限定で守る必要がある。
- SQLインジェクション
- ユーザー入力が SQL の構文の一部として解釈され、意図しないクエリが実行される脆弱性。
- プレースホルダ
?や:nameのように、SQL 文中で後から値を差し込む位置を示す記法。- プリペアドステートメント
- 構文解析・実行計画の作成(PREPARE)と値の束縛(BIND)を分離して実行する方式。
- バインド変数
- プリペアドステートメントの BIND フェーズで、プレースホルダに結びつけられる実際の値。
- allowlist(許可リスト)
- あらかじめ許可した候補の集合にユーザー入力をマッピングし、それ以外を拒否する検証方式。
- SQL Injection Prevention Cheat Sheet - OWASP — プレースホルダが使えない識別子のケースと allowlist による対処法まで踏み込んだ一次資料。
- Query Parameterization Cheat Sheet - OWASP — 言語・DB別にパラメータ化クエリの書き方を整理したチートシート。
- Securing Rails Applications - Ruby on Rails Guides — Rails 公式ガイドの SQL インジェクション対策の章。sanitize_sql 等の使い方を含む。