SQLインジェクションの概要と基本的な対策(実務版)
SQLインジェクションとは、ユーザー入力がSQL文に混入して意図しないクエリを実行させる攻撃である。対策の要は“パラメータ化”で、基本は静的プレースホルダー、必要最小限の動的は安全装置(WL/ビルダー)付きで行う。
概要:何が起きるのか/なぜ危険か
-
本質:アプリが文字連結でSQLを組み立て、値が構文として解釈される。
-
典型例(ログイン回避):
-- 本来 SELECT * FROM users WHERE name='alice' AND pass='secret'; -- 攻撃入力: name="' OR '1'='1 -- " -- 実行される可能性のあるSQL SELECT * FROM users WHERE name='' OR '1'='1 -- ' AND pass='...';
-
結果:認証回避、データの漏えい/改ざん、DDL実行(DROP/ALTER)、OSファイルアクセス(DB依存)など。
図:攻撃の流れ(概念)
[攻撃者入力]
↓ (悪意あるペイロード)
[Webアプリ] --(文字連結でSQL生成)--> [DB]
↓ ↑
期待外のSQLが実行 ←←←←←←←←←←←←←←←
主な攻撃手口(ざっくり地図)
-
OR 条件注入:
' OR '1'='1 --
で WHERE 条件を無効化。 -
UNION 注入:
... UNION SELECT ...
で別テーブルを結合して情報閲覧。 -
エラー/ブラインドSQLi:エラー文・真偽差・時間差(
SLEEP
)で情報抽出。 -
スタッククエリ:
; DROP TABLE users;
など複文実行(ドライバや設定次第)。 - 二次(Second-order):一旦DBに攻撃データを保存→別の動的SQLで爆発。
基本対策の原則(優先度順)
- パラメータ化(プレースホルダー):値は常にバインド。文字連結で値を埋めない。
- 動的SQLの最小化:形を可変にしない。必要な場合だけ、厳格WL+ビルダーAPIで。
- 入力検証(Allowlist):数値/UUID/列挙など形式を強制。失敗時は即エラー。
- DB権限の最小化:本番アプリユーザーに DDL やファイル権限を与えない。スキーマ分離。
- 詳細エラーをユーザーに返さない:ログにのみ詳細、UIは汎用メッセージ。
-
複文実行の禁止:
multipleStatements=false
等の設定。ストアド使用時も値はバインド。 - テスト/監査:ユニットに悪意入力のケース、CIで SAST/DAST、定期ペンテスト。
対策の比較(要点表)
対策 | 目的 | 効果範囲 | よくある落とし穴 |
---|---|---|---|
パラメータ化 | 値と構文の分離 | ほぼ全般 | 識別子(列/表名)はバインド不可→WL必須 |
入力検証 | 形式を制約 | 数値/UUID/列挙 等 | 検証抜けの分岐、国際化(全角/正規化) |
最小権限 | 影響限界 | 破壊/漏洩の抑制 | 管理系とアプリ系の資格情報混用 |
エラー隠蔽 | 情報露出抑止 | ブラインド難化 | ログ欠落・監視性低下 |
複文禁止 | 破壊防止 | スタック注入 | ライブラリ初期値の見落とし |
静的プレースホルダー(基本形)
定義:SQLの“形”が固定で、実行時に変わるのは値だけ。
メリット
- 安全:値が構文にならない。
- 性能:プランキャッシュが効きやすい(解析コスト安定)。
- 運用:APMの正規化SQLが収束→遅いクエリ特定が容易。
コード例
# Python + psycopg2 (PostgreSQL)
sql = "SELECT * FROM users WHERE email = %s AND status = %s"
cur.execute(sql, (email, status))
// Node.js + pg
const sql = "SELECT * FROM users WHERE email=$1 AND status=$2";
await client.query(sql, [email, status]);
// Go + database/sql (MySQL例)
rows, err := db.Query("SELECT * FROM users WHERE email=? AND status=?", email, status)
// PHP + PDO
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); // 可能ならサーバ側プリペア
$stmt = $pdo->prepare("SELECT * FROM users WHERE email=:email AND status=:status");
$stmt->execute([':email'=>$email, ':status'=>$status]);
注意点
-
LIKE
のワイルドカードは別物:エスケープ+ESCAPE
句を。kw = keyword.replace('%', r'\%').replace('_', r'\_') cur.execute("SELECT * FROM t WHERE name LIKE %s ESCAPE '\\'", (f"%{kw}%",))
動的プレースホルダー(柔軟性が必要な場合)
定義:条件の付け外し・IN
件数可変・並び替えなどで、SQLの形を組み立てる。ただし値は必ずバインド。
1) 可変 WHERE 条件
clauses, params = [], []
if min_price is not None: clauses.append("price >= %s"); params.append(min_price)
if max_price is not None: clauses.append("price <= %s"); params.append(max_price)
if categories:
ph = ",".join(["%s"]*len(categories))
clauses.append(f"category_id IN ({ph})"); params.extend(categories)
base = "SELECT * FROM items"
sql = base + (" WHERE "+" AND ".join(clauses) if clauses else "")
cur.execute(sql, params)
2) IN
句(PostgreSQL最適解)
ids = [1,2,3]
cur.execute("SELECT * FROM items WHERE id = ANY(%s)", (ids,))
3) ORDER BY
の列や昇降順(識別子はホワイトリスト)
from psycopg2 import sql
ORDERABLE = {"name":"name","created":"created_at","price":"price"}
order_col = sql.Identifier(ORDERABLE.get(user_input_col, "created_at"))
direction = sql.SQL("DESC") if user_input_desc else sql.SQL("ASC")
q = sql.SQL("SELECT * FROM items ORDER BY {} {} LIMIT %s OFFSET %s")\
.format(order_col, direction)
cur.execute(q, (limit, offset))
4) テーブル/カラム名の動的化
-
原則固定。やむを得ないときはWL+識別子API(上記
sql.Identifier
等)で限定。
5) LIMIT/OFFSET
- 多くのドライバで数値もバインド可能。だめなら厳格に数値検証してから埋め込み。
比較:静的 vs 動的 vs ハイブリッド
観点 | 静的(固定形) | 動的(可変形) | ハイブリッド(NULL制御等) |
---|---|---|---|
安全性 | 高(値のみバインド) | 中〜高(WL等が必須) | 高(ただし条件が複雑化) |
実装コスト | 低 | 中(組立/ヘルパ必要) | 中(NULL制御やCASE式) |
プラン安定 | 良 | ばらつきやすい | 中(単一形で維持可能) |
運用/監視 | 楽(署名が収束) | 散りやすい | 中 |
代表用途 | 認証/明確なCRUD | 絞り込み検索/IN/ソート切替 | 条件ON/OFFを1クエリで吸収 |
ハイブリッド例(単一形でON/OFF)
SELECT *
FROM items
WHERE ($1 IS NULL OR price >= $1)
AND ($2 IS NULL OR price <= $2)
※ インデックス利用が鈍るケースあり。頻出パターンはクエリを分けた方が実務は安定。
図:対策選定のフローチャート(簡易)
[要件に可変条件がある?]
├─ いいえ ─→ [静的] 値のみバインド
└─ はい ───→ [動的]
├─ 識別子?(列/表/ASC,DESC)
│ └→ WLで固定マップから選んで Identifier API で埋込
├─ IN句?(件数可変)
│ └→ Postgres: ANY($1) / それ以外: ? を件数分生成
└─ LIKE?
└→ メタ文字エスケープ + ESCAPE 句
NG/OK スニペット集(よくあるミスの矯正)
NG
sql = "SELECT * FROM items WHERE id = " + user_id
sql = f"SELECT * FROM items ORDER BY {user_input}"
OK
cur.execute("SELECT * FROM items WHERE id=%s", (user_id,))
# ORDER BY は WL 経由
order = ORDERABLE.get(user_input, "created_at")
q = sql.SQL("SELECT * FROM items ORDER BY {}").format(sql.Identifier(order))
運用での落とし穴と対処
-
ORMは万能ではない:
raw()
や無名の文字連結は普通に脆弱。ORMのパラメトリックAPIを使う。 - LIKE/正規化:大文字小文字、全角半角、照合順序で抜けが出る。正規化+テストケースを用意。
- APM/ログ:正規化SQLで集計し、遅い/多いクエリを特定。静的の採用率を監視すると事故が減る。
- CI:危険ペイロードのユニットテスト(クォート、コメント、改行、長大配列、NULL/0、UTF-8境界)。
まとめ(持ち帰りチェックリスト)
- すべてのSQLがプレースホルダーで値バインドされている
-
文字連結の
SELECT/INSERT/UPDATE/DELETE
がゼロ -
ORDER BY/識別子
はWL固定マップ経由のみ -
IN
は ANY($1)(Postgres) or ?生成(MySQL/SQLite) -
LIKE
はESCAPE併用でメタ文字を無効化 - 本番DBユーザーは最小権限(DDL/FILE権限なし、スキーマ分離)
- 例外メッセージはユーザーに詳細非表示、ログに詳細
- CIで SAST/DAST と 悪意入力ユニットテストを定期実行
結論:まずは静的プレースホルダーで書けるところを100%静的に。どうしても可変が必要な場所だけ、WL+ビルダーで堅く動的に——これが実務で事故らない最短ルートです。