PHP-DB - 17. SQLインジェクション

ここではデータベースを扱うシステムのセキュリティについて学習します。これまでにPHPプログラムからデータベースに対してSQLを実行する方法を学習してきました。Webアプリケーションの開発においてはユーザの入力データをSQLのパラメータとして利用することも頻繁にあります。SQLを実行するPHPプログラムに脆弱性があると、ユーザの不正なSQLの入力によって意図しないSQLを実行されてしまう可能性があります。このような攻撃をSQLインジェクションと呼びます。

SQLインジェクションによって、データベース上のデータが改ざん・削除されてしまったり、機密情報が漏洩してしまったり、甚大な被害につながる可能性があります。

SQLインジェクションの例

それでは実際にSQLインジェクションを再現してその原因と対策について確認していきましょう。ここでは categories テーブルを検索する簡単な検索機能を開発してみましょう。検索画面( sql_search.html )と検索処理を行うPHPプログラム( sql_search.php )の2つを作成します。

まずはじめに検索画面( sql_search.html )を作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>SQL Injection Sample</title>
</head>
<body>
  <h3>Search</h3>
  <hr>
  <form action="sql_search.php" method="get">
    ID: <input type="text" name="id">
    <input type="submit" value="search">
  </form>
</body>
</html>

検索画面は入力フォームを表示します。IDを入力するテキストボックスと search ボタンを表示します。入力フォームから送信されたIDは次の sql_search.php ファイルで処理します。

<?php
$dsn = "mysql:host=localhost;dbname=eldb;charset=utf8mb4";
$username = "root";
$password = "admin";
$pdo = new PDO($dsn, $username, $password);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

$id = filter_input(INPUT_GET, "id");
$sql = "select id, title from categories where id = " . $id;
$st = $pdo->query($sql);  
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>SQL Injection Sample</title>
</head>
<body>
  <h3>Search</h3>
  <hr>
  <ul>
  <?php foreach($st as $row) { ?>
    <li><?= htmlspecialchars($row["title"]) ?></li>
  <?php }?>
  </ul>
</body>
</html>

このプログラムではPDOインスタンスを生成してデータベースに接続した後、 filter_input 関数を使ってリクエストパラメータの id を取得しています。

$id = filter_input(INPUT_GET, "id");

次に受け取ったリクエストパラメータをSQL文に文字列として連結しています。

$sql = "select id, title from categories where id = " . $id;

このとき画面から "3" のようなIDが入力された場合、次のようなSQLが生成されます。

select id, title from categories where id = 3

この場合は categories テーブルから id3 のレコードを検索することになります。

その後 PDO クラスの query メソッドを使ってSQLを実行しています。戻り値に PDOStatement インスタンスが返却されるので $st 変数に代入しています。

$st = $pdo->query($sql);

このあと $st 変数に対して fetch メソッドや fetchAll メソッドを呼び出して検索結果にアクセスすることもできますが、このプログラムでは後のHTML出力部分で $st 変数を使ってループ処理しています。

  <?php foreach($st as $row) { ?>
    <li><?= htmlspecialchars($row["title"]) ?></li>
  <?php }?>

このように PDOStatement インスタンスは foreach 構文を使って検索結果を1件ずつ取得して処理することもできます。

動作確認

それでは実際にビルトインWebサーバを起動して動作確認をしてみましょう。

ターミナルからビルトインWebサーバを起動します。

$ php -S localhost:8080

ブラウザから以下のURLにアクセスします。

https://〜.vfs.cloud9.ap-northeast-1.amazonaws.com/sql_search.html

検索画面( search.html ) にアクセスします。

IDを入力するテキストボックスに 3 と入力し、 search ボタンをクリックします。

そうすると画面に ID : 3 の検索結果である Marketing と表示されます。

続いて、ブラウザの戻るボタンで検索画面に戻った後に、IDを入力するテキストボックスに 9 と入力し、 search ボタンをクリックします。

そうすると画面に検索結果が表示されなくなります。これは ID : 9 の検索結果は存在しないからです。

ここまでは問題ありません。続いて、ブラウザの戻るボタンで検索画面に戻った後に、IDを入力するテキストボックスに 9 or 1 = 1 と入力し、 search ボタンをクリックしてみましょう。

そうすると画面に categories テーブルのレコードがすべて表示されてしまいます。

今回作成した検索機能はIDに紐づく結果を表示することを期待していたので、このような結果になることは想定していませんでした。何が起きたのかを考察していきましょう。

SQLインジェクションの原因

さきほど作成した検索処理を行うPHPプログラム( sql_search.php )にはSQLインジェクションの脆弱性が存在します。それが以下の部分です。

$id = filter_input(INPUT_GET, "id");
$sql = "select id, title from categories where id = " . $id;
$st = $pdo->query($sql);

具体的にはユーザからの入力データである $id 変数をSQL文に連結している部分に問題があります。

$sql = "select id, title from categories where id = " . $id;

さきほどブラウザから入力した不正な値 9 or 1 = 1$id 変数に代入すると次のようなSQLになってしま
います。

select id, title from categories where id = 9 or 1 = 1

