はじめに
保存されたデータが後から牙をむく――静かな時限爆弾型 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);
問題点
- 文字列連結で SQL を構築している
- multi_query により複数文実行が可能
- 保存された 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();
}
?>