SQLインジェクションでいちばん多い誤解は、「今どきORMを使っているから大丈夫」という考え方です。確かにORMの普通の使い方なら値はプレースホルダで渡され、注入は起きません。けれどORMには素のSQLを書くための抜け道があり、そこに文字列を組み立てて入れた瞬間、守りは消えます。しかもプレースホルダは値しか守れず、列名やORDER BYのような構造はそもそもバインドできません。

連載「事故から逆算するWebセキュリティ」の第22回は、Python標準のsqlite3とSQLAlchemyで注入を実際に動かし、プレースホルダが防げる所と防げない所を手元で確かめます。

BIND THE VALUESQLインジェクション対策の要

値を文字列に埋め込まず、プレースホルダで渡す

文字列で組み立てる

f-string・連結で WHERE を作る

  • 入力がSQLの構文として解釈される
  • 実測: ' OR '1'='1 で全4行が返った
  • ORMのtext()にf-stringでも同じく漏れる

プレースホルダで渡す

? / :name にバインドする

  • 入力は必ずデータとして扱われる
  • 実測: 同じ入力で0行(遮断)
  • ただし識別子・ORDER BY・LIKEは別問題
実測: Python 3.13.16 / SQLite 3.45.1 / SQLAlchemy 2.1.4(当サイト・2026-10-17)。考え方は OWASP Query Parameterization Cheat Sheet

30秒でわかる(2026年10月17日時点)

  • ・原因は文字列組み立て:f-stringや連結でSQLを作ると、入力がデータでなく構文として解釈される
  • ・値はプレースホルダで防げる:実測で' OR '1'='1は、文字列組み立てだと全4行、バインドだと0行
  • ・構造は防げない:列名・テーブル名・ORDER BYはバインド不可。ここは許可リストで決める
  • ・ORMの抜け道:SQLAlchemyのtext()・Djangoのraw()/extra()・Prismaの$queryRawUnsafe・Sequelizeの未タグ文字列は自動エスケープの外側

インシデント全般の読み方は サプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説 に、実際のORMで起きたSQLインジェクションの事例は Sequelize CVE-2026-69240のOracle方言のSQLインジェクション にまとめています。

古い脆弱性ですが、いまも狙われています。10月13日に NICT が国内で相次ぐ情報漏えいの原因の一つとして注意喚起した「A2B(App to Backend)攻撃」は、公開アプリを解析して API の接続先を割り出し、裏のサーバを直接攻撃する手口で、事業者に重点的な点検を求める脆弱性として、認可不備(IDOR/BOLA)、SSRF、RCE と並んで SQL インジェクションが挙がっています。

SQLインジェクションはなぜ起きるのか——文字列で組み立てた瞬間に負ける

SQLインジェクションは、攻撃者が入力した文字列がデータではなくSQLの構文として実行される脆弱性です(CWE-89)。原因はほぼ一つで、SQL文を文字列として組み立てていることです。

手元で確かめます。利用者テーブルに4人(うち管理者1人)を入れ、名前で1人を探すクエリを、文字列で組み立てる場合とプレースホルダで渡す場合で比べました。入力には' OR '1'='1を渡します。

import sqlite3
db = sqlite3.connect(":memory:")  # users に4行(うち管理者1行)
evil = "' OR '1'='1"

# 文字列で組み立てる(危険): WHERE が常に真になり全件返る
q = f"SELECT name FROM users WHERE name = '{evil}'"
db.execute(q).fetchall()          # → 4行(本来は1行)

# プレースホルダで渡す(安全): 入力はデータとして扱われる
db.execute("SELECT name FROM users WHERE name = ?", (evil,)).fetchall()  # → 0行

実測(Python 3.13.16 / SQLite 3.45.1)では、f-stringで組み立てた側は入力の'がSQLの文字列を閉じ、OR '1'='1が条件として解釈されて全4行が返りました。プレースホルダ?で渡した側は、' OR '1'='1という名前の利用者を探しただけなので0行です。違いは、入力をSQLの一部にするか、SQLとは別の値として送るかだけです。

入力の検証では塞げない

「危ない記号を入力時に消す」方式は、エスケープ漏れが残りやすく、記号を含む正しいデータ(O'Brienのような名前)まで壊します。OWASPも入力の無害化を主防御にせず、プレースホルダ(準備済み文=prepared statement)を第一の防御に挙げています。

プレースホルダで防げる所——値はバインドすれば安全

防御の基本は、OWASP Query Parameterization Cheat Sheetが示すとおり「SQL文と値を別々に送る」ことです。多くの言語・ドライバがこれを備えています。問題は、ORMを使っていても生SQLを書くと自分で文字列を組み立ててしまう点です。SQLAlchemyのtext()で確かめます。

from sqlalchemy import create_engine, text, bindparam
eng = create_engine("sqlite://")
evil = "' OR '1'='1"
with eng.begin() as c:
    # text() に f-string で埋め込む(危険): 全行返る
    c.execute(text(f"SELECT name FROM users WHERE name = '{evil}'"))      # → 3行

    # :name でバインド(安全): 0行
    c.execute(text("SELECT name FROM users WHERE name = :n"), {"n": evil})  # → 0行

    # IN 句も expanding=True で各要素がバインドされる
    st = text("SELECT name FROM users WHERE name IN :names").bindparams(
        bindparam("names", expanding=True))
    c.execute(st, {"names": ["alice", evil]})   # → ['alice'](evil は何にもマッチしない)

実測(SQLAlchemy 2.1.4)では、text()にf-stringで入力を埋め込むと全3行が返り、素のSQLと同じく注入が成立しました。text()は「安全なラッパー」ではなく、:nameでバインドして初めて安全になります。:nに辞書で渡した側は0行、IN句もexpanding=Trueで各要素がバインドされ、悪意ある要素は何にもマッチしませんでした。

他のORMの生SQLも同じ構図です。各ドキュメントが明記しています。

ORM・API 安全な書き方 危険な書き方
SQLAlchemy text() :nameでバインド(実測) f-string・連結で埋め込む(実測)
Django raw()/RawSQL params引数+%sプレースホルダ %sを引用符で囲む・文字列整形(公式が明記)
Django extra() 「最後の手段」。paramsを使う select等にユーザー入力を直接入れる
Prisma $queryRaw タグ付きテンプレート(準備済み文) $queryRawUnsafeに文字列連結で入力
Sequelize sqlタグ/replacements/bind 未タグの文字列にテンプレート展開

Django公式はraw()について「%sプレースホルダを引用符で囲むな・文字列整形を使うな」と明記し、extra()は「最後の手段として使え」と注意しています。Prisma公式は$queryRawをタグ付きテンプレートで準備済み文を作る安全な方法とし、$queryRawUnsafeは「SQLインジェクションの重大なリスク」と記しています。Sequelize公式も、sequelize.queryにsqlタグを付け忘れて文字列展開すると「SQLインジェクションにつながる」と警告しています。

ORMでも撃ててしまう所——識別子・ORDER BY・LIKE

プレースホルダは強力ですが、守れるのは値だけです。SQLの構造——テーブル名・列名・ORDER BYの並び順——はバインドできません。ここがORMでも残る穴です。

VALUES ONLYプレースホルダで渡せる所

プレースホルダで渡せるのは値だけ。構造は許可リストで決める

プレースホルダ ? / :name

  • 値WHERE の比較値・INSERT の列値 バインドで安全ユーザー入力
  • ININ (...) の複数値 各要素がバインドされ安全expanding=True / 配列バインド
  • 列名ORDER BY / SELECT の列・方向 バインド不可→許可リストユーザー入力
  • 表名テーブル名・スキーマ名 バインド不可→許可リストユーザー入力
  • LIKELIKE の % _ \ 注入ではないがエスケープが要るユーザーの検索語
識別子(列名・表名)はプレースホルダで渡せない。出典: PostgreSQL / SQLite の識別子の仕様と OWASP SQL Injection Prevention Cheat Sheet

ORDER BYで確かめます。並び順の列名をプレースホルダで渡しても、それは定数として扱われ並べ替えが効きません。文字列で組み立てると、今度は注入が通ります。

# ORDER BY ? に 'name' を渡しても定数扱いで並ばない
db.execute("SELECT name FROM users ORDER BY ?", ("name",))  # 並べ替え効かず

# 文字列で組み立てると注入式が実行される(危険)
def order_build(sort):
    return db.execute(f"SELECT name FROM users WHERE is_admin=0 ORDER BY {sort}")
order_build("(SELECT is_admin FROM users WHERE id=3)")  # 攻撃者の式が走る
order_build("no_such_col")                               # → no such column エラー

# 許可リストで遮断(安全)
ALLOWED = {"name", "email", "id"}
def order_safe(sort):
    col = sort if sort in ALLOWED else "id"   # 既知の列名だけ通す
    return db.execute(f"SELECT name FROM users WHERE is_admin=0 ORDER BY {col}")

実測では、ORDER BYに差し込んだ部分問い合わせがそのまま実行され、存在しない列名を入れるとno such columnエラーが返りました。このエラーは、攻撃者が列名や条件の真偽を1ビットずつ探る手掛かりになります(エラーベースやブールベースのSQLインジェクション)。許可リスト(既知の列名のみ通し、外れたらidにフォールバック)を挟むと、注入式は通らなくなりました。OWASPも、バインドできないテーブル名・列名・並び順には許可リスト検証を使い、こうした値は可能な限りユーザー入力でなくコード側から与えるよう勧めています。

LIKEの % は注入ではないが別の事故になる

LIKE検索も見落としがちです。プレースホルダでSQLインジェクションは防げても、%や_はLIKEのワイルドカードなので、ユーザーが入れた%はそのまま「全件マッチ」になります。

# LIKE ? に '%' を渡すと全件返る
db.execute("SELECT name FROM users WHERE name LIKE ?", ("%",))   # → 4行

# % _ \ をエスケープし ESCAPE 句を付ける(リテラルとして検索)
term = "carol_100%".replace("\\","\\\\").replace("%","\\%").replace("_","\\_")
db.execute("SELECT name FROM users WHERE name LIKE ? ESCAPE '\\'", (term,))  # → carol_100% の1行

実測では、%をそのまま渡すと全4行、記号をエスケープしてESCAPE '\'を付けると、carol_100%というリテラル名の1行だけに戻りました。注入ではありませんが、検索の意味が壊れ、全件スキャンで負荷にもなります。

SQLインジェクション対策——許可リスト・最小権限・エラー・二次注入

ここまでを、守る順番にまとめます。

DEFENSE ORDER対策の順番

プレースホルダを既定にし、構造は許可リスト、権限は最小に

  1. 値は全部プレースホルダ

    f-string・連結・.format を禁止。生SQLはgrepで探す

  2. 識別子は許可リスト

    ORDER BY・列名・表名は既知の値だけ通す

  3. DBユーザーは最小権限

    読むだけの画面に DROP / GRANT を持たせない

  4. エラーと二次注入に注意

    SQLエラーを画面に出さない。保存した値も再利用時に危険

順番は当サイトの整理。各段の根拠は OWASP SQL Injection Prevention Cheat Sheet と CWE-89

・値は全部プレースホルダ:f-string・連結・.formatでSQLを作らない。ORMの生SQL機能はバインド引数を使う
・識別子は許可リスト:ORDER BY・列名・テーブル名は既知の値だけ通し、外れたら既定値にフォールバック
・DBユーザーは最小権限:読むだけの画面にDROP・GRANT・書き込み権限を持たせない
・エラーを画面に出さない:SQLエラー文はエラーベース注入の手掛かりになる。ログだけに残す
・二次注入に注意:保存した値を後で別のクエリに文字列で埋め込むと、入力時に無害でも実行時に注入になる

最小権限は、注入が成立しても被害を狭めるための二重目です。OWASPは、アプリ用のDBアカウントにテーブルへの直接権限を広く与えず、必要な操作だけに絞るよう勧めています。閲覧だけの画面がDROP TABLEやGRANTを実行できる必要はありません。権限昇格そのものの事例は MariaDBのGRANT PROXY権限昇格(MDEV-40470) に整理しています。

二次SQLインジェクションは特に見落とされます。登録時はプレースホルダで安全に保存した値を、別の処理でDBから読み出し、今度は文字列連結でクエリに埋め込むと、保存時点では無害だった'入りの値がそこで注入になります。「入力の時だけ気をつければよい」という思い込みが穴になるので、DBから読んだ値も、クエリに入れるときは必ずバインドするのが原則です。SQLインジェクションは入力の問題ではなく、SQLを組み立てる全ての箇所の問題だと捉えるのが確実です。

コードベースの自己点検——文字列組み立てのSQLをgrepで洗う

最後に、既存コードに「文字列で組み立てたSQL」が残っていないかを機械で洗います。f-string内にSQLの動詞がある行と、SQL文字列の直後に連結・整形が続く行を探します。

# 文字列で組み立てたSQLの疑いがある行を探す(Python 例)
grep -rniE \
  'f"[^"]*\b(SELECT|INSERT|UPDATE|DELETE)\b|"[^"]*\b(SELECT|INSERT|UPDATE|DELETE)\b[^"]*"[[:space:]]*(\+|%|\.format)' \
  --include=*.py .

陽性・陰性の対照で確かめました。cur.execute(f"SELECT * FROM users WHERE name = '{name}'")・text(f"... WHERE id = {uid}")・"SELECT ... WHERE user = '" + user + "'"の3行はすべて検出され、cur.execute("... WHERE name = ?", (name,))やf"user {user} logged in"(SQLでないf-string)は検出されませんでした。これは文字列でSQLを組み立てている箇所を洗い出す一次スクリーニングで、脆弱かどうかの判定ではありません。この結果だけでは安全とは言えず、ヒットがそのまま脆弱とは限らず(正しくバインドしていれば安全)、ヒットが0でもこのパターン以外の組み立て方やORMの生クエリは見逃します。ヒットした箇所は目視して「値をバインドしているか」を確認します。より精密にはsemgrepのSQLインジェクション向けルールで、execute()の引数が定数でない呼び出しを拾えます。

本記事の実測環境: Python 3.13.16 / SQLite 3.45.1 / SQLAlchemy 2.1.4(Linux x86_64、インメモリDB、読み取り専用の教育用デモ)。ORMごとの仕様は各公式ドキュメントで確認。PostgreSQL・MySQLの実機や、Django・Prisma・Sequelizeのランタイム実行は未検証で、記述はドキュメントの明記に基づきます。

参照ソース