こんにちは、かつコーチです。
「ログインフォームに' OR '1'='1と入力するだけで、誰でもログインできてしまう」
こんな話を聞いたことはないでしょうか。
これはSQLインジェクション(ユーザーの入力値をそのままSQL文に組み込むことで、意図しないSQL文を実行させられてしまう脆弱性)と呼ばれる、Webアプリケーションで最も有名な脆弱性の一つです。
古典的な脆弱性ですが、今でも実際の被害報告がなくなることはありません。
この記事では、SQLインジェクションが起きる仕組みと、MySQLを使う上での具体的な対策方法であるプレースホルダ(SQL文中の値の部分を一旦仮の記号に置き換え、後から安全に値を割り当てる仕組み)の使い方を解説します。
SQLインジェクションとは何か
文字列連結でSQL文を組み立てる危険性
SQLインジェクションが起きる典型的なパターンは、ユーザーの入力値を文字列連結でそのままSQL文に埋め込んでしまうことです。
// ❌ Before:文字列連結で組み立てる危険な書き方
$username = $_POST['username'];
$password = $_POST['password'];
$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
一見すると普通のログイン処理に見えますが、usernameに次のような値を入力されるとどうなるでしょうか。
' OR '1'='1
SQL文は次のように組み立てられてしまいます。
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = ''
'1'='1'は常に真になるため、パスワードのチェックを実質的にすり抜けて、ログインが成立してしまいます。
さらに悪質な入力では、DROP TABLEのような破壊的な命令を混入させることも理論上は可能です。
なぜ文字列連結が危険なのか
問題の本質は、「データ」であるはずのユーザー入力が、「命令」の一部として実行されてしまうことです。
本来、usernameやpasswordは単なる値であって、SQL文の構文を変える力を持ってはいけません。
しかし文字列連結では、入力値の中に'(シングルクォート)が含まれるだけで、SQL文の構造そのものが変わってしまいます。
この「データと命令の境界が曖昧になる」という構造上の問題は、文字列連結を続ける限り根本的には解決できません。
プレースホルダで対策する
プレースホルダの基本的な考え方
プレースホルダは、SQL文中の値の部分を?や:nameのような仮の記号に置き換えておき、実行時にデータベースドライバが安全な形で値を割り当てる仕組みです。
SELECT * FROM users WHERE username = ? AND password = ?
このSQL文自体は先に準備(プリペア)され、後から渡す値は「あくまでデータとして扱われる」ため、値の中に'やORのような文字列が含まれていてもSQL文の構造には一切影響しません。
これがプリペアドステートメント(あらかじめSQL文の構造を準備しておき、後からパラメータを安全に割り当てる仕組み)と呼ばれる方式で、プレースホルダはその中で使う「値の置き場」です。
PHP(PDO)でのプレースホルダの使い方
PHPのPDO(PHP Data Objects、DB接続を抽象化するPHPの標準拡張)を使った例です。
// ✅ After:プレースホルダを使った安全な書き方
$pdo = new PDO('mysql:host=localhost;dbname=myapp;charset=utf8mb4', $dbUser, $dbPass);
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username AND password_hash = :password_hash');
$stmt->execute([
':username' => $username,
':password_hash' => hash('sha256', $password),
]);
$user = $stmt->fetch();
prepare()でSQL文の構造を先に確定させ、execute()で渡す値は完全にデータとしてのみ扱われます。
これでusernameにどんな文字列が入力されても、SQL文の構造が変わることはありません。
なお、パスワードは平文で比較するのではなく、ハッシュ化した上で比較・保存するのが前提です(ハッシュ化については別記事で詳しく扱います)。
主要言語でのプレースホルダ対応状況
| 言語・ライブラリ | プレースホルダの書き方 | 備考 |
|---|---|---|
| PHP(PDO) | :name または ? | 名前付き・位置指定どちらも可能 |
Python(mysql-connector-python) | %s | 位置指定のみ |
Node.js(mysql2) | ? | 位置指定 |
| Java(JDBC) | ? | PreparedStatementクラスを使用 |
言語やライブラリによって記法は異なりますが、「値を文字列連結せず、専用の仕組みに渡す」という考え方は共通です。
使っているフレームワークのORM(LaravelのEloquent、DjangoのORMなど)を経由してSQLを発行している場合、内部的にはほぼ自動でプレースホルダが使われているため安全ですが、生SQLを直書きする箇所では特に注意が必要です。
つまずきやすいポイント・エラー対処
LIKE検索でハマるパターン
プレースホルダを使っていても、LIKE検索の書き方を誤ると効果が薄れることがあります。
// ❌ Before:ワイルドカードを文字列連結で組み立てている
$keyword = $_GET['keyword'];
$sql = "SELECT * FROM articles WHERE title LIKE '%$keyword%'";
これは典型的なSQLインジェクションの入り口になるため、プレースホルダ化が必須です。
// ✅ After:ワイルドカードごとプレースホルダの値として渡す
$stmt = $pdo->prepare('SELECT * FROM articles WHERE title LIKE :keyword');
$stmt->execute([':keyword' => '%' . $keyword . '%']);
%(ワイルドカード)はSQL文の中に直接書くのではなく、プレースホルダに渡す値の一部として組み立てるのがポイントです。
こうすることで、SQL文の構文自体はLIKE :keywordのまま固定され、ユーザー入力は最後まで「値」として扱われます。
IN句で複数値を渡すときのエラー
IN句に配列を渡そうとして、次のようなエラーに遭遇した経験がある方も多いのではないでしょうか。
SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens
これは、プレースホルダの数と実際に渡した値の数が一致していないために起きるエラーです。
// ❌ Before:配列をそのまま1つのプレースホルダに渡してしまう
$ids = [1, 2, 3];
$stmt = $pdo->prepare('SELECT * FROM users WHERE id IN (?)');
$stmt->execute($ids); // ← プレースホルダは1つなのに値が3つ
IN句で複数値を扱う場合は、値の数だけプレースホルダを動的に生成する必要があります。
// ✅ After:値の数だけプレースホルダを動的に生成する
$ids = [1, 2, 3];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ($placeholders)");
$stmt->execute($ids);
筆者もこのエラーに初めて遭遇したとき、原因が分からず30分ほど悩んだ経験があります。
エラーメッセージの「number of bound variables does not match number of tokens」は、直訳すると「束縛された変数の数がトークンの数と一致しない」という意味で、プレースホルダの数と値の数のズレを直接指摘してくれているので、落ち着いて数を数え直せば解決できます。
応用・一歩先の使い方(中級読者向けブリッジ)
プレースホルダだけでは防げない部分
プレースホルダは値の部分を安全にしますが、テーブル名やカラム名、ソート順(ORDER BY句の指定など)を動的に組み立てる場合はプレースホルダの対象外になる点に注意が必要です。
// ⚠️ 注意:カラム名はプレースホルダにできない
$sortColumn = $_GET['sort']; // ユーザー入力
// これはSQL構文エラーになる(プレースホルダは値専用のため)
$stmt = $pdo->prepare('SELECT * FROM users ORDER BY ?');
このような場合は、許可するカラム名のリストをあらかじめ用意し、ユーザー入力がそのリストに含まれるかをチェックする(ホワイトリスト方式)という別の対策が必要です。
// ✅ ホワイトリストで許可された値かチェックしてから組み立てる
$allowedColumns = ['created_at', 'name', 'price'];
$sortColumn = in_array($_GET['sort'], $allowedColumns, true) ? $_GET['sort'] : 'created_at';
$stmt = $pdo->prepare("SELECT * FROM products ORDER BY $sortColumn");
$stmt->execute();
「プレースホルダを使えば絶対安全」ではなく、「値はプレースホルダ、識別子(テーブル名・カラム名)はホワイトリスト」という2段構えで考えるのが実務的なセキュリティ対策です。
まとめ
この記事のポイント
- SQLインジェクションは、ユーザー入力を文字列連結でSQL文に組み込むことで発生する
- プレースホルダを使うと、値がSQL文の構造に影響を与えなくなり根本的な対策になる
LIKE検索やIN句のように、プレースホルダの使い方を誤りやすい箇所には特に注意する- テーブル名・カラム名などの識別子はプレースホルダにできないため、ホワイトリスト方式で別途対策する
次に読むべき記事
- ユーザー権限管理:GRANT・REVOKEの基本
- よくあるMySQLエラーまとめ(Access denied等)
タグ: MySQL, 中級者向け, セキュリティ