上記のようにユーザの入力した 9 or 1 = 1 という値がSQLの構文として成立しているのがわかります。この場合、 select 文の where 句は「id = 9」あるいは「1 = 1」と解釈されます。少しわかりにくいですが、「1 = 1」という条件式は categories テーブルのすべてのレコードにおいて真(true)となるので、結果として categories テーブルの全レコードが返却されることになります。

このようにユーザの入力データをSQL文に文字列として連結することがSQLインジェクションの直接的な原因となります。

SQLインジェクションの対策

それではSQLインジェクションの対策について考えていきましょう。繰り返しになりますが、SQLインジェクションはユーザの入力によって、意図しないSQLが実行されてしまう問題です。そうではなく、開発者の意図したとおりにSQLを実行するためには、以前に学習したプリペアドステートメントを使うことができます。

あたらためてプリペアドステートメントについて確認しておくと、プリペアドステートメントとは「SQLの構文解析」と「解析済みSQLの実行」を分離する実行方式です。プリペアドステートメントを使うことで予め「SQLの構文解析」を済ませておくことで、ユーザの入力によってSQLの構文を拡張できないようにします。

さきほどの検索プログラム( sql_search.php )をプリペアドステートメントを使うように修正してみましょう。

<?php
$dsn = "mysql:host=localhost;dbname=eldb;charset=utf8mb4";
$username = "root";
$password = "admin";
$pdo = new PDO($dsn, $username, $password);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

$id = filter_input(INPUT_GET, "id");
$sql = "select id, title from categories where id = :id";
$st = $pdo->prepare($sql);
$st->bindValue(":id", $id, PDO::PARAM_INT);
$st->execute();
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>SQL Injection Sample</title>
</head>
<body>
  <h3>Search</h3>
  <hr>
  <ul>
  <?php foreach($st as $row) { ?>
    <li><?= htmlspecialchars($row["title"]) ?></li>
  <?php }?>
  </ul>
</body>
</html>

修正後のプログラムではSQLの実行部分をプリペアドステートメントに修正しています。

$id = filter_input(INPUT_GET, "id");
$sql = "select id, title from categories where id = :id";
$st = $pdo->prepare($sql);
$st->bindValue(":id", $id, PDO::PARAM_INT);
$st->execute();

このようにSQLのパラメータにプレースホルダ(名前付きプレースホルダ)を利用することで、SQLの構文解析を済ませておくことで、ユーザの入力を安全に処理することができます。

動作確認

それでは実際にビルトインWebサーバを起動して動作確認をしてみましょう。

ターミナルからビルトインWebサーバを起動します。

$ php -S localhost:8080

ブラウザから以下のURLにアクセスします。

https://〜.vfs.cloud9.ap-northeast-1.amazonaws.com/sql_search.html

検索画面( sql_search.html ) にアクセスします。

IDを入力するテキストボックスに 9 or 1 = 1 と入力し、 search ボタンをクリックしてみましょう。

そうすると修正前の結果とは異なり、画面には何も表示されないようになります。

参考:SQLインジェクションの予防的な対策

これまでに見てきたようにSQLインジェクションは、悪意のあるユーザの入力によって、SQLの構文が拡張されてしまい、意図しないSQLが実行されてしまうことで被害が発生します。根本的な対策として、ユーザの入力値をSQLに連結しないことを徹底する必要があります。

勘違いしてはいけないのは、プリペアドステートメントを使えば安心というわけではありません。次のようなプログラムにもSQLインジェクションの脆弱性が生まれます。

$id = filter_input(INPUT_GET, "id");
$sql = "select id, title from categories where id = " . $id;
$st = $pdo->prepare($sql);
$st->execute();

上記のプログラムはプリペアドステートメントを利用していますが、SQL文にユーザの入力値を連結している点は変わりません。ユーザの入力値はプレースホルダを使って制御するようにします。

$id = filter_input(INPUT_GET, "id");
$sql = "select id, title from categories where id = :id";
$st = $pdo->prepare($sql);
$st->bindValue(":id", $id, PDO::PARAM_INT);
$st->execute();

またそれ以外の予防的な対策として、ユーザの入力値を検証(バリデーション)することも考えられます。たとえば今回のプログラムの場合は、以下のように filter_input 関数の結果を int 型にキャストしておけば、 9 or 1 = 1 のような入力は or のような文字列以降は無視されるので 9 という数値に置き換わります。

$id = (int)filter_input(INPUT_GET, "id");

他にも if 文を使ってユーザの入力に不正な文字列が含まれているかどうか検証することもできるでしょう。しかしこのような入力データの検証はあくまで予防策として捉えるべきで、根本的な対策として、ユーザの入力値をSQLに連結しないことを徹底する必要があります。

他にも $st 変数に対して fetch メソッドを呼び出してレコードを 1 件だけ取得するように実装しておけば、 categories テーブルの全レコードが出力されるような問題は防げたかもしれません。しかしこれらもあくまで予防的な対策です。

まとめ

  • SQLインジェクションは、悪意のあるユーザの入力によって意図しないSQLが実行されてしまう問題
  • ユーザの入力値をSQLに連結している部分が原因となる
  • プリペアドステートメントによってSQLの構文解析を先に済ませることで、ユーザの入力によってSQLの構文が拡張されないようにする