Webアプリケーションのセキュリティ脆弱性のなかで、SQLインジェクションは件数・被害規模ともに深刻なリスクです。攻撃が成功すると、データベースの全件取得・改ざん・削除が可能になるほか、場合によってはサーバー上でコマンドを実行されることもあります。この記事では、SQLインジェクションがどのような仕組みで起きるかを脆弱なコード例で確認し、プレースホルダ(プリペアドステートメント)を使った確実な防御方法をPHP・Python・Node.jsの実例で整理します。MySQLを前提としていますが、考え方はどのデータベースでも共通です。
このシナリオで考える
ユーザー名とパスワードを受け取ってログイン可否を判定するシステムを例として使います。users テーブルを次のように定義します。
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
PRIMARY KEY (id)
);
INSERT INTO users (username, password_hash) VALUES
('alice', '$2y$10$abc...'),
('bob', '$2y$10$def...');
ログイン処理では username と password をフォームから受け取り、一致するユーザーを SELECT で取得します。この「値をクエリに渡す」部分の実装方法が、安全性を大きく左右します。
SQLインジェクションの仕組み
まず、脆弱なコードがどのように攻撃されるかを見てみます。次は PHP で文字列結合を使った悪い例です。
-- PHP 側でこう組み立てると脆弱
-- $query = "SELECT * FROM users WHERE username = '$username' AND password_hash = '$pass'";
-- 正常なリクエスト (username = 'alice') では問題なし
SELECT * FROM users WHERE username = 'alice' AND password_hash = '入力値';
通常のリクエストでは問題ありません。しかし username の入力欄に次の文字列を送り込まれると、クエリの構造が変わります。
-- 攻撃者の入力: username = "' OR '1'='1' -- "
SELECT * FROM users
WHERE username = '' OR '1'='1' -- ' AND password_hash = '任意の値';
'1'='1' は常に真なので WHERE 句全体が真になります。-- 以降はSQLコメントとして無視されるため、パスワード検証も完全にスキップされます。テーブルの全行が返り、攻撃者は認証を突破できます。
さらに悪質なケースでは ; DROP TABLE users; -- を挿入してテーブルを削除したり、UNION SELECT で別テーブルの情報を読み出すことも可能です。
根本的な原因は、SQLのコード(構造)とユーザーが入力した値(データ)が区別されていないことです。どれだけ入力値をエスケープしようとしても、文字列結合を使う限りこのリスクを完全には排除できません。
プレースホルダ(プリペアドステートメント)で防ぐ
SQLインジェクションを根本的に防ぐ方法がプレースホルダを使ったプリペアドステートメントです。SQLの構造を先に送信し、値は後から別ルートで渡します。データベースがSQLとして解析するのは「構造部分だけ」に限定されるため、入力値がSQLとして動くことがありません。
PHPの場合(PDO)
-- PHP PDO では :name 形式の名前付きプレースホルダを使う
-- $stmt = $pdo->prepare(
-- "SELECT * FROM users WHERE username = :username AND password_hash = :pass"
-- );
-- $stmt->execute([':username' => $username, ':pass' => $pass]);
-- $user = $stmt->fetch();
-- データベースが受け取る「構造」はこれだけ
SELECT * FROM users WHERE username = :username AND password_hash = :pass;
prepare() でSQL構造を確定させ、execute() で値を渡す2段階の仕組みです。
Pythonの場合(mysql-connector-python)
-- Python では %s が位置プレースホルダ
-- cursor.execute(
-- "SELECT * FROM users WHERE username = %s AND password_hash = %s",
-- (username, password_hash)
-- )
SELECT * FROM users WHERE username = %s AND password_hash = %s;
クエリ文字列とデータを execute() に分けて渡します。
Node.jsの場合(mysql2)
-- mysql2 では ? が位置プレースホルダ
-- const [rows] = await pool.query(
-- "SELECT * FROM users WHERE username = ? AND password_hash = ?",
-- [username, passwordHash]
-- );
SELECT * FROM users WHERE username = ? AND password_hash = ?;
記法はライブラリによって異なりますが、「構造を先に確定させ、値を後から別ルートで渡す」という原則はどの言語でも共通です。
| 言語 / ライブラリ | プレースホルダ記法 | 値の渡し方 |
|---|---|---|
| PHP (PDO) | :name または ? |
execute([':name' => $val]) |
| Python (mysql-connector) | %s |
cursor.execute(sql, (val,)) |
| Node.js (mysql2) | ? |
pool.query(sql, [val]) |
| Java (JDBC) | ? |
stmt.setString(1, val) |
なぜプレースホルダは安全なのか
プレースホルダが有効なのは、SQLのパースとデータのバインドが完全に分離されているからです。
通常の文字列結合では、データベースはクエリ全体を「1つの文字列」として受け取り、その中からSQL構造を解析します。攻撃者が ' や -- を挿入すると、意図しない構造として解析されます。
プリペアドステートメントでは、まず構造だけがデータベースに送られます。データベースはこの段階でSQLをパース(解析)し、? や :name の位置に「後でデータが入る」と記録します。その後で値が渡されますが、この値はSQLとして再解析されません。単なるデータとして扱われます。
よって ' OR '1'='1 という文字列が渡されても、データベースはそれを「username カラムと比較する文字列値」として扱うだけです。SQLの構造を変える手段がありません。
よくある落とし穴と注意点
プレースホルダを使っていても、いくつかのパターンで不完全になることがあります。
- LIKE 句のワイルドカード:
%と_はSQLインジェクションではなく LIKE の特殊文字です。部分一致検索でLIKE CONCAT('%', ?, '%')を使うとき、入力値に含まれる%や_をエスケープしないと、意図しない範囲が一致します。ESCAPE '\'句と組み合わせるか、アプリ側で\%に置換してから渡します。 - カラム名・テーブル名にはプレースホルダを使えない: プレースホルダは値(リテラル)を渡す仕組みです。ORDER BY のカラム名や ASC/DESC、テーブル名には使えません。これらを動的に変える場合は許可リスト(allowlist)で検証してからSQL文字列に埋め込みます。
- IN 句で件数が動的なとき:
WHERE id IN (?)と書いても複数値は渡せません。?を件数分並べてWHERE id IN (?, ?, ?)と組み立てる必要があります。
-- NG: ORDER BY の列名にプレースホルダは使えない
SELECT * FROM orders ORDER BY ?; -- エラーまたは意図しない動作
-- OK: 許可リストで検証してから SQL に埋め込む
-- PHP 例
-- $allowed = ['price', 'created_at', 'quantity'];
-- if (in_array($col, $allowed)) {
-- $sql = "SELECT * FROM orders ORDER BY {$col}";
-- }
-- OK: IN 句は ? を件数分並べて組み立てる
-- $placeholders = implode(',', array_fill(0, count($ids), '?'));
-- $sql = "SELECT * FROM orders WHERE id IN ({$placeholders})";
また、文字列エスケープ(mysqli_real_escape_string 等)はプレースホルダの代替にはなりません。マルチバイト文字の扱いやコンテキストの違いによって迂回できるケースがあります。プレースホルダが使える場面ではエスケープに頼らないのが原則です。
まとめ
SQLインジェクションはSQLのコードとデータが混在することで起きます。根本的な対策はプレースホルダを使い、SQLの構造と値を分離することです。ORDER BY句の動的指定など、プレースホルダが使えない箇所だけ許可リスト方式で補う設計にすれば、安全なSQLの組み立てが実現できます。
参考リンク
アイキャッチ画像: Photo by Jantine Doornbos on Unsplash
