お疲れ。インシデントレスポンスの現場で泥水をすすってきた私から、一つ現場のリアルを教えよう。
Webアプリケーションの脆弱性診断やペネトレーションテストを行っていると、いまだに絶滅しない致命傷に遭遇する。それが「安全ではないデシリアライゼーション(Insecure Deserialization)」だ。
攻撃者は、一見無害に見えるシリアライズされたオブジェクトの中に、マジックメソッド(PHPの __wakeup() や Pythonの __reduce__() など)を巧みに仕込む。そして、アプリケーションがそれを復元(デシリアライズ)した瞬間に、メモリ空間を乗っ取り、リモートコード実行(RCE)を達成する。
今回は、この魔窟のような脆弱性のメカニズムと、公開鍵暗号の概念を応用した「絶対に破られない署名検証による防御パターン」を、実務でそのまま使えるコードベースで解説しよう。
—
1. なぜデシリアライゼーションは「魔窟」なのか?
セッション管理、キャッシュ、ジョブキューのバックグラウンド処理……。現代のWeb開発において、複雑なオブジェクト構造をそのままバイト列に変換し、復元する仕組みは非常に便利だ。
しかし、ここに大きな落とし穴がある。「信頼できないデータをそのまま信用して復元する」という設計自体が、セキュリティ上の最大の罪なのだ。
例えば、PHPの unserialize() や Pythonの pickle.loads() は、単なるデータの復元装置ではない。データ構造を復元するプロセスの中で、指定されたクラスのインスタンス化やメソッドの自動実行(マジックメソッド)を引き起こす「実行エンジン」としての側面を持っている。
攻撃者は、アプリケーションが意図しないクラス(例えば、OSコマンドを実行できるガジェットチェーン)をシリアライズデータに紛れ込ませる。サーバー側がそれをデシリアライズした瞬間、アプリケーションの権限で任意のコマンドが実行される。WAF(Web Application Firewall)のシグネチャ回避など、彼らにとっては朝飯前だ。
—
2. 攻撃の構図:Pythonの pickle を例にした脅威
百聞は一見に如かず。まずは、安全ではないデシリアライズがいかに簡単にシステムを沈めるか、そのメカニズムを理解してほしい。以下のPythonコードは、pickle モジュールを用いた危険な実装の典型例だ。
import pickle
import base64
import os
# 【危険な例】攻撃者が送り込む悪意あるペイロードの概念
class RCEPayload:
def __reduce__(self):
# デシリアライズ時にOSコマンド(例:リバースシェルやファイル削除)を実行するよう仕込む
return (os.system, ('echo "Hacked!" > /tmp/pwned.txt',))
# 攻撃者が作成し、Cookie等に隠蔽して送信するシリアライズデータ
evil_data = base64.b64encode(pickle.dumps(RCEPayload())).decode('utf-8')
print(f"攻撃者が生成した危険なペイロード: {evil_data}")
サーバー側がこの evil_data を検証なしに pickle.loads() した瞬間、/tmp/pwned.txt が生成される。これがRCEの悪夢の正体だ。
—
3. 鉄壁の防御:型チェックと「暗号学的署名(HMAC / 公開鍵)」の併用
この脆弱性を根絶するための原則はシンプルだ。
1. 可能な限り pickle や unserialize() のような汎用デシリアライザを使わない。 代わりに JSON などの安全なデータフォーマット(値のみを保持するもの)を使用する。
2. どうしてもオブジェクトのシリアライズが必要な場合は、データが改ざんされていないかを「暗号学的署名」で保証する。
今回は、後者の「安全にシリアライズデータを扱い、改ざんを完全に検知・防御する実装パターン」をPHPで示そう。共通鍵暗号(HMAC-SHA256)を用いて、データが第三者によって書き換えられていないかを厳密に検証する。
コピペで使えるセキュアなPHP実装サンプル
以下のコードは、データのシリアライズとデシリアライズを行う際に、必ずHMAC署名の検証を行い、不正なデータ(改ざん品や偽造品)を一切受け付けないクラスの実装例だ。
<?php
/**
* セキュアなシリアライズ・デシリアライズを管理するクラス
* 信頼できない入力を受け取る前に、必ず暗号学的署名による完全性検証を行います。
*/
class SecureSerializer {
// 本番環境では環境変数(getenv)等から強固な秘密鍵をロードすること
private static string $secretKey = 'your-super-secret-and-robust-key-here';
/**
* データを安全にシリアライズし、改ざん検知用のHMAC署名を付与する
*
* @param mixed $data シリアライズするデータ
* @return string 署名付きシリアライズデータ(Base64エンコード済み)
*/
public static function pack(mixed $data): string {
// 1. データをシリアライズ
$serialized = serialize($data);
// 2. 秘密鍵を用いてHMAC-SHA256署名を生成
$signature = hash_hmac('sha256', $serialized, self::$secretKey, true);
// 3. 署名とシリアライズデータを結合してBase64エンコード
// 構造: [署名(32バイト)] + [シリアライズデータ]
return base64_encode($signature . $serialized);
}
/**
* 署名を検証し、安全な場合のみデシリアライズを実行する
*
* @param string $inputData pack()で生成された文字列
* @return mixed 復元されたデータ
* @throws Exception 改ざんや不正が検知された場合
*/
public static function unpack(string $inputData): mixed {
$decoded = base64_decode($inputData, true);
if ($decoded === false || strlen($decoded) < 32) {
throw new Exception('無効なデータフォーマットです。');
}
// 署名(最初の32バイト)とデータ本体を分離
$signature = substr($decoded, 0, 32);
$serialized = substr($decoded, 32);
// 期待される署名を再計算(タイミング攻撃を防ぐため hash_equals を使用)
$expectedSignature = hash_hmac('sha256', $serialized, self::$secretKey, true);
if (!hash_equals($expectedSignature, $signature)) {
// 署名が一致しない=データが改ざんされている、または偽造されたもの
throw new Exception('セキュリティ警告: データの改ざんまたは不正な署名を検知しました!');
}
// 4. 検証が通った安全なデータのみデシリアライズを実行
// さらに厳格にしたい場合は allowed_classes オプションで許可するクラスを制限する
return unserialize($serialized, ['allowed_classes' => false]);
}
}
// --- 【使用例・動作テスト】 ---
try {
// 正常なデータのやり取り
$userData = ['user_id' => 1042, 'role' => 'editor'];
$safeToken = SecureSerializer::pack($userData);
echo "生成されたセキュアトークン:\n" . $safeToken . "\n\n";
// 復元テスト
$restoredData = SecureSerializer::unpack($safeToken);
echo "デシリアライズ成功:\n";
print_r($restoredData);
// 【攻撃シミュレーション】トークンを少しでも改ざんしてみる
$tamperedToken = $safeToken;
// 適当な文字を書き換える
$tamperedToken[5] = chr(ord($tamperedToken[5]) ^ 1);
echo "\n[攻撃テスト] 改ざんされたトークンの検証試行...\n";
SecureSerializer::unpack($tamperedToken);
} catch (Exception $e) {
echo "捕捉された例外: " . $e.getMessage() . "\n";
}
—
4. チーフエンジニアからの実務的アドバイス
上記のコードを見て、「ここまでやるのか」と思ったかもしれない。だが、これがプロの現場の標準だ。
1. hash_equals() の徹底
文字列の比較に通常の == や === を使うな。タイミング攻撃(処理時間の差から秘密鍵を推測する攻撃)の餌食になる。必ず定数時間で比較を行う hash_equals() を使うこと。PHPに限らず、他の言語(Node.jsの crypto.timingSafeEqual など)でも同様の配慮が必須だ。
2. PHPの unserialize() なら allowed_classes を絶対に指定しろ
PHP 7.0以降、unserialize() の第2引数に ['allowed_classes' => false](プリミティブな型のみ許可)や、許可するクラス名のホワイトリストを指定できる。オブジェクトのインスタンス化が不要なら、そもそも allowed_classes => false にして配列やスカラー値だけに限定するのが最も安全だ。
3. そもそも「シリアライズ形式」を見直す
PHPネイティブの serialize() や Pythonの pickle は、言語仕様に深く依存しておりリスクが高い。WebAPIやセッションデータのやり取りには、人間が読めてコード実行の危険性がない json_encode() / json_decode() を第一選択とせよ。
セキュリティは「信頼」ではなく「検証」で成り立っている。
「このデータは自社システムが生成したものだから大丈夫」という性善説の設計は、今日で終わりだ。すべての入力は悪意あるものとして扱い、暗号学的署名と厳格な型チェックでシステムを武装させよう。実装で迷ったら、いつでも私のところへ相談に来るといい。
コメント