- ユーザー入力をSQLに直接連結することでDBを乗っ取られる、古典的ながら依然として強力な攻撃
- 静的プレースホルダ(Prepared Statements)を使い、命令とデータを物理的に分離する根本対策
- ORMのRaw Query使用時の注意点と、DB接続ユーザーの権限を最小化する多層防御の考え方
SQLインジェクションとは?
SQLインジェクションは、アプリケーションがユーザー入力を適切に処理せずにSQLクエリに組み込むことで、外部からデータベースを不正に操作される脆弱性です。
この攻撃が成功すると、機密情報の漏洩、データの改ざん、あるいはデータベース全体の削除といった深刻な被害につながる可能性があります。現在のWebセキュリティにおいて、最も警戒すべき古典的かつ強力な攻撃手法の一つです。
脆弱性が発生する仕組み
脆弱なコードでは、ユーザー入力を直接文字列連結してSQLを組み立てています。
-- ユーザーが ' OR '1'='1 を入力した場合の不正なクエリ
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '...';
このように、ユーザー入力によってSQLの構造そのものが変更されてしまうことが問題の核心です。
根本的な対策:静的プレースホルダの使用
最も確実な対策は、**「静的プレースホルダ(Prepared Statements)」**を利用することです。
対策のポイント(仕様まとめ)
| 対策手法 | 実装の方向性 | エンジニアとしての所感 |
|---|---|---|
| 静的プレースホルダ | SQLの構文とデータを完全に分離する | 基本中の基本。バインド機構によりデータが命令として解釈されるのを防ぎます。 |
| 型の強制 | 入力値を数値や文字列として厳格に扱う | 予期せぬスクリプト注入の余地を狭めることができます。 |
| 最小権限の原則 | DB接続ユーザーの権限を最小限に絞る | 万が一突破された際の被害を最小化する多層防御の考え方です。 |
2026年3月の現場感: ORM 時代でも SQLi は生SQLから戻ってくる
2026年3月の開発コミュニティでは、「ORM を使っているのに、どこで SQLi が入るのか」という議論がむしろ増えています。理由は単純で、今の実務では性能改善、分析用クエリ、AI 生成コード、レポート機能のために生SQLを混ぜる場面が多いからです。つまり普段は ORM で安全でも、急いで追加した1本の raw query が全体を壊します。
最近の空気感として印象的なのは、AI 補助コーディングで raw query を書く機会が増えたことで、**「速く書けるが、プレースホルダが抜けやすい」**という新しい事故パターンが意識されている点です。2026年時点で強いチームは、ORM か raw SQL かを争うのではなく、raw SQL を書くときの共通ガードレールを先に整えています。
私なら、レビュー時に次を必ず見ます。
- 検索条件や並び替え条件を文字列連結していないか
- IN 句や ORDER BY で動的識別子を allowlist 化しているか
- 運用用スクリプトや管理画面のクエリが本番コードと同じ基準で守られているか
トラブルシューティング:既存コードの修正手順
もし既存のシステムで文字列連結によるSQL組み立てが見つかった場合は、以下の手順で修正します。
1. ライブラリのバインド機能を確認
使用しているORM(Sequelize, Prisma, Eloquent等)やDBドライバが提供するプレースホルダ機能を確認します。
2. プレースホルダへの置き換え
// Before (NG)
const query = `SELECT * FROM users WHERE id = ${userId}`;
// After (OK)
const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [userId]);
3. 動的な識別子の処理
テーブル名やカラム名を動的に変更する必要がある場合は、プレースホルダが使えないため、ホワイトリスト方式(許可された名前以外はエラーにする)で厳格にバリデーションを行います。
4. ページング・並び替え・検索条件を別枠で監査する
実務で抜けやすいのは ORDER BY ${sortKey}、LIMIT ${size}、全文検索の条件式などです。値だけでなく識別子や句の一部を差し込む箇所は、許可済みパターンへのマッピングで処理してください。
5. DB 権限で被害を抑える
アプリ接続ユーザーが DROP TABLE や任意スキーマ参照までできる構成は危険です。読み取り専用 API と更新系 API で資格情報を分けるだけでも、事故時の被害がかなり変わります。
実務で見落としにくい確認表
| 確認項目 | 合格ライン | よくある落とし穴 |
|---|---|---|
| 通常クエリ | すべてプレースホルダ利用 | 文字列補間で急ぎ実装 |
| Raw Query | ラッパー関数や共通 helper 経由 | 一部サービスだけ独自実装 |
| 並び替え/列指定 | allowlist で固定マッピング | ORDER BY ${query.sort} |
| 管理画面/バッチ | 本番APIと同じ安全基準 | 「社内限定だから」で緩みやすい |
| DB権限 | 最小権限、用途別資格情報 | 便利だから管理者権限を共有 |
脆弱なコードと修正例
コードレビューや過去の自分のコードで実際に見かけたパターンを、言語別にbefore/afterでまとめた。
ログイン処理(PHP)
Before(脆弱):
// NG: POST入力を直接文字列連結している
$username = $_POST['username'];
$password = $_POST['password'];
$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = mysqli_query($conn, $sql);
// 攻撃者が username に: ' OR '1'='1' --
// → 実行クエリ: SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = '...'
// パスワードチェックをスキップして全ユーザーを取得できてしまう
After(安全):
// OK: PDO の静的プレースホルダを使用
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? AND password_hash = ?');
$stmt->execute([$username, password_hash($password, PASSWORD_BCRYPT)]);
$user = $stmt->fetch();
検索機能(Node.js + MySQL2)
Before(脆弱):
// NG: テンプレートリテラルで直接埋め込み
app.get('/search', async (req, res) => {
const keyword = req.query.q;
const sql = `SELECT * FROM products WHERE name LIKE '%${keyword}%'`;
const [rows] = await connection.query(sql);
res.json(rows);
});
After(安全):
// OK: プレースホルダを使用(% はバインド値の中に含める)
app.get('/search', async (req, res) => {
const keyword = req.query.q;
const [rows] = await connection.execute(
'SELECT * FROM products WHERE name LIKE ?',
[`%${keyword}%`] // % はJS側でバインドする値に含める
);
res.json(rows);
});
ORDER BY への動的な値(識別子問題)
Before(脆弱):
// NG: ORDER BY にはプレースホルダが使えないため、そのまま埋め込んでしまう
const sortColumn = req.query.sort; // "name" や "price" などを想定
const sql = `SELECT * FROM products ORDER BY ${sortColumn} ASC`;
// "sort=1; DROP TABLE products--" のような入力が通ってしまう
After(安全):
// OK: allowlist 方式で入力を固定マッピングにバリデートする
const ALLOWED_SORT_COLUMNS = ['name', 'price', 'created_at'];
const sortColumn = ALLOWED_SORT_COLUMNS.includes(req.query.sort)
? req.query.sort
: 'created_at'; // デフォルト値にフォールバック
const sql = `SELECT * FROM products ORDER BY ${sortColumn} ASC`;
// allowlist 通過済みの安全な識別子のみここに到達する
ORDER BY・GROUP BY・テーブル名・カラム名など「識別子」はプレースホルダで置き換えられない。allowlist による固定マッピングが唯一の対策。
Python(SQLAlchemy での raw SQL 混在パターン)
Before(脆弱):
# NG: text() に f-string でパラメータを埋め込む
from sqlalchemy import text
user_id = request.args.get('id')
result = db.execute(text(f"SELECT * FROM users WHERE id = {user_id}"))
After(安全):
# OK: bindparams でパラメータを分離する
from sqlalchemy import text
user_id = request.args.get('id')
result = db.execute(
text("SELECT * FROM users WHERE id = :user_id"),
{"user_id": user_id}
)
ORM を使っていても text() に文字列補間を混ぜた瞬間に脆弱になる。raw メソッドを使うときはバインドを忘れない。
よくやらかす失敗パターンと対処法
SQLインジェクション対策で実際にやってしまったミスや、コードレビューで見かけるパターンをまとめた。
パターン1: ORMを使っているから安全と思い込んで raw メソッドに文字列連結を使った
- 症状: Sequelizeで
db.query(rawSql)、LaravelでDB::select($rawSql)に直接文字列を渡していた - 問題: ORM経由でも raw 実行メソッドに文字列連結を使えば脆弱になる
- 対処:
db.query(sql, { replacements: { id: userId } })のようにバインドを必ず明示する。raw メソッドを使う際は「バインド引数も渡しているか」をレビューで確認する
パターン2: LIKE 句の % をどこに書けばいいか分からなかった
- 症状:
WHERE name LIKE ?にkeywordだけ渡したら「%」が効かずに完全一致になってしまった - 原因:
LIKE '%?%'と書いてもプレースホルダとして機能しない。%はアプリ側でバインドする値に含める - 対処: バインド値を
['%' . $keyword . '%'](PHP)や[%${keyword}%](JS)として渡す
パターン3: 管理画面やバッチスクリプトは「社内専用だから」と緩い実装にした
- 症状: 本番DBに接続する管理用スクリプトが文字列連結のままだった
- 問題: 社内用でも、設定ファイルや外部ファイルから値が入れば SQLi リスクは変わらない。コードが流出した場合のリスクも同じ
- 対処: 社内専用・バッチ処理を問わず、プレースホルダ使用を全体の標準にする
パターン4: mysqli_real_escape_string() でエスケープしたから安全と判断した
- 症状: エスケープ関数を使えばプレースホルダは不要と思っていた
- 問題: charset の設定次第ではマルチバイト文字を使ったバイパス攻撃でエスケープを迂回できる。複雑さのわりにリスクが残る
- 対処: エスケープ関数ではなく静的プレースホルダ(Prepared Statement)を第一選択にする。PHPなら
PDO+prepare()が確実で分かりやすい
よくある質問(FAQ)
Q: 文字列のエスケープ処理だけで対策は十分ですか?
A: 推奨されません。エスケープ処理は複雑なエンコーディングによるバイパスのリスクがあるため、常に静的プレースホルダを第一選択とすべきです。
Q: ORMを使っていれば自動的に安全になりますか?
A: 多くの場合YESですが、生SQL(Raw Query)を実行できるメソッドを使用する際には注意が必要です。ドキュメントを読み、自動的にエスケープされるかを確認してください。
Q: SQL を一切書かず ORM だけに寄せるべきですか?
A: 必ずしもそうではありません。複雑な集計や性能要件で raw SQL が必要になる場面はあります。重要なのは、raw SQL を禁止することではなく、raw SQL に入る全経路でバインドと allowlist を強制することです。
私が手元で確認したこと
ここで書いた手順は、自分の作業ログをベースに整理した記録です。途中で詰まった箇所・遠回りした箇所・想定と違った挙動も、後から同じ場面に遭遇した自分が読み返せるよう、できるだけ生のままメモしています。環境差で再現しないケースもあるため、ベンダー公式情報と本記事を見比べて取捨選択していただくのが一番確実です。