「自作の検索フォームや会員機能で、DBに渡す値の扱いが本当に安全か自信がない」——そんなWeb担当者・サイト運営者・制作会社の方向けに、SQLインジェクションを作り込まないためのPHP/WordPressの書き方をコピペできる形でまとめます。
読み終えると、①なぜ危険なのか、②PHPとWordPressでの正しい書き方、③プレースホルダに置き換えられない箇所の安全な扱い、までひととおり分かります。攻撃の再現手順ではなく、同じ穴を二度と作らないための実装に絞って解説します。
SQLインジェクションとは(根本原因は1つ)
SQLインジェクションは、ユーザーから受け取った値をSQL文の文字列にそのまま連結してしまうことで、値の一部がSQLの構文として解釈され、意図しない検索・改ざん・情報漏えいにつながる問題です。IPAの「安全なウェブサイトの作り方」でも最重要級の脆弱性として長年挙げられています。
危険なのは、次のように値を文字列連結しているコードです。
<?php
// ❌ 危険: $_GET の値をSQLにそのまま連結している
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = '" . $id . "'";
$stmt = $pdo->query($sql);
$id に想定外の文字(クォートやSQL構文)が入ると、WHERE 条件がまるごと書き換わってしまいます。
根本原因は「データ(値)とコード(SQL文)を文字列として混ぜている」ことだけです。逆に言えば、値をSQL文の外に出し、プレースホルダ経由で渡す(=プリペアドステートメント)ようにすれば、この問題は構造的に発生しなくなります。入力値のエスケープやサニタイズを自作するより、まずこちらを徹底します。
PHP(PDO)の安全な書き方 — コピペ雛形
PDOでは、値を ?(疑問符プレースホルダ)または :name(名前付きプレースホルダ)にしておき、実際の値は execute() / bindValue() で渡します。
<?php
// 接続時に例外モードと文字コードを指定(この2点も安全側の既定)
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 本物のプリペアドを使う
]
);
// ✅ 名前付きプレースホルダ
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = :email AND status = :status');
$stmt->execute([
':email' => $_POST['email'],
':status' => 'active',
]);
$rows = $stmt->fetchAll();
? プレースホルダなら、順番に値を渡します。
<?php
// ✅ 疑問符プレースホルダ(渡す順番 = ? の並び順)
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = ? AND status = ?');
$stmt->execute([$_POST['email'], 'active']);
$rows = $stmt->fetchAll();
ポイントは 値のまわりにクォート '...' を書かないこと。プレースホルダはドライバ側が適切に扱うため、WHERE email = ':email' のようにクォートで囲むと逆に壊れます。また PDO::ATTR_EMULATE_PREPARES => false を明示し、擬似プリペアド(内部で文字列組み立て)ではなくサーバ側プリペアドを使うようにします。
PHP(mysqli)の場合
mysqliでも bind_param() でプレースホルダに値をバインドできます。型指定文字(s=文字列 / i=整数 / d=小数)を値の数だけ並べます。
<?php
$stmt = $mysqli->prepare('SELECT id, name FROM users WHERE email = ? AND age >= ?');
$stmt->bind_param('si', $_POST['email'], $minAge); // s:文字列, i:整数
$stmt->execute();
$result = $stmt->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);
プレースホルダに「できない」箇所の安全な扱い
プレースホルダに置き換えられるのは 値だけです。テーブル名・カラム名・ORDER BY の並び順・ASC/DESC などのSQLの構造部分はプレースホルダにできません。ここに外部入力を使いたい場合は、あらかじめ用意した許可リスト(ホワイトリスト)と完全一致したものだけを採用します。
<?php
// 並び替えカラムと方向を、決めておいた選択肢だけに絞る
$allowedSort = ['created_at', 'name', 'price']; // 許可カラム
$sort = in_array($_GET['sort'] ?? '', $allowedSort, true) ? $_GET['sort'] : 'created_at';
$dir = (strtolower($_GET['dir'] ?? '') === 'asc') ? 'ASC' : 'DESC';
// 構造部分は許可リスト由来の固定値、値はプレースホルダ
$sql = "SELECT id, name FROM products WHERE category = ? ORDER BY {$sort} {$dir} LIMIT ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$_GET['category'], 20]);
IN (...) 句も件数ぶんのプレースホルダを動的に生成して対応します。
<?php
// ✅ 配列の件数だけ ? を作って IN 句に展開する
$ids = array_values($_POST['ids']); // 例: [3, 7, 12]
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM items WHERE id IN ($placeholders)");
$stmt->execute($ids);
WordPress:$wpdb->prepare() の正しい使い方
WordPressで独自のSQLを書くとき(プラグインやテーマのカスタム機能など)は、必ず $wpdb->prepare() を通します。%s(文字列)・%d(整数)・%f(小数)のプレースホルダを使います。
<?php
global $wpdb;
// ✅ %s / %d をプレースホルダとして使う。%s のまわりにクォートは付けない
$sql = $wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = %s AND post_author = %d",
'publish',
$author_id
);
$rows = $wpdb->get_results( $sql );
よくある間違いを3つ挙げます。
-
prepare()を通さず変数を連結している:一番の事故原因。外部由来の値が1つでも入るなら必ずprepare()を使います。 -
'%s'とクォートで囲む:WHERE name = '%s'は二重に壊れます。%sはそのまま書きます(WordPressが適切に処理します)。 -
IN句:IN (%s)に配列は渡せません。件数ぶんの%d/%sを組み立ててから渡します。
<?php
// ✅ WordPress で IN 句を安全に組む
$ids = array_map( 'absint', (array) $_POST['ids'] ); // 整数化してさらに安全側に
$ph = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$sql = $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID IN ($ph)", $ids );
$rows = $wpdb->get_col( $sql );
なお、WP_Query や get_posts() など標準APIで実現できる処理は、自分で生SQLを書かずにAPIを使うのが最も安全です。生SQLは「標準APIでは書けないときだけ」に限定しましょう。
仕上げの多層防御(3点)
プリペアドステートメントが本丸ですが、万一の被害を小さくする「もう一段」を添えておきます。
-
DBユーザーの権限を最小化:Webアプリが使うDBユーザーに
DROPや他DBへのアクセス権を与えない。参照専用の処理はSELECT権限だけのユーザーで動かす、という分離も有効です。 - 本番でSQLエラーを画面に出さない:エラーメッセージからテーブル構造やクエリが漏れると、次の攻撃の手がかりになります(→ 関連記事)。
- WAFで一律遮断を重ねる:アプリ側の実装ミスを取りこぼしても、共通的な攻撃パターンを手前で止める層を持っておくと安心です。ただしWAFは保険であって、プリペアドステートメントの代わりにはなりません。
まとめ
- SQLインジェクションの根本原因は「値をSQL文字列に連結する」1点。
- 対策の本丸はプリペアドステートメント。PHPは PDO/mysqli、WordPressは
$wpdb->prepare()を必ず通す。 - テーブル名・
ORDER BYなど構造部分は許可リストで、IN句は件数ぶんのプレースホルダで安全に。 - 最小権限DBユーザー・本番エラー非表示・WAFで多層に守る。
関連記事
- 本番のエラー表示で情報を漏らさない7設定(PHP/nginx/Apache/WordPress対応)
- 画像・PDFのアップロード機能を安全にする7つの検証(PHP/WordPress/nginx対応)
- WordPressの改ざんを防ぐファイル権限とPHP実行制限(uploads対策)
本記事のような入力の取り扱いや設定の抜けは、Webサイトを9つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp