0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

#0206(2025/08/12)SQLインジェクションの概要と基本的な対策

Posted at

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で爆発。

基本対策の原則(優先度順)

  1. パラメータ化(プレースホルダー)値は常にバインド。文字連結で値を埋めない。
  2. 動的SQLの最小化:形を可変にしない。必要な場合だけ、厳格WL+ビルダーAPIで。
  3. 入力検証(Allowlist):数値/UUID/列挙など形式を強制。失敗時は即エラー。
  4. DB権限の最小化:本番アプリユーザーに DDL やファイル権限を与えない。スキーマ分離。
  5. 詳細エラーをユーザーに返さない:ログにのみ詳細、UIは汎用メッセージ。
  6. 複文実行の禁止multipleStatements=false 等の設定。ストアド使用時も値はバインド。
  7. テスト/監査:ユニットに悪意入力のケース、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固定マップ経由のみ
  • INANY($1)(Postgres) or ?生成(MySQL/SQLite)
  • LIKEESCAPE併用でメタ文字を無効化
  • 本番DBユーザーは最小権限(DDL/FILE権限なし、スキーマ分離)
  • 例外メッセージはユーザーに詳細非表示、ログに詳細
  • CIで SAST/DAST悪意入力ユニットテストを定期実行

結論:まずは静的プレースホルダーで書けるところを100%静的に。どうしても可変が必要な場所だけ、WL+ビルダーで堅く動的に——これが実務で事故らない最短ルートです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?