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は別問題
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 の % _ \ 注入ではないがエスケープが要るユーザーの検索語
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対策の順番
プレースホルダを既定にし、構造は許可リスト、権限は最小に
-
値は全部プレースホルダ
f-string・連結・.format を禁止。生SQLはgrepで探す
-
識別子は許可リスト
ORDER BY・列名・表名は既知の値だけ通す
-
DBユーザーは最小権限
読むだけの画面に DROP / GRANT を持たせない
-
エラーと二次注入に注意
SQLエラーを画面に出さない。保存した値も再利用時に危険
・値は全部プレースホルダ: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()の引数が定数でない呼び出しを拾えます。
参照ソース
- NICT「相次ぐ情報漏えい、その原因の一つは『アプリ解析からのサーバ攻撃』」(2026-10-13)
- OWASP SQL Injection Prevention Cheat Sheet(プレースホルダ・許可リスト・最小権限)
- OWASP Query Parameterization Cheat Sheet(言語別の準備済み文の書き方)
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- SQLAlchemy — Using Textual SQL(
text()/bindparam) - Django — Performing raw SQL queries(
raw()/params/extra()/RawSQL) - Prisma — Raw queries(
$queryRawと$queryRawUnsafe) - Sequelize — Raw SQL(
sqlタグ・replacements・bind・sql.identifier)





