- サイバーセキュリティ 🛡️
- データベース
- 技術 💻
SQLインジェクション対策|脆弱なコードと修正版を実行して比較
SQLインジェクション対策の基本は、SQLを文字列連結で組み立てず、プレースホルダー(バインド変数)で値を渡すことです。プレースホルダーを使うと、利用者が入力した値は「SQLの文」ではなく「ただの値」として扱われるため、入力に記号が含まれていてもSQLの意味が変わりません。
この記事は、実装する開発者向けです。Python(sqlite3・psycopg)とNode.js(pg)で、脆弱なコードと修正版を実際にローカルのDBに対して実行し、同じ入力で「全件取得される」→「0件になる」ことを実行結果で比較します。さらに、プレースホルダーが使えないORDER BYの列名、LIKEのワイルドカード、IN句、ORMの生SQLなど、現場でつまずきやすい場面ごとの書き方と、AIが書いたコードを確認するポイントまで紹介します。
SQLインジェクション対策の早見表
| 場面 | 対策 | 位置づけ |
|---|---|---|
| WHEREやINSERTの値 | プレースホルダー(?・%s・$1) |
根本的な対策 |
| ORDER BYの列名・テーブル名 | ホワイトリストで決めた値だけを使う | 根本的な対策 |
| LIKEの検索語 | プレースホルダー+%・_のエスケープ |
意図しない検索を防ぐ |
| IN句 | 配列を1つのパラメータで渡す(= ANY($1))か、個数分のプレースホルダー |
根本的な対策 |
| ORMの生SQL | raw・nativeでもパラメータ機能を使う | 根本的な対策 |
| エラー表示 | DBのエラー文を画面に出さない | 被害を小さくする備え |
| DBアカウント | 必要最小限の権限だけを付与 | 被害を小さくする備え |
| WAF・入力チェック | 補助として併用 | コードの対策の代わりにはならない |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQLインジェクション対策|脆弱なコードと修正版を実行して比較」
SQLインジェクションとは:入力がSQLの一部になってしまう
SQLインジェクションとは、入力した値がSQLの「文」として解釈され、開発者の意図と違うSQLが実行されてしまう脆弱性です。原因はほぼ1つで、入力をSQLの文字列に直接つなげていることです。
名前で会員を検索する処理を例にします。
| 入力 | 組み立てられるSQL | 結果 |
|---|---|---|
sato |
... WHERE name = 'sato' |
satoだけ |
x' OR '1'='1 |
... WHERE name = 'x' OR '1'='1' |
条件が常に真になり、全件 |
入力に含まれる ' が文字列の終わりとして解釈され、その後ろの OR '1'='1' がSQLの条件として動いてしまいます。IPA(情報処理推進機構)の「安全なウェブサイトの作り方」でも、根本的解決として「SQL文の組み立ては全てプレースホルダで実装する」ことが最初に挙げられています。
flowchart LR
A["入力: x' OR '1'='1"] --> B{"SQLの組み立て方"}
B -->|文字列連結| C["SQLの文の一部になる<br/>→ 条件が変わり全件"]
B -->|プレースホルダー| D["ただの値として比較<br/>→ 一致なし 0件"]
実行して確認:脆弱なコードと修正版
以下はすべて、自分の手元のDB(検証用に作ったテーブル)に対して実行した結果です。確認は必ず自分が管理する環境で行ってください。
Python(sqlite3)
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.executemany("INSERT INTO users(name, email) VALUES (?, ?)",
[("sato", "sato@example.com"), ("suzuki", "suzuki@example.com"),
("tanaka", "tanaka@example.com")])
def find_user_unsafe(name):
sql = f"SELECT id, name, email FROM users WHERE name = '{name}'" # 文字列をつなげている
print("SQL:", sql)
return conn.execute(sql).fetchall()
def find_user_safe(name):
sql = "SELECT id, name, email FROM users WHERE name = ?" # プレースホルダー
return conn.execute(sql, (name,)).fetchall()
attack = "x' OR '1'='1"
print("unsafe:", find_user_unsafe(attack))
print("safe :", find_user_safe(attack))
print("safe :", find_user_safe("sato"))
実行結果:
SQL: SELECT id, name, email FROM users WHERE name = 'x' OR '1'='1'
unsafe: [(1, 'sato', 'sato@example.com'), (2, 'suzuki', 'suzuki@example.com'), (3, 'tanaka', 'tanaka@example.com')]
safe : []
safe : [(1, 'sato', 'sato@example.com')]
文字列連結の版は、1人を探すはずが全員のメールアドレスを返しました。プレースホルダーの版は、x' OR '1'='1 という名前の会員を探すので0件です。普通の検索(sato)は、どちらでも同じように動きます。
Python(psycopg 3 × PostgreSQL 16)
import psycopg
conn = psycopg.connect("host=127.0.0.1 dbname=t03 user=t03app password=...")
def find_unsafe(name):
return conn.execute(f"SELECT id, name FROM app_users WHERE name = '{name}'").fetchall()
def find_safe(name):
return conn.execute("SELECT id, name FROM app_users WHERE name = %s", (name,)).fetchall()
attack = "x' OR '1'='1"
print("unsafe:", find_unsafe(attack))
print("safe :", find_safe(attack))
unsafe: [(1, 'sato'), (2, 'suzuki'), (3, 'tanaka'), (4, 'yamada')]
safe : []
psycopgの %s は、Pythonの文字列フォーマットの %s とは別物です。"... = '%s'" % name のように自分で埋め込むと文字列連結と同じになるため、必ず execute の第2引数で渡します。
Node.js(pg)
const { Client } = require('pg');
// 以下は async 関数の中で実行
const client = new Client({ host: '127.0.0.1', database: 't03', user: 't03app', password: '...' });
await client.connect();
const name = "x' OR '1'='1";
// 脆弱:テンプレートリテラルで連結
const unsafe = await client.query(`SELECT id, name FROM app_users WHERE name = '${name}'`);
console.log('unsafe:', unsafe.rows);
// 安全:$1 プレースホルダー
const safe = await client.query('SELECT id, name FROM app_users WHERE name = $1', [name]);
console.log('safe :', safe.rows);
unsafe: [
{ id: 1, name: 'sato' },
{ id: 2, name: 'suzuki' },
{ id: 3, name: 'tanaka' },
{ id: 4, name: 'yamada' }
]
safe : []
JavaScriptでは、テンプレートリテラル(`...${name}...`)が文字列連結と同じ働きをします。見た目がすっきりしているぶん気づきにくいので、SQLの中に ${ があれば要確認です。
なお、パラメータ付きのクエリに ; で2つ目の文を続けると、pgは次のエラーで実行しませんでした。
ERR : cannot insert multiple commands into a prepared statement
Java(JDBC PreparedStatement)
Javaでは PreparedStatement と ? を使います。以下はJava 21でコンパイルを確認したコードです(検証環境にJDBCドライバを用意できなかったため、DBへの接続は実行していません。公式のJDBC APIに沿った書き方です)。
public List<String> findByName(Connection conn, String name) throws SQLException {
// × String sql = "SELECT id, name FROM app_users WHERE name = '" + name + "'";
String sql = "SELECT id, name FROM app_users WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name); // 1番目の ? に値をセット
try (ResultSet rs = ps.executeQuery()) {
List<String> result = new ArrayList<>();
while (rs.next()) {
result.add(rs.getInt("id") + ":" + rs.getString("name"));
}
return result;
}
}
}
PreparedStatement を使っていても、prepareStatement("... '" + name + "'") のように連結した文字列を渡すと効果がありません。値は必ず setString・setInt などでセットします。
プレースホルダーの書き方一覧
| 言語・ライブラリ | 書き方 | 値の渡し方 |
|---|---|---|
| Python sqlite3 | ? または :name |
execute(sql, (値,)) |
| Python psycopg | %s または %(name)s |
execute(sql, (値,)) |
| Node.js pg | $1、$2 … |
query(sql, [値]) |
| Java JDBC | ? |
ps.setString(1, 値) |
| PHP PDO | ? または :name |
$stmt->execute([値]) |
プレースホルダーが使えない場所:ORDER BYの列名
プレースホルダーで渡せるのは「値」だけで、列名・テーブル名・ASC/DESCのようなSQLの構造は渡せません。psycopgで試すと、エラーにはならず、意図どおりにも並びませんでした。
conn.execute("SELECT id, name FROM app_users ORDER BY %s", ("name",)).fetchall()
[(1, 'sato'), (2, 'suzuki'), (3, 'tanaka'), (4, 'yamada')]
'name' という文字列の定数で並べ替えたことになり、名前順にはなっていません(たまたまid順に見えています)。ここで列名を文字列連結すると、SQLインジェクションの入口になります。
列名は、ホワイトリスト(許可する値の一覧)から選びます。
from psycopg import sql
SORTABLE = {"name": "name", "created": "created_at", "id": "id"} # 画面の値 → 列名
def list_users(sort_key, desc):
col = SORTABLE.get(sort_key, "id") # 一覧にない値は既定の列に
direction = sql.SQL("DESC") if desc else sql.SQL("ASC")
q = sql.SQL("SELECT id, name FROM app_users ORDER BY {} {}").format(sql.Identifier(col), direction)
print(q.as_string(conn))
return conn.execute(q).fetchall()
print(list_users("name", True))
print(list_users("name; DROP TABLE app_users", False))
SELECT id, name FROM app_users ORDER BY "name" DESC
[(4, 'yamada'), (3, 'tanaka'), (2, 'suzuki'), (1, 'sato')]
SELECT id, name FROM app_users ORDER BY "id" ASC
[(1, 'sato'), (2, 'suzuki'), (3, 'tanaka'), (4, 'yamada')]
一覧にない値は既定の列(id)になり、入力の文字列がSQLに入ることはありません。sql.Identifier は識別子として正しく引用符で囲む、psycopgの機能です。Javaでも Map.of("name", "name", ...) から getOrDefault で列名を決める方法が使えます。
LIKEの検索語:% と _ をエスケープする
LIKEの値もプレースホルダーで渡しますが、それだけでは %(任意の文字列)と _(任意の1文字)がワイルドカードとして働きます。「_ を含むメモ」を探したつもりが全件になる例です。
def escape_like(s):
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def search_note(word, escape=True):
w = escape_like(word) if escape else word
return conn.execute(
"SELECT name, note FROM app_users WHERE note LIKE %s ESCAPE '\\'",
("%" + w + "%",)).fetchall()
like '_' no escape: [('sato', '100%達成'), ('suzuki', 'メモなし'), ('tanaka', 'a_b テスト'), ('yamada', '要確認')]
like '_' escaped : [('tanaka', 'a_b テスト')]
like '%' no escape: [('sato', '100%達成'), ('suzuki', 'メモなし'), ('tanaka', 'a_b テスト'), ('yamada', '要確認')]
like '%' escaped : [('sato', '100%達成')]
エスケープしないと、_ や % の1文字で全件がヒットしました。SQLの文が変わるわけではありませんが、意図しない大量の検索結果や、重い検索の原因になります。エスケープ文字(ここでは \)自体も先にエスケープするのがポイントです。
IN句:配列を1つのパラメータで渡す
IN句の値を ",".join(ids) で連結するのはよくある書き方ですが、これも文字列連結です。PostgreSQLでは、配列を1つのパラメータで渡して = ANY(...) を使うのが簡単です。
conn.execute("SELECT id, name FROM app_users WHERE id = ANY(%s) ORDER BY id", ([1, 3],)).fetchall()
# → [(1, 'sato'), (3, 'tanaka')]
await client.query('SELECT id, name FROM app_users WHERE id = ANY($1::int[]) ORDER BY id', [[2, 4]]);
// → [ { id: 2, name: 'suzuki' }, { id: 4, name: 'yamada' } ]
上の2つはどちらも実行して確認した結果です。配列を渡せないDBやライブラリでは、値の個数分だけプレースホルダーを並べる(IN (?, ?, ?) を件数から組み立てる)方法を使います。組み立てるのは ? の個数だけで、値そのものは連結しません。
ORMの生SQL・ストアドプロシージャ
ORMを使っていても、生SQLを書く機能で文字列を連結すると、SQLインジェクションは起こります。
| 仕組み | 危ない書き方 | 安全な書き方 |
|---|---|---|
| SQLAlchemy | text(f"... WHERE name = '{name}'") |
text("... WHERE name = :name") + {"name": name} |
| Django | raw(f"... '{name}'")、extra() に連結 |
raw("... = %s", [name]) |
| JPA | createNativeQuery("... '" + name + "'") |
createNativeQuery("... = ?1").setParameter(1, name) |
| MyBatis | ${name}(そのまま埋め込み) |
#{name}(プレースホルダー) |
MyBatisの ${} と #{} は1文字違いで意味が大きく変わります。${} はORDER BYの列名など、ホワイトリストで検査した値にだけ使います。
ストアドプロシージャの中で動的SQLを組み立てる場合も同じです。PostgreSQLのPL/pgSQLでは、値は EXECUTE ... USING で渡し、識別子は format('%I', ...) で引用符付きにします。
EXECUTE format('SELECT count(*) FROM %I WHERE name = $1', table_name)
INTO cnt
USING user_name;
この形の関数を作って確認すると、user_name に x' OR '1'='1 を渡しても0件、table_name に app_users; DROP TABLE app_users を渡すと、全体が1つのテーブル名として扱われ relation "app_users; DROP TABLE app_users" does not exist というエラーになりました。値は USING、識別子は %I と覚えておくと安心です。
被害を小さくする対策:エラー表示と最小権限
DBのエラー文を画面に出さない
o'brien のように ' を含む名前を、文字列連結の版に渡すと次のエラーになりました。
ERR: SyntaxError syntax error at or near "brien"
LINE 1: SELECT id, name FROM app_users WHERE name = 'o'brien'
^
このエラー文には、組み立てたSQLそのものが含まれています。画面やAPIのレスポンスにそのまま返すと、テーブル名や列名などの手がかりを渡すことになります。詳細はサーバーのログにだけ出し、利用者には「エラーが発生しました」のような一般的なメッセージを返します。なお、プレースホルダーの版では o'brien もただの値として扱われ、エラーにならずに検索できました。
DBアカウントに最小限の権限だけを与える
アプリ用のアカウント(t03app)に、app_users の SELECT権限だけ を付けて、DELETEとDROPを試しました。
GRANT SELECT ON app_users TO t03app;
ERR: InsufficientPrivilege permission denied for table app_users
ERR: InsufficientPrivilege must be owner of table app_users
万一SQLインジェクションがあっても、権限にない操作は実行できません。参照だけの画面は参照用のアカウント、テーブルの作成や変更はマイグレーション用のアカウント、と分けておくと被害を小さくできます。
AIが書いたコードのチェックポイント
生成AIが書いたSQL周りのコードは、動作は正しくても、文字列連結になっていることがあります。レビューでは次の点を確認します。
| 確認すること | 見つけ方の例 |
|---|---|
| SQLの中に変数を埋め込んでいないか | f"SELECT、`SELECT ... ${、"SELECT ..." + を検索 |
| ORDER BY・列名・テーブル名が入力から来ていないか | 並べ替え・絞り込み機能の引数をたどる |
LIKEの %・_ をエスケープしているか |
LIKE を検索 |
IN句を join で組み立てていないか |
join( とSQLが近くにないか |
ORMのraw・native・${} を使っていないか |
raw(、text(、createNativeQuery、${ を検索 |
| 例外のメッセージをそのまま返していないか | catch の中で e.message・str(e) をレスポンスにしていないか |
AIに頼むときは、「SQLはすべてプレースホルダーで書いて。列名はホワイトリストで決めて」と最初に条件として伝えると、この形のコードが出てきやすくなります。
よくある質問
Q. 入力値のチェック(バリデーション)をしていれば、プレースホルダーは不要ですか?
A. 必要です。入力チェックは、想定外の値を早めに弾く補助として有効ですが、SQLの組み立て方そのものの対策にはなりません。名前やメモのように ' を含みうる項目もあるため、プレースホルダーを基本にします。
Q. WAFを入れれば対策は十分ですか? A. WAFは、既知の攻撃パターンを入口で止める補助として役立ちます。ただし、アプリのコードに脆弱性が残っている状態は変わらないため、コードでプレースホルダーを使うことが根本的な対策です。
Q. 自分で ' をエスケープする関数を作れば安全ですか?
A. DBの種類や文字コード、設定によってエスケープの規則が異なるため、自作は取りこぼしが起きやすくなります。DBのドライバが用意するプレースホルダーを使うのが確実です。
Q. 数値のIDなら文字列連結でも大丈夫ですか? A. 数値のつもりでも、リクエストから来た値は文字列として届くことがあります。型の変換をしたうえで、値はプレースホルダーで渡すと、例外を考えずに済みます。
まとめ
- SQLインジェクション対策の基本は、文字列連結をやめてプレースホルダーで値を渡すこと
- 実行結果では、連結版は「' OR '1'='1」で全件、プレースホルダー版は0件になった
- ORDER BYの列名はプレースホルダーにできない。ホワイトリストで決める
- LIKEは
%・_・エスケープ文字をエスケープ、IN句は 配列を1つのパラメータで渡す - ORMの生SQL・MyBatisの
${}・ストアドの動的SQLでも同じ考え方で書く - エラー文を画面に出さない、DBアカウントは最小権限で被害を小さくする
- AIが書いたコードは、SQL周りの文字列連結がないかを確認する
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQLインジェクション対策|脆弱なコードと修正版を実行して比較」
株式会社RJCでは、動くだけでなく安心して使えるシステムを届けるために、安全な書き方をチームで確かめ合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。