【PHP】プリペアドステートメントでSQLインジェクションを防ぐ方法

phpアイキャッチ PHP

こんにちは、かつコーチです。

前回まで、固定のSQL文をPDOで実行してデータを取得する方法を見てきました。

ですが実際のアプリでは、フォームから送られてきたユーザーの入力値をSQLに組み込む場面がほとんどです。

このとき書き方を間違えると、SQLインジェクションという非常に危険な脆弱性を生んでしまいます。

今回は、そうならないためのプリペアドステートメントという書き方を、必ず身につけてほしいテーマとして解説します。

SQLインジェクションとは?

ユーザーの入力がSQLの一部になってしまう危険

SQLインジェクションとは、ユーザーが入力した値の中にSQLの一部として解釈される文字列を混ぜ込むことで、想定外のSQLを実行させてしまう攻撃手法です。

言葉だけだとイメージしにくいので、具体的なログイン処理のコードで見てみましょう。

❌ Before:入力値をそのままSQLに埋め込む(絶対にやってはいけない書き方)

<?php
$email = $_POST['email'];
$password = $_POST['password'];

// 危険:ユーザーの入力をそのまま文字列結合している
$sql = "SELECT * FROM users WHERE email = '$email' AND password = '$password'";
$stmt = $pdo->query($sql);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user) {
    echo "ログイン成功";
} else {
    echo "ログイン失敗";
}
?>

一見動きそうに見えるこのコードですが、$emailにちょっとした文字列を入れるだけで、認証をすり抜けられてしまいます。

実際にどう突破されるのか

たとえば、パスワード欄に次のような文字列を入力されたとします。

' OR '1'='1

すると、$sqlの中身は実際にはこうなります。

SELECT * FROM users WHERE email = 'test@example.com' AND password = '' OR '1'='1'

'1'='1'は常に真になる条件なので、パスワードが一致していなくても、このORのせいで条件全体が真と判定されてしまいます。

結果として、パスワードを知らなくても誰かのアカウントでログインが成功してしまう、という重大な事故につながります。

私はこの仕組みを初めて知ったとき、正直かなり衝撃を受けました。

「動くから大丈夫」と思って書いていたコードが、実は攻撃者にとって入口が開いたままの状態だったからです。

プリペアドステートメントで防ぐ

プリペアドステートメントとは

プリペアドステートメントとは、SQL文の「型(骨組み)」と「値」を分けて実行する仕組みです。

先に「どこに値が入るか」というプレースホルダー(:email?)だけのSQLをデータベースに送り、その後で実際の値を安全な形で当てはめます。

値がSQLの構文として解釈されることがなくなるため、' OR '1'='1のような文字列を入力されても、それは単なる「文字列」として扱われ、SQLの構造を書き換えられることはありません。

名前付きプレースホルダーで書き直す

先ほどの危険なログイン処理を、プリペアドステートメントで書き直してみましょう。

✅ After:プレースホルダーと値を分離する

<?php
$email = $_POST['email'];
$password = $_POST['password'];

$sql = 'SELECT * FROM users WHERE email = :email AND password = :password';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':email', $email, PDO::PARAM_STR);
$stmt->bindValue(':password', $password, PDO::PARAM_STR);
$stmt->execute();

$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user) {
    echo "ログイン成功";
} else {
    echo "ログイン失敗";
}
?>

流れとしては、prepare()でプレースホルダー付きのSQLを準備し、bindValue()で実際の値を割り当て、最後にexecute()で実行する、という3ステップになります。

' OR '1'='1'という文字列を:passwordに渡しても、それは「そういう文字列のパスワード」として扱われるだけなので、認証は正しく失敗します。

実際のパスワード照合では平文比較ではなくハッシュ化した値を比較すべきですが、それは認証編の記事で改めて扱います。

プレースホルダーの2つの書き方

プレースホルダーには、名前付き(:email)と、疑問符だけの無名(?)の2種類があります。

<?php
// 疑問符プレースホルダーの場合
$sql = 'SELECT * FROM users WHERE email = ? AND password = ?';
$stmt = $pdo->prepare($sql);
$stmt->execute([$email, $password]);
?>

無名プレースホルダーはexecute()に配列を渡すだけで済むので短く書けますが、値の順番を間違えるとバグの原因になります。

条件が多いSQLでは、どの値がどこに入るか分かりやすい名前付きプレースホルダーをおすすめします。

bindValueとbindParamの違い

値を渡すタイミングの違い

似たメソッドにbindParam()もありますが、bindValue()との違いは押さえておきましょう。

メソッド値を評価するタイミング
bindValue()bindValue()を呼んだ時点の値を渡す
bindParam()execute()を実行する時点の変数の値を参照渡しする
<?php
$name = '田中太郎';

$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');
$stmt->bindParam(':name', $name); // 変数への参照を渡す
$name = '佐藤花子'; // execute前に変数を書き換えると…
$stmt->bindValue(':email', 'sato@example.com');
$stmt->execute();
// 実際にINSERTされるnameは「佐藤花子」になる(参照渡しのため)
?>

ループの中で1つの変数を使い回しながら複数回execute()する、といった特殊なケース以外では、挙動が分かりやすいbindValue()を基本に使うことをおすすめします。

つまずきやすいポイント:execute()に配列で直接渡す簡易記法

プレースホルダーの型に迷ったときの簡易な書き方

毎回bindValue()を書くのが面倒に感じる場合、execute()に連想配列を直接渡す書き方もあります。

<?php
$sql = 'SELECT * FROM users WHERE email = :email AND password = :password';
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':email' => $email,
    ':password' => $password,
]);

$user = $stmt->fetch(PDO::FETCH_ASSOC);
?>

私は最初、この書き方とbindValue()をどう使い分けるべきか迷いました。

結論としては、単純な値の受け渡しならこの簡易記法で十分ですが、PDO::PARAM_INTのようにデータ型を明示したい場合はbindValue()を使う、という使い分けで問題ありません。

とにかく大事なのは「文字列結合でSQLを組み立てない」という一点なので、どちらの書き方でもプリペアドステートメントを使ってさえいれば、SQLインジェクション対策としては有効です。

まとめ

この記事のポイント

  • ユーザーの入力値を文字列結合でSQLに埋め込むと、SQLインジェクションの脆弱性が生まれる
  • ' OR '1'='1'のような入力で、認証をすり抜けられる可能性がある
  • プリペアドステートメント(prepare()bindValue()execute())を使えば、値がSQLの構造として解釈されなくなる
  • プレースホルダーには名前付き(:email)と無名(?)があり、条件が多いときは名前付きがおすすめ
  • bindValue()は即時評価、bindParam()は参照渡しという違いがある

次に読むべき記事

安全なSQLの書き方が分かったところで、次はいよいよ登録・一覧・更新・削除をひとまとめにした、実践的なCRUD処理を作ってみましょう。

→ 次の記事:PHPだけでCRUD処理を実装してみる(登録・一覧・更新・削除)

タイトルとURLをコピーしました