1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【セキュリティ】Second-Order SQL Injection を徹底解説

1
Last updated at Posted at 2025-11-25

はじめに

保存されたデータが後から牙をむく――静かな時限爆弾型 SQL インジェクション

SQL Injection と聞くと、多くの人は「ユーザー入力がその場で SQL を壊して暴走する攻撃」を思い浮かべます。しかし、もっと厄介で見つけにくい “静かな脅威” が存在します。

それが Second-Order SQL Injection(2次 SQLi / Stored SQLi)

この攻撃は、悪意ある入力が「保存された後」、まったく別の処理で再利用された瞬間に爆発するという、“時限式”のインジェクションです。

フロントエンドのバリデーションも、入力時のサニタイズも、あっさりすり抜けてしまう攻撃であり、アプリの設計を根本から理解していないと発見が極めて困難です。

この記事では、脆弱な BookStore アプリを題材に、Second-Order SQL Injection の仕組み・ワークフロー・攻撃例・対策をわかりやすく解説します。


Second-Order SQL Injection のワークフロー

Second-Order SQLi は以下の流れで発生します。

攻撃のポイントは “2回目の利用で発火すること”。
最初の INSERT では全く問題が起きません。


Impact(影響範囲)

Second-Order SQL Injection が危険な理由は以下のとおり:

1. 入力時では攻撃が発生しない

フロントの入力チェックやリアルタイムの SQL エラーは一切起きないため、開発者は脆弱性に気づきません。

2. 悪意あるデータが DB の“内部”に潜伏

アプリ内部で「信用されたデータ」として扱われるため、2次利用された際に突然発火します。

3. 管理者権限の操作で発火することが多い

update.php、ログ管理、バックアップ、統計など
管理者が操作したときに発動 → 影響が最大化

4. 場合によってはデータベース全損

DROP TABLE、全件 UPDATE、データ漏洩など
攻撃者の目的次第で壊滅的な結果へ。


実例:BookStore アプリの Second-Order SQLi(from TryCatchme)

今回使用するアプリは下記のように書籍を登録できるシンプルな PHP アプリです。

  • add.php
  • update.php

ユーザーは SSN・book_name・author を入力します。

add.php の挙動と落とし穴

コード(脆弱バージョン)
if (isset($_POST['submit'])) {

    $ssn = $conn->real_escape_string($_POST['ssn']);
    $book_name = $conn->real_escape_string($_POST['book_name']);
    $author = $conn->real_escape_string($_POST['author']);

    $sql = "INSERT INTO books (ssn, book_name, author)
            VALUES ('$ssn', '$book_name', '$author')";

    $conn->query($sql);
}
real_escape_string() があっても Second-Order SQLi は防げない理由

real_escape_string() は入力時の即時 SQLi(一次 SQLi)を防ぐ目的で使われますが、

DB に保存される値の“意味”までは無害化できません。

例えば:

test';

12345'; UPDATE books SET book_name='Hacked'; --

は、INSERT 実行時は harmless ですが、
後で SQL 文に再利用されると攻撃コードに変身します。


攻撃準備:ペイロードの保存

SSN に以下を入力して書籍を追加します。

12345'; UPDATE books SET book_name='Hacked'; --

INSERT 時には何も起こらず、データベースには「ただの文字列」として保存されます。


update.php が脆弱な理由

update.php の問題部分
$ssn = $_POST['ssn_' . $unique_id];
$new_book_name = $_POST['new_book_name_' . $unique_id];
$new_author = $_POST['new_author_' . $unique_id];

$update_sql = "UPDATE books SET book_name = '$new_book_name', author = '$new_author'
               WHERE ssn = '$ssn';
               INSERT INTO logs (page) VALUES ('update.php');";
$conn->multi_query($update_sql);
問題点
  1. 文字列連結で SQL を構築している
  2. multi_query により複数文実行が可能
  3. 保存された ssn を無加工で使用

Second-Order SQLi が起きる条件がすべて揃っています。


Exploit:攻撃発動の瞬間

update.php を開き、対象書籍を更新すると…

実際に DB が受け取る SQL(攻撃発動)

UPDATE books SET book_name = 'Testing', author = 'Hacker'
WHERE ssn = '12345';
UPDATE books SET book_name = 'Hacked'; --';
INSERT INTO logs (page) VALUES ('update.php');

結果

  • books テーブル内の book_name が全件「Hacked」に書き換えられる
  • 残りの SQL はコメントアウトされ無効化
  • 管理者がアクションした瞬間にデータベースが破壊される

典型的な Second-Order SQL Injection の成功例です。


防止策(Secure Coding)

Second-Order SQL Injection の防御は、一次 SQLi と同じではありません。
本質は:

「データをいつ使うか」にフォーカスして守る

以下は必須の対策です。


1. Prepared Statement(パラメータ化クエリ)を徹底する

$stmt = $conn->prepare("UPDATE books SET book_name=?, author=? WHERE ssn=?");
$stmt->bind_param("sss", $new_book_name, $new_author, $ssn);
$stmt->execute();

入力時だけでなく、再利用時にも必ずパラメータ化を適用すること。


2. multi_query() を禁止する

複数文実行を無効化します。


3. DB 内部データを“信用しない”

保存データは必ずエスケープ/パラメータ化して再利用する。


4. 入力の制限(ホワイトリスト)

SSN や ID は英数字だけに制限する。


まとめ

Second-Order SQL Injection は以下の特徴を持つ非常に危険な脆弱性です。

特徴 内容
発生タイミング 2回目(保存 → 再利用)
原因 保存データを SQL に無加工で使用
防げないもの real_escape_string() や簡易サニタイズ
よくある発火点 update、ログ保存、バッチ処理、管理画面
破壊力 DROP / UPDATE 全件 / データ漏洩

攻撃が「保存時」ではなく「再利用時」に起きるため、
発見が難しく、被害は大きくなりがちです。


安全な add.php(書籍追加)

ポイント:

  • real_escape_string()使わない(プリペアドステートメントに一本化)
  • 必要最低限のバリデーション(SSN を英数字のみなど)
<?php
// db.php などで接続を共通化してもOK
$host = 'localhost';
$user = 'db_user';
$pass = 'db_pass';
$dbname = 'BookStore';

$conn = new mysqli($host, $user, $pass, $dbname);
if ($conn->connect_error) {
    die('Connection failed: ' . $conn->connect_error);
}

// 文字コードを明示
$conn->set_charset('utf8mb4');

$message = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['submit'])) {
    // 生データ取得
    $ssn       = trim($_POST['ssn'] ?? '');
    $book_name = trim($_POST['book_name'] ?? '');
    $author    = trim($_POST['author'] ?? '');

    // --- 簡単なバリデーション ---
    if ($ssn === '' || $book_name === '' || $author === '') {
        $message = "<p class='text-red-500'>All fields are required.</p>";
    } elseif (!preg_match('/^[A-Za-z0-9_-]+$/', $ssn)) {
        // SSN を英数字 + _ - のみに制限
        $message = "<p class='text-red-500'>Invalid SSN format.</p>";
    } else {
        // --- プリペアドステートメントで安全に INSERT ---
        $stmt = $conn->prepare(
            "INSERT INTO books (ssn, book_name, author) VALUES (?, ?, ?)"
        );

        if (!$stmt) {
            $message = "<p class='text-red-500'>Prepare failed: " . htmlspecialchars($conn->error) . "</p>";
        } else {
            $stmt->bind_param('sss', $ssn, $book_name, $author);

            if ($stmt->execute()) {
                $message = "<p class='text-green-500'>New book added successfully</p>";
            } else {
                $message = "<p class='text-red-500'>Error: " . htmlspecialchars($stmt->error) . "</p>";
            }

            $stmt->close();
        }
    }
}
?>

安全な update.php(書籍更新 + ログ記録)

元コードの危険ポイント:

  • UPDATE ... WHERE ssn = '$ssn'; INSERT INTO logs ...;1つの文字列に連結
  • multi_query() で複数クエリ実行
  • 保存済みの ssn を無加工で SQL に埋め込み

→ これを 全部禁止 して、

  • 検索も更新もログ記録も すべて prepared statement
  • クエリは 1文ずつ実行
    にします。
<?php
$host = 'localhost';
$user = 'db_user';
$pass = 'db_pass';
$dbname = 'BookStore';

$conn = new mysqli($host, $user, $pass, $dbname);
if ($conn->connect_error) {
    die('Connection failed: ' . $conn->connect_error);
}
$conn->set_charset('utf8mb4');

$message = '';

// --- 更新処理 ---
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['update'])) {
    $ssn           = trim($_POST['ssn'] ?? '');
    $new_book_name = trim($_POST['new_book_name'] ?? '');
    $new_author    = trim($_POST['new_author'] ?? '');

    if ($ssn === '' || $new_book_name === '' || $new_author === '') {
        $message = "<p class='text-red-500'>All fields are required.</p>";
    } elseif (!preg_match('/^[A-Za-z0-9_-]+$/', $ssn)) {
        $message = "<p class='text-red-500'>Invalid SSN format.</p>";
    } else {
        // トランザクション開始(任意だが安全)
        $conn->begin_transaction();

        try {
            // 1. books テーブル更新(プリペアド)
            $updateStmt = $conn->prepare(
                "UPDATE books
                 SET book_name = ?, author = ?
                 WHERE ssn = ?"
            );

            if (!$updateStmt) {
                throw new Exception('Prepare failed (update): ' . $conn->error);
            }

            $updateStmt->bind_param('sss', $new_book_name, $new_author, $ssn);

            if (!$updateStmt->execute()) {
                throw new Exception('Execute failed (update): ' . $updateStmt->error);
            }

            $updateStmt->close();

            // 2. logs テーブルにログを INSERT(プリペアド)
            $logStmt = $conn->prepare(
                "INSERT INTO logs (page) VALUES (?)"
            );

            if (!$logStmt) {
                throw new Exception('Prepare failed (log): ' . $conn->error);
            }

            $page = 'update.php';
            $logStmt->bind_param('s', $page);

            if (!$logStmt->execute()) {
                throw new Exception('Execute failed (log): ' . $logStmt->error);
            }

            $logStmt->close();

            // 3. 両方成功したのでコミット
            $conn->commit();

            $message = "<p class='text-green-500'>Book updated successfully.</p>";

        } catch (Exception $e) {
            $conn->rollback();
            $message = "<p class='text-red-500'>Error: " . htmlspecialchars($e->getMessage()) . "</p>";
        }
    }
}

// --- 一覧取得(更新フォーム表示用) ---
$books = [];
$result = $conn->query("SELECT ssn, book_name, author FROM books ORDER BY ssn ASC");
if ($result) {
    while ($row = $result->fetch_assoc()) {
        $books[] = $row;
    }
    $result->free();
}
?>


1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?