SQLインジェクション対策のエスケープ処理|実装方法と落とし穴

エスケープ処理によるSQLインジェクション対策の実装方法と重要な注意点を解説。mysql_real_escape_string等の正しい使い方と、エスケープだけでは防げない攻撃パターン、文字エンコーディングの罠など、開発者が知るべき限界と適切な実装方法を詳説。

エスケープ処理とは何か|基本概念と役割

エスケープ処理は、SQLインジェクション対策の一つとして長年使用されてきた手法です。しかし、完全な対策ではないことを最初に明確にしておく必要があります。現代ではプレースホルダが推奨されますが、レガシーシステムや特定の状況では、エスケープ処理の理解が依然として重要です。

エスケープ処理の定義と目的

エスケープ処理とは、SQL文において特別な意味を持つ文字を、通常の文字として扱えるように変換する処理です。これは、鍵括弧の中の文字が特別な意味を持たないようにするようなものです。

特殊文字の無害化メカニズムにより、攻撃者が注入しようとするSQL文を単なる文字列データとして扱わせることができます。例えば、シングルクォート(')はSQL文で文字列を区切る特別な記号ですが、エスケープ処理により「\'」や「''」に変換することで、文字列の一部として認識させます。

SQLメタ文字の文字列化は、データベースエンジンがSQL文を解析する際に、特定の文字を命令ではなくデータとして解釈させる仕組みです。これは、HTMLでの「<」が「<」として表示されるのと同じ原理です。

データとコードの境界明確化が、エスケープ処理の最終目的です。ユーザー入力をSQL文の構造から切り離し、純粋なデータとして扱うことで、SQLインジェクション攻撃を防ぐ試みです。ただし、この方法には限界があることを理解しておく必要があります。

エスケープが必要な特殊文字一覧

データベースシステムによって若干の違いはありますが、一般的にエスケープが必要な文字は以下の通りです。

文字 SQL内での意味 エスケープ後 用途・影響
'(シングルクォート) 文字列の区切り \' または '' 文字列リテラルの終端
"(ダブルクォート) 識別子の区切り \" カラム名、テーブル名
\(バックスラッシュ) エスケープ文字 \\ ファイルパス、正規表現
%(パーセント) LIKE句のワイルドカード \% 任意の文字列
_(アンダースコア) LIKE句のワイルドカード \_ 任意の1文字
;(セミコロン) 文の区切り そのまま* 複数文の実行
--(ハイフン2つ) コメント開始 そのまま* 以降を無効化
/* ブロックコメント開始 そのまま* 範囲を無効化

*これらの文字は、文字列内では通常エスケープ不要ですが、コンテキストによっては注意が必要です。

プレースホルダとの決定的な違い

エスケープ処理とプレースホルダは、どちらもSQLインジェクション対策ですが、根本的なアプローチが異なります

処理タイミングの違い

エスケープ処理
アプリケーション側で文字列操作として処理されます。SQL文を構築する時点で、特殊文字を変換してからデータベースに送信します。つまり、最終的には一つの完成したSQL文がデータベースに渡されます。この方法では、エスケープ漏れや不適切な実装により、SQL文の構造が変更される可能性が残ります。処理はアプリケーションサーバーのCPUで行われ、データベースは通常のSQL文として受け取ります。
プレースホルダ
データベース側でパラメータとして処理されます。SQL文の構造(テンプレート)とデータを別々に送信し、データベースエンジンが安全に結合します。SQL文の構造は事前に確定し、後から変更することは不可能です。この方法により、データがSQL文の構造を変更することは原理的にあり得ません。処理はデータベースサーバーで行われ、より確実な安全性を提供します。

安全性レベルの比較

エスケープ処理とプレースホルダの安全性には、明確な差があります:

  • エスケープ処理:部分的な対策(60-80%の攻撃を防御)

    • 文字列型の単純な攻撃は防げる
    • 数値型、識別子への攻撃は防げない
    • 実装ミスによる脆弱性のリスク
    • 文字エンコーディング問題の影響を受ける
  • プレースホルダ:根本的な対策(99%以上の攻撃を防御)

    • SQL構造とデータの完全分離
    • あらゆる型の攻撃に対応
    • 実装が単純でミスが起きにくい
    • 文字エンコーディング問題の影響を受けにくい

MySQL/MariaDBでのエスケープ実装

MySQLとMariaDBは、Webアプリケーションで最も広く使用されているデータベースです。これらのシステムでのエスケープ処理には、特有の注意点があります。

mysql_real_escape_stringの正しい使い方

mysql_real_escape_string()(mysqli版ではmysqli_real_escape_string())は、MySQL専用のエスケープ関数です。この関数は、接続の文字セットを考慮してエスケープを行うため、addslashes()より安全です。

PHP環境での実装

<?php
// データベース接続(mysqliの例)
$connection = new mysqli($host, $user, $password, $database);

// 文字セットを必ず設定(重要!)
$connection->set_charset("utf8mb4");

// 基本的な使用方法
$unsafe_value = $_POST['username'];
$safe_value = mysqli_real_escape_string($connection, $unsafe_value);

// 重要:必ずクォートで囲む
$query = "SELECT * FROM users WHERE username = '$safe_value'";
// これで「' OR '1'='1」のような攻撃を防げる

// よくある間違い:クォートなしは脆弱
$id = mysqli_real_escape_string($connection, $_GET['id']);
$query = "SELECT * FROM users WHERE id = $id";  // 危険!
// 攻撃例:id=1 OR 1=1 はエスケープされない

// 数値の場合の正しい対処法
$id = (int)$_GET['id'];  // 型変換
$query = "SELECT * FROM users WHERE id = $id";  // これなら安全

// 複数の値をエスケープする場合
function escapeMultiple($connection, $values) {
	$escaped = [];
	foreach ($values as $key => $value) {
		$escaped[$key] = mysqli_real_escape_string($connection, $value);
	}
	return $escaped;
}

$userData = escapeMultiple($connection, $_POST);
$query = "INSERT INTO users (name, email, bio) VALUES (
	'{$userData['name']}',
	'{$userData['email']}',
	'{$userData['bio']}'
)";
?>

文字セットの重要性

文字セットの設定は、エスケープ処理の安全性に直結する重要な要素です。不適切な設定は、重大な脆弱性を生み出す可能性があります。

マルチバイト文字の脆弱性

<?php
// 正しい方法:接続オブジェクトのメソッドを使用
$connection = new mysqli($host, $user, $password, $database);
$connection->set_charset("utf8mb4");

// 危険な方法:SET NAMESの使用(脆弱性の原因)
mysqli_query($connection, "SET NAMES utf8");  // これは避ける!

// なぜ危険なのか?
// SET NAMESはサーバー側の設定のみ変更し、
// クライアント側のライブラリは認識しない
// これによりエスケープ処理が不適切になる可能性

// PDOでの正しい設定方法
try {
	$pdo = new PDO(
		"mysql:host=$host;dbname=$database;charset=utf8mb4",
		$user,
		$password,
		[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
	);
} catch (PDOException $e) {
	die("接続失敗: " . $e->getMessage());
}

// 文字セット関連の脆弱性チェック
function checkCharsetVulnerability($connection) {
	$clientCharset = $connection->character_set_name();
	$result = $connection->query("SHOW VARIABLES LIKE 'character_set_connection'");
	$row = $result->fetch_assoc();
	$serverCharset = $row['Value'];
	
	if ($clientCharset !== $serverCharset) {
		throw new Exception("文字セットの不一致: クライアント[$clientCharset] サーバー[$serverCharset]");
	}
	
	return true;
}
?>

MySQLの特殊なエスケープルール

MySQLには、SQLモードによって動作が変わる特殊なエスケープルールがあります。これらを理解しておくことは、安全なアプリケーション開発に不可欠です。

NO_BACKSLASH_ESCAPESモード
このモードが有効な場合、バックスラッシュ(\)がエスケープ文字として機能しません。代わりに、シングルクォートを2つ重ねて('')エスケープする必要があります。このモードは、標準SQLにより準拠した動作を提供しますが、多くのPHPアプリケーションは、このモードを想定していないため、注意が必要です。確認方法:`SELECT @@sql_mode;`で現在のモードを確認できます。
ANSI_QUOTESモード
このモードでは、ダブルクォート(")が文字列の区切りではなく、識別子(テーブル名やカラム名)の区切りとして機能します。文字列リテラルにはシングルクォート(')のみが使用可能になります。これにより、`"users"."name"`のような標準SQL記法が使用できますが、既存のアプリケーションが文字列にダブルクォートを使用している場合、エラーの原因となります。
STRICT_TRANS_TABLESモード
厳密モードが有効な場合、不正なデータの挿入や更新がエラーとなります。これはセキュリティの観点からは望ましい動作ですが、エスケープ処理の不備がより顕在化しやすくなります。

PostgreSQLでのエスケープ実装

PostgreSQLは、エンタープライズ環境で広く使用される、高機能なオープンソースデータベースです。MySQLとは異なるエスケープ処理の特徴があります。

pg_escape_string vs pg_escape_literal

PostgreSQLには、用途の異なる2つの主要なエスケープ関数があります。それぞれの特徴と使い分けを理解することが重要です。

関数の使い分け

<?php
// PostgreSQL接続
$connection = pg_connect("host=localhost dbname=mydb user=myuser password=mypass");

// pg_escape_string:文字列の中身のみエスケープ
$user_input = "O'Reilly"; 
$escaped = pg_escape_string($connection, $user_input);
echo $escaped; // 出力: O''Reilly

// 使用時は手動でクォートを追加
$query = "SELECT * FROM books WHERE author = '$escaped'";
// 結果: SELECT * FROM books WHERE author = 'O''Reilly'

// pg_escape_literal:クォート込みでエスケープ(PostgreSQL 9.0以降)
$escaped_literal = pg_escape_literal($connection, $user_input);
echo $escaped_literal; // 出力: 'O''Reilly'(クォート付き)

// クォートは自動で付与されるため、そのまま使用
$query = "SELECT * FROM books WHERE author = $escaped_literal";
// 結果: SELECT * FROM books WHERE author = 'O''Reilly'

// pg_escape_identifier:識別子(テーブル名、カラム名)用
$table_name = 'user-data';
$escaped_identifier = pg_escape_identifier($connection, $table_name);
echo $escaped_identifier; // 出力: "user-data"(ダブルクォート付き)

$query = "SELECT * FROM $escaped_identifier WHERE active = true";

// 複雑な例:複数の種類のデータを含むクエリ
function buildSecureQuery($conn, $table, $conditions) {
	$table_escaped = pg_escape_identifier($conn, $table);
	$where_parts = [];
	
	foreach ($conditions as $column => $value) {
		$col_escaped = pg_escape_identifier($conn, $column);
		
		if (is_null($value)) {
			$where_parts[] = "$col_escaped IS NULL";
		} elseif (is_bool($value)) {
			$val = $value ? 'true' : 'false';
			$where_parts[] = "$col_escaped = $val";
		} elseif (is_numeric($value)) {
			$where_parts[] = "$col_escaped = $value";
		} else {
			$val_escaped = pg_escape_literal($conn, $value);
			$where_parts[] = "$col_escaped = $val_escaped";
		}
	}
	
	$where_clause = implode(' AND ', $where_parts);
	return "SELECT * FROM $table_escaped WHERE $where_clause";
}
?>

PostgreSQL特有の考慮事項

PostgreSQLとMySQLでは、いくつかの重要な違いがあり、エスケープ処理にも影響します。

項目 MySQL PostgreSQL 影響
エスケープ文字 \(バックスラッシュ) 標準は'(シングルクォート) エスケープ方法が異なる
文字列結合 CONCAT()関数 ||演算子 SQL構築方法が異なる
大文字小文字 通常区別なし 識別子は区別あり テーブル名等の扱い注意
文字列型 VARCHAR VARCHAR, TEXT, CHAR 型に応じた処理
ブーリアン型 TINYINT(1) 真のBOOLEAN型 true/false文字列

ドル引用符記法の活用

PostgreSQLには、独特のドル引用符記法があり、これを理解しておくことは重要です。

-- 通常のクォート(エスケープ必要)
INSERT INTO posts (content) VALUES ('It''s a nice day');
-- シングルクォートを2つ重ねてエスケープ

-- ドル引用符記法(エスケープ不要)
INSERT INTO posts (content) VALUES ($$It's a nice day$$);
-- $$の間はそのまま文字列として扱われる

-- タグ付きドル引用符(より安全)
INSERT INTO posts (content) VALUES ($content$It's a "nice" day$content$);
-- $tag$...$tag$の形式で、tagは任意の文字列

-- 関数定義での使用例
CREATE FUNCTION check_password(pwd text) RETURNS boolean AS $$
BEGIN
	-- ここでは'や"を自由に使える
	IF pwd LIKE '%password%' OR pwd LIKE '%123456%' THEN
		RETURN false;
	END IF;
	RETURN true;
END;
$$ LANGUAGE plpgsql;

ただし、ドル引用符を動的に生成するのは危険です:

// 危険:ユーザー入力でドル引用符を使用
$content = $_POST['content'];
$query = "INSERT INTO posts (content) VALUES ($$" . $content . "$$)";
// 攻撃例:content = $$); DROP TABLE posts; --

SQL Serverでのエスケープ実装

Microsoft SQL Serverは、企業環境で広く使用されており、T-SQL独自の機能を持っています。エスケープ処理にも特有のアプローチがあります。

T-SQLでのエスケープ方法

SQL Serverでは、主にシングルクォートの重複によるエスケープと、QUOTENAME関数を使用します。

QUOTENAME関数の活用

-- QUOTENAME関数:識別子(テーブル名、カラム名)を安全にエスケープ
DECLARE @tableName NVARCHAR(128) = 'Users; DROP TABLE Accounts--'
DECLARE @sql NVARCHAR(MAX)

-- QUOTENAMEで識別子をエスケープ
SET @sql = 'SELECT * FROM ' + QUOTENAME(@tableName)
PRINT @sql
-- 結果:SELECT * FROM [Users; DROP TABLE Accounts--]
-- 角括弧で囲まれ、SQLインジェクションが防がれる

-- スキーマ付きテーブル名の処理
DECLARE @schema NVARCHAR(128) = 'dbo'
DECLARE @table NVARCHAR(128) = 'Products'
SET @sql = 'SELECT * FROM ' + QUOTENAME(@schema) + '.' + QUOTENAME(@table)
-- 結果:SELECT * FROM [dbo].[Products]

-- 文字列値のエスケープ(シングルクォートを2重に)
DECLARE @userInput NVARCHAR(500) = 'O''Reilly'
SET @userInput = REPLACE(@userInput, '''', '''''')
SET @sql = 'SELECT * FROM Authors WHERE LastName = ''' + @userInput + ''''

-- カラム名の動的指定(QUOTENAMEを使用)
DECLARE @sortColumn NVARCHAR(128) = 'LastName; DROP TABLE--'
SET @sql = 'SELECT * FROM Users ORDER BY ' + QUOTENAME(@sortColumn)
-- 結果:SELECT * FROM Users ORDER BY [LastName; DROP TABLE--]

-- ストアドプロシージャでの実装例
CREATE PROCEDURE GetUsersByDepartment
	@DeptName NVARCHAR(100),
	@SortBy NVARCHAR(50) = 'Name'
AS
BEGIN
	DECLARE @sql NVARCHAR(MAX)
	
	-- ソートカラムの検証(ホワイトリスト)
	IF @SortBy NOT IN ('Name', 'Email', 'HireDate', 'Salary')
	BEGIN
		SET @SortBy = 'Name'  -- デフォルト値
	END
	
	-- 動的SQL構築(パラメータ化と組み合わせ)
	SET @sql = N'SELECT * FROM Users WHERE Department = @dept 
				 ORDER BY ' + QUOTENAME(@SortBy)
	
	-- sp_executesqlでパラメータ化実行
	EXEC sp_executesql @sql, 
					   N'@dept NVARCHAR(100)', 
					   @dept = @DeptName
END

.NETでのパラメータ化との併用

SQL Serverを使用する場合、エスケープよりもパラメータ化を強く推奨します。

using System;
using System.Data;
using System.Data.SqlClient;

public class SqlServerSecurity
{
	private string connectionString;
	
	// 推奨:SqlParameterを使用したパラメータ化
	public DataTable GetUsersSafe(string department, decimal minSalary)
	{
		using (SqlConnection conn = new SqlConnection(connectionString))
		{
			string sql = @"SELECT * FROM Users 
						  WHERE Department = @Department 
						  AND Salary >= @MinSalary";
			
			using (SqlCommand cmd = new SqlCommand(sql, conn))
			{
				// パラメータの追加(型も指定)
				cmd.Parameters.Add("@Department", SqlDbType.NVarChar, 50).Value = department;
				cmd.Parameters.Add("@MinSalary", SqlDbType.Decimal).Value = minSalary;
				
				DataTable dt = new DataTable();
				conn.Open();
				
				using (SqlDataAdapter adapter = new SqlDataAdapter(cmd))
				{
					adapter.Fill(dt);
				}
				
				return dt;
			}
		}
	}
	
	// どうしてもエスケープが必要な場合(識別子の動的指定)
	public DataTable GetDataFromTable(string tableName)
	{
		using (SqlConnection conn = new SqlConnection(connectionString))
		{
			// テーブル名の検証(ホワイトリスト)
			if (!IsValidTableName(tableName))
			{
				throw new ArgumentException("Invalid table name");
			}
			
			// QUOTENAME相当の処理(C#側)
			string escapedTable = "[" + tableName.Replace("]", "]]") + "]";
			string sql = $"SELECT TOP 100 * FROM {escapedTable}";
			
			using (SqlCommand cmd = new SqlCommand(sql, conn))
			{
				DataTable dt = new DataTable();
				conn.Open();
				
				using (SqlDataAdapter adapter = new SqlDataAdapter(cmd))
				{
					adapter.Fill(dt);
				}
				
				return dt;
			}
		}
	}
	
	private bool IsValidTableName(string tableName)
	{
		// 許可されたテーブル名のリスト
		string[] allowedTables = { "Users", "Products", "Orders", "Customers" };
		return Array.Exists(allowedTables, t => t.Equals(tableName, StringComparison.OrdinalIgnoreCase));
	}
}

エスケープ処理の限界と問題点

エスケープ処理には、構造的な限界があり、すべての攻撃を防ぐことはできません。これらの限界を理解することは、適切なセキュリティ対策を選択する上で極めて重要です。

数値型への攻撃

最も深刻な問題の一つは、数値型のパラメータに対してエスケープ処理が無効であることです。

エスケープが効かないケース

<?php
// 脆弱なコード:数値型へのエスケープ
$id = mysqli_real_escape_string($conn, $_GET['id']);
$query = "SELECT * FROM users WHERE id = $id";
// 攻撃例:id=1 OR 1=1
// エスケープ後も:1 OR 1=1(変化なし)
// 結果:SELECT * FROM users WHERE id = 1 OR 1=1(全件取得)

// なぜエスケープが効かないのか?
// エスケープは特殊文字('、"、\等)を対象とする
// 「1 OR 1=1」には特殊文字が含まれないため、そのまま通過

// 対策1:型変換(最も確実)
$id = (int)$_GET['id'];
$query = "SELECT * FROM users WHERE id = $id";

// 対策2:数値検証
if (!is_numeric($_GET['id'])) {
	die("Invalid input");
}
$id = $_GET['id'];

// 対策3:正規表現での検証
if (!preg_match('/^\d+$/', $_GET['id'])) {
	die("Invalid ID format");
}

// より複雑な例:BETWEEN句での攻撃
$min = mysqli_real_escape_string($conn, $_GET['min']);
$max = mysqli_real_escape_string($conn, $_GET['max']);
$query = "SELECT * FROM products WHERE price BETWEEN $min AND $max";
// 攻撃:min=0 AND 1=1 OR price>0 AND price

// 安全な実装
$min = (float)$_GET['min'];
$max = (float)$_GET['max'];

// 範囲の妥当性も検証
if ($min < 0 || $max > 1000000 || $min > $max) {
	die("Invalid price range");
}

$query = "SELECT * FROM products WHERE price BETWEEN $min AND $max";
?>

LIKE句での脆弱性

LIKE句では、%_が特別な意味を持つため、通常のエスケープ処理では不十分です。

問題点
LIKE句において、%(任意の文字列)と_(任意の1文字)はワイルドカードとして機能します。これらをエスケープしないと、攻撃者が意図しない検索を実行でき、DoS攻撃やデータの大量取得が可能になります。例えば、検索語に「%」を入力されると、全レコードが検索対象となり、データベースに大きな負荷がかかります。
攻撃例
ユーザーが検索ボックスに「%」や「_」を入力すると、意図しない大量のレコードがマッチします。「%%%」のような入力では、さらに負荷が増大し、サービス停止に至る可能性があります。また、「a%」で始まるすべてのデータを取得するなど、情報漏洩のリスクもあります。
対策
通常のエスケープに加えて、LIKE句専用のエスケープ処理が必要です。PHPでは`addcslashes()`関数を使用し、%と_を追加でエスケープします。また、ESCAPE句を使用してエスケープ文字を明示的に指定することも重要です。

安全なLIKE句の実装

<?php
function escapeLike($string, $connection) {
	// Step 1: 通常のSQLエスケープ
	$escaped = mysqli_real_escape_string($connection, $string);
	
	// Step 2: LIKE句用の追加エスケープ
	// % と _ をエスケープ(\% と \_ に変換)
	$escaped = str_replace(['%', '_'], ['\\%', '\\_'], $escaped);
	
	// 別の方法:addcslashes使用
	// $escaped = addcslashes($escaped, '%_');
	
	return $escaped;
}

// 使用例
$searchTerm = $_GET['search'];
$escapedTerm = escapeLike($searchTerm, $connection);

// ESCAPE句でエスケープ文字を明示
$query = "SELECT * FROM products 
		  WHERE name LIKE '%$escapedTerm%' ESCAPE '\\'";

// より安全な実装:検索語の長さ制限
function safeSearch($term, $connection) {
	// 空白や短すぎる検索を防ぐ
	$term = trim($term);
	if (strlen($term) < 2) {
		return [];  // 検索しない
	}
	
	// 長すぎる検索語を制限
	if (strlen($term) > 100) {
		$term = substr($term, 0, 100);
	}
	
	$escaped = escapeLike($term, $connection);
	
	// 部分一致検索
	$query = "SELECT * FROM products 
			  WHERE name LIKE '%$escaped%' ESCAPE '\\' 
			  LIMIT 100";  // 結果も制限
	
	return mysqli_query($connection, $query);
}

// PostgreSQLでの例
function escapeLikePostgres($string, $connection) {
	// PostgreSQLの場合
	$escaped = pg_escape_string($connection, $string);
	
	// SIMILAR TO演算子用のエスケープ
	$escaped = str_replace(
		['%', '_', '[', ']'], 
		['\\%', '\\_', '\\[', '\\]'], 
		$escaped
	);
	
	return $escaped;
}
?>

ORDER BY句とGROUP BY句の問題

これらの句では、カラム名を文字列として扱えないという根本的な制限があります。

問題 なぜエスケープできないか 対策
ORDER BY カラム名は文字列として扱えない 識別子はリテラルではない ホワイトリスト検証
GROUP BY 同上 同上 同上
LIMIT 数値のみだがエスケープ不可 数値コンテキスト 数値検証
OFFSET 同上 同上 同上
<?php
// ORDER BY句の安全な実装
function buildSortQuery($sortColumn, $sortOrder) {
	// ホワイトリスト方式
	$allowedColumns = ['name', 'price', 'created_at', 'popularity'];
	$allowedOrders = ['ASC', 'DESC'];
	
	// デフォルト値
	if (!in_array($sortColumn, $allowedColumns)) {
		$sortColumn = 'name';
	}
	
	if (!in_array(strtoupper($sortOrder), $allowedOrders)) {
		$sortOrder = 'ASC';
	}
	
	// 安全にクエリ構築
	return "SELECT * FROM products ORDER BY $sortColumn $sortOrder";
}

// 配列でのマッピング方式
function getSortedProducts($sortKey) {
	$sortMap = [
		'newest' => 'created_at DESC',
		'oldest' => 'created_at ASC',
		'cheapest' => 'price ASC',
		'expensive' => 'price DESC',
		'popular' => 'view_count DESC'
	];
	
	$orderBy = $sortMap[$sortKey] ?? 'name ASC';
	
	return "SELECT * FROM products ORDER BY $orderBy";
}
?>

文字エンコーディングの罠と対策

文字エンコーディングに関する問題は、エスケープ処理を無効化する可能性があり、深刻な脆弱性につながります。

UTF-8の不正なバイトシーケンス

UTF-8には、同じ文字を異なるバイト列で表現する「オーバーロングエンコーディング」という問題があります。

オーバーロングエンコーディング攻撃

正常なエンコーディング:
' (U+0027) → 0x27(1バイト)

オーバーロングエンコーディング(無効だが一部システムで誤認識):
' → 0xC0 0xA7(2バイト表現)
' → 0xE0 0x80 0xA7(3バイト表現)

古いシステムやライブラリでは、これらを同じ文字として
誤認識し、エスケープ処理をバイパスできる可能性

対策コード:

<?php
// UTF-8の妥当性チェック
function isValidUtf8($string) {
	return mb_check_encoding($string, 'UTF-8');
}

// 不正なUTF-8シーケンスを除去
function sanitizeUtf8($input) {
	// 不正なバイトシーケンスを検出
	if (!isValidUtf8($input)) {
		// ログに記録(攻撃の可能性)
		error_log("Invalid UTF-8 detected: " . bin2hex($input));
		
		// 安全な文字のみを通過させる
		$input = mb_convert_encoding($input, 'UTF-8', 'UTF-8');
	}
	
	// 制御文字も除去
	$input = preg_replace('/[\x00-\x1F\x7F]/u', '', $input);
	
	return $input;
}

// 使用例
$userInput = $_POST['comment'];
$sanitized = sanitizeUtf8($userInput);
$escaped = mysqli_real_escape_string($connection, $sanitized);
?>

Shift-JISの5C問題

日本語環境で特に注意が必要なのが、Shift-JISの「5C問題」です。

<?php
// Shift-JISの問題を示す例
// 「表」という文字:0x95 0x5C
// 0x5Cは「\」(バックスラッシュ)と同じ

// 危険な例(Shift-JIS環境)
$input = "表' OR '1'='1";
// バイト列:95 5C 27 20 4F 52 ...
// 0x5Cが\として解釈され、次の'がエスケープされる可能性

// 対策1:UTF-8への統一(強く推奨)
mb_internal_encoding('UTF-8');
$connection->set_charset('utf8mb4');

// 対策2:どうしてもShift-JISを使う場合
function escapeShiftJIS($str) {
	// マルチバイト対応のエスケープが必要
	$result = '';
	$len = mb_strlen($str, 'SJIS');
	
	for ($i = 0; $i < $len; $i++) {
		$char = mb_substr($str, $i, 1, 'SJIS');
		$bytes = unpack('C*', $char);
		
		// 5Cを含む文字の特別処理
		if (in_array(0x5C, $bytes)) {
			// 特別な処理が必要
		}
		
		$result .= $char;
	}
	
	return $result;
}

// 現実的な対策:UTF-8への移行計画
// 1. データベースをUTF-8に変換
// 2. アプリケーションをUTF-8対応に
// 3. すべての入出力をUTF-8に統一
?>

文字セット設定のベストプラクティス

安全な文字エンコーディング処理のための設定方法:

<?php
// MySQL接続時の設定(mysqli)
$connection = new mysqli($host, $user, $pass, $db);

// 文字セットを確実に設定
$connection->set_charset("utf8mb4");  // 絵文字にも対応

// 設定確認
if ($connection->character_set_name() !== 'utf8mb4') {
	throw new Exception("文字セット設定に失敗しました");
}

// PDOでの設定
try {
	$pdo = new PDO(
		"mysql:host=$host;dbname=$db;charset=utf8mb4",
		$user, 
		$pass,
		[
			PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
			PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"
		]
	);
} catch (PDOException $e) {
	die("接続失敗: " . $e->getMessage());
}

// PostgreSQLでの設定
$pgConnection = pg_connect("host=localhost dbname=mydb");
pg_set_client_encoding($pgConnection, "UTF8");

// アプリケーション全体の設定
mb_internal_encoding('UTF-8');
mb_http_output('UTF-8');
mb_http_input('UTF-8');
mb_regex_encoding('UTF-8');

// HTMLヘッダーでも明示
header('Content-Type: text/html; charset=utf-8');
?>

動的SQL生成時の安全なエスケープ戦略

動的にSQL文を生成する必要がある場合でも、安全性を保つための戦略があります。

多層防御アプローチ

セキュリティは、単一の対策に依存しない多層防御が基本です。

  1. 入力検証:型チェック、長さ制限、形式検証

    • データ型の確認(文字列、数値、日付等)
    • 文字数の上限設定
    • 正規表現による形式チェック
    • 不正な文字の除去
  2. エスケープ処理:適切な関数使用

    • データベース固有の関数を使用
    • 文字エンコーディングを考慮
    • コンテキストに応じた処理
  3. 出力検証:生成されたSQLの妥当性確認

    • SQLパーサーでの構文チェック
    • 期待される構造との一致確認
    • ログ出力による監査

コンテキスト別エスケープマトリックス

SQLの各部分で必要なエスケープ処理は異なります:

コンテキスト エスケープ方法 追加対策
文字列値 DB固有関数 クォート必須 'value'
数値 型変換 範囲検証 123
識別子 ホワイトリスト QUOTENAME等 [table]
LIKE句 特殊エスケープ %_の処理 ESCAPE '\'
日付 フォーマット検証 妥当性確認 '2024-01-01'
JSON JSON関数使用 構造検証 JSON_VALUE()

エスケープ関数の自作は危険

絶対に自作のエスケープ関数を作らないでください。多くの開発者が見落とす問題があります:

<?php
// 危険:不完全な自作エスケープ関数
function dangerousEscape($str) {
	// シングルクォートだけエスケープ(不十分!)
	return str_replace("'", "''", $str);
}

// この関数が見落としている問題:
// 1. バックスラッシュの処理なし
// 2. NULL文字(\0)の処理なし
// 3. 制御文字の処理なし
// 4. マルチバイト文字の考慮なし
// 5. 文字エンコーディングの考慮なし
// 6. データベース固有の特殊文字なし

// より詳細な問題を示す攻撃例
$input = "abc\0' OR '1'='1";  // NULL文字を含む
$escaped = dangerousEscape($input);  // 不完全なエスケープ
// 一部のシステムでは、\0以降が無視される可能性

// 正しい方法:公式関数を使用
$safe = mysqli_real_escape_string($connection, $input);

// どうしてもカスタム処理が必要な場合
class SafeEscaper {
	private $connection;
	private $charset;
	
	public function __construct($connection) {
		$this->connection = $connection;
		$this->charset = $connection->character_set_name();
	}
	
	public function escape($value, $type = 'string') {
		switch ($type) {
			case 'string':
				return mysqli_real_escape_string($this->connection, $value);
			
			case 'integer':
				return (int)$value;
			
			case 'float':
				return (float)$value;
			
			case 'identifier':
				// 識別子は特別な処理
				if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $value)) {
					throw new InvalidArgumentException("Invalid identifier");
				}
				return "`$value`";  // MySQLの場合
			
			default:
				throw new InvalidArgumentException("Unknown type: $type");
		}
	}
}
?>

エスケープ処理とプレースホルダの使い分け

現実的なシステム開発では、両方の技術を理解し、適切に使い分ける必要があります。

エスケープ処理が適切な場面

エスケープ処理を使用せざるを得ない、または使用が妥当な限定的なケースがあります:

限定的な使用ケース

レガシーシステムの保守
古いPHPバージョン(5.0以前)や、プレースホルダをサポートしていない古いデータベースドライバを使用している場合、エスケープ処理が唯一の選択肢となることがあります。ただし、これは技術的負債であり、できるだけ早期にシステムのアップグレードを計画すべきです。セキュリティパッチの適用も困難になるため、リスクは時間とともに増大します。
動的テーブル/カラム名
プレースホルダは値にのみ使用でき、テーブル名やカラム名などの識別子には使用できません。管理画面でのテーブル選択、動的レポート生成、マルチテナントシステムでのスキーマ切り替えなど、識別子を動的に指定する必要がある場合は、ホワイトリスト検証と組み合わせたエスケープ処理が必要です。
一時的な移行措置
大規模システムをエスケープ処理からプレースホルダに移行する過程では、両方式が混在する期間が発生します。この期間中は、どの部分がどの方式を使用しているか明確に管理し、段階的に安全な実装に移行していく必要があります。移行計画を立て、優先順位を付けて実施することが重要です。

プレースホルダへの移行戦略

組織的にプレースホルダへの移行を進めるための戦略:

  1. 新規開発:最初からプレースホルダ使用

    • コーディング規約に明記
    • コードレビューでのチェック必須
    • テンプレートコードの提供
  2. 既存改修:高リスク箇所から順次移行

    • 認証・決済関連から優先
    • 外部入力を扱う部分を重点的に
    • アクセス頻度の高い機能を優先
  3. 混在期間:両方式の共存管理

    • 明確なマーキング(コメント、アノテーション)
    • 自動テストでの検証
    • 定期的な進捗確認
  4. 完全移行:エスケープ処理の廃止

    • 最終確認とテスト
    • パフォーマンス測定
    • ドキュメント更新

パフォーマンス比較

各方式のパフォーマンス特性を理解して選択することも重要です:

方式 CPU負荷 メモリ スケール性 開発効率
エスケープ 中(文字列処理) 低(複雑)
プレースホルダ 小(DB側処理) 高(簡潔)
ストアド 最小 最高 中(別言語)
ORM 大(抽象化) 最高

セキュリティテストとバリデーション

エスケープ処理の効果を確認し、脆弱性を発見するためのテスト手法を紹介します。

エスケープ処理の効果測定

システマティックなテストにより、エスケープ処理の有効性を検証します:

テストケースの作成

# Python でのエスケープ処理テスト
import pymysql
import unittest

class EscapeTestCase(unittest.TestCase):
	def setUp(self):
		self.connection = pymysql.connect(
			host='localhost',
			user='test',
			password='test',
			database='testdb'
		)
		
	def test_escape_patterns(self):
		"""各種攻撃パターンのテスト"""
		test_cases = [
			# (入力値, 期待される安全性, 説明)
			("normal_input", True, "通常の入力"),
			("' OR '1'='1", True, "基本的なSQLi"),
			("'; DROP TABLE users--", True, "破壊的SQLi"),
			("\" OR \"1\"=\"1", True, "ダブルクォート攻撃"),
			("\\' OR 1=1--", True, "エスケープ回避"),
			("%' OR '1'='1", True, "LIKE句攻撃"),
			("田中' OR '1'='1", True, "マルチバイト攻撃"),
			("\x00' OR '1'='1", True, "NULL文字攻撃"),
			("admin'/*", True, "コメント攻撃"),
			("'; EXEC xp_cmdshell('dir')--", True, "コマンド実行"),
		]
		
		for test_input, should_be_safe, description in test_cases:
			with self.subTest(input=test_input):
				escaped = self.connection.escape_string(test_input)
				
				# エスケープ後の安全性を検証
				self.assertTrue(
					self.is_safe_after_escape(escaped),
					f"Failed for: {description}"
				)
				
				# 実際のクエリでテスト(テスト環境のみ)
				self.assert_query_safe(escaped)
	
	def is_safe_after_escape(self, escaped_string):
		"""エスケープ後の文字列が安全かチェック"""
		# 危険なパターンが無効化されているか確認
		dangerous_patterns = [
			"' OR",
			"'; DROP",
			"' UNION",
			"' AND",
			"' --",
		]
		
		for pattern in dangerous_patterns:
			if pattern in escaped_string:
				return False
		return True
	
	def assert_query_safe(self, escaped_value):
		"""実際のクエリ実行で安全性確認"""
		try:
			cursor = self.connection.cursor()
			# テスト用の安全なテーブルで確認
			query = f"SELECT * FROM test_table WHERE value = '{escaped_value}'"
			cursor.execute(query)
			cursor.close()
		except pymysql.Error as e:
			# SQLエラーが発生したら安全でない
			self.fail(f"SQL execution failed: {e}")
	
	def tearDown(self):
		self.connection.close()

# 自動実行
if __name__ == '__main__':
	unittest.main()

自動化セキュリティテスト

継続的なセキュリティテストの実装:

#!/bin/bash
# SQLMap等を使用した自動テスト

# 1. エスケープ迂回テスト
sqlmap -u "http://localhost/test.php?id=1" \
	   --technique=B \
	   --tamper=space2comment,between \
	   --level=5 \
	   --risk=3

# 2. ファジングテスト
# 様々な入力パターンを自動生成
python3 fuzzer.py --target="http://localhost/api" \
				  --payloads="sqli_payloads.txt" \
				  --threads=10

# 3. 文字エンコーディングテスト
# 異なるエンコーディングでの攻撃を試行
for encoding in utf8 latin1 sjis gbk; do
	echo "Testing with $encoding encoding..."
	curl -X POST http://localhost/test \
		 -H "Content-Type: text/plain; charset=$encoding" \
		 -d @"payloads_$encoding.txt"
done

よくある質問(FAQ)

Q: エスケープ処理だけでも十分安全ですか?
A: いいえ、エスケープ処理だけでは不十分です。エスケープ処理は文字列型の単純な攻撃には効果がありますが、数値型への攻撃、LIKE句での攻撃、ORDER BY句での攻撃など、防げない攻撃パターンが多数存在します。また、実装ミスによる脆弱性のリスクも高くなります。新規開発では必ず[プレースホルダ](/security/web-api/sql-injection/column/placeholder-implementation/)を使用し、エスケープ処理は補助的な対策、またはレガシーシステムでの暫定対策として位置づけるべきです。可能な限り早期にプレースホルダへの移行を計画してください。
Q: addslashes()とmysql_real_escape_string()の違いは?
A: 大きな違いがあり、セキュリティ上重要です。addslashes()は汎用的な関数で、シングルクォート、ダブルクォート、バックスラッシュ、NULL文字をエスケープしますが、データベース固有の処理やマルチバイト文字を考慮していません。特に、文字エンコーディングを認識しないため、Shift-JISなどのマルチバイト文字環境では「5C問題」により脆弱性が生じます。一方、mysql_real_escape_string()はMySQL専用の関数で、接続の文字セットを考慮し、MySQLの仕様に合わせた適切なエスケープを行います。必ず後者を使用し、さらに文字セットを明示的に設定(set_charset())することが重要です。
Q: WAFがあればエスケープ処理は不要ですか?
A: いいえ、[WAF](/security/web-api/sql-injection/column/waf-protection/)があってもアプリケーション側の対策は必要です。これは多層防御の基本原則です。WAFは既知の攻撃パターンを防ぎますが、新しい攻撃手法や、アプリケーション固有の脆弱性をすべて防ぐことはできません。また、WAFの設定ミスや、バイパス手法により突破される可能性もあります。アプリケーション側では、理想的にはプレースホルダ、最低限でもエスケープ処理を実装し、WAFは追加の防御層として機能させるべきです。セキュリティは単一の対策に依存せず、複数の防御層を重ねることで実現されます。
Q: ストアドプロシージャなら安全ですか?
A: ストアドプロシージャ自体は追加のセキュリティ層となりますが、完全に安全というわけではありません。ストアドプロシージャ内で動的SQLを構築している場合(EXECUTE文やEXEC sp_executesqlで文字列連結を使用)、同様にSQLインジェクションの脆弱性が発生します。また、ストアドプロシージャの呼び出し時にパラメータを適切に処理しないと、攻撃を受ける可能性があります。安全に使用するには、ストアドプロシージャ内でもパラメータ化を徹底し、動的SQL生成を避け、適切な権限管理を行う必要があります。
Q: 最新のフレームワークではエスケープは自動化されていますか?
A: 多くの最新フレームワーク(Laravel、Django、Ruby on Railsなど)は、デフォルトでORMを使用し、自動的にパラメータ化やエスケープを行います。しかし、完全に自動化されているわけではありません。生のSQL実行機能(Laravel のDB::raw()、DjangoのRaw()など)を使用する際は、開発者が明示的に安全対策を実装する必要があります。また、動的なテーブル名やカラム名の処理、複雑なクエリの構築では、手動での対策が必要です。フレームワークの機能を過信せず、セキュリティのベストプラクティスを理解して実装することが重要です。

まとめ

エスケープ処理は、SQLインジェクション対策の歴史において重要な役割を果たしてきましたが、現代においては限界が明確になっています。

エスケープ処理の重要なポイント

  1. 部分的な対策に過ぎない

    • 文字列型には有効だが、数値型には無効
    • LIKE句、ORDER BY句では特別な処理が必要
    • 完全な防御は不可能
  2. 実装の複雑性とリスク

    • データベースごとに異なる関数
    • 文字エンコーディングの考慮必須
    • 実装ミスによる脆弱性のリスク
  3. 使用すべき状況は限定的

    • レガシーシステムの保守
    • 動的な識別子の処理
    • プレースホルダへの移行期間
  4. プレースホルダへの移行を強く推奨

    • より安全で確実な対策
    • 実装が簡単でミスが起きにくい
    • パフォーマンスも向上

エスケープ処理の知識は、既存システムの理解や緊急対応のために必要ですが、新規開発では必ずプレースホルダを使用してください。また、WAF定期的な脆弱性診断インシデント対応計画など、多層防御の一部として位置づけることが重要です。

最後に、セキュリティは技術だけでなく、組織全体の意識と継続的な改善によって実現されます。SQLインジェクションの被害事例から学び、適切な対策を実装することで、安全なシステムを構築していきましょう。


【重要なお知らせ】

  • 本記事は一般的な情報提供を目的としており、個別の状況に対する助言ではありません
  • エスケープ処理には限界があり、可能な限りプレースホルダの使用を推奨します
  • 実装の際は、使用するデータベースの最新ドキュメントを参照してください
  • 記載内容は作成時点の情報であり、仕様や推奨事項は変更される可能性があります

更新履歴

初稿公開

京都開発研究所

システム開発/サーバ構築・保守/技術研究

CMSの独自開発および各業務管理システム開発を行っており、 10年以上にわたり自社開発CMSにて作成してきた70,000以上のサイトを 自社で管理するサーバに保守管理する。