デシリアライズの「禁忌」に触れる:RCEを許す脆弱性のメカニズムと現場の防衛術
「JSONなら大丈夫」とタカを括っている開発者がいれば、今すぐその甘い考えを捨ててほしい。
現場でインシデント対応をしていると、往々にして「なぜこんな実装をしたのか」と頭を抱える設計に出くわす。その筆頭が、信頼できない入力値をそのままプログラムのオブジェクトとして復元する――いわゆる「安全でないデシリアライズ(Insecure Deserialization)」だ。
今日は、なぜこの脆弱性がWebアプリケーションにとって「死の宣告」になり得るのか、そしてどうやってそれを封じ込めるのかを、実戦的な視点で叩き込む。
—
1. なぜデシリアライズはRCE(リモートコード実行)に直結するのか
シリアライズ(直列化)は、オブジェクトをメモリ上のデータ形式から、ファイル保存やネットワーク転送に適したバイト列や文字列に変換することだ。そして、デシリアライズはその逆。
問題の本質はここにある。「デシリアライズは、受信したバイト列から『実行可能なコードやロジック』を再構築するプロセスである」ということだ。
攻撃者は、特定のクラスやプロパティの値を細工した「ガジェットチェーン(Gadget Chain)」と呼ばれるペイロードを作成する。これをアプリケーションに送り込むと、デシリアライズの過程で意図しない関数が呼び出されたり、メモリ上の処理が乗っ取られたりして、OSコマンドの実行へと繋がる。
「ただのデータ」を食わせたつもりが、実は「命令」を食わされていた。これがRCEの正体だ。
—
2. 現場でよく見る「地雷」とPoCの概念
例えば、PHPの unserialize() は悪名高い。かつては __destruct() や __wakeup() といったマジックメソッドを悪用する攻撃が猛威を振るった。
もし、開発者が以下のようなコードを書いていたら、それは招待状を送っているのと同じだ。
// 非常に危険な実装例
$user_data = $_COOKIE['user_session'];
$user_obj = unserialize(base64_decode($user_data)); // 攻撃者が制御可能な入力を直接デシリアライズ
攻撃者は、特定のクラスをシリアライズした文字列をBase64でエンコードし、Cookieに仕込む。unserialize() が実行された瞬間、サーバー側でガジェットチェーンが発火し、システム権限で任意のコマンドが実行される。
—
3. 「絶対」に破られない防御の鉄則
デシリアライズの脆弱性を防ぐための唯一の正攻法は、「外部からのデータを信じず、オブジェクトの復元にシリアライズ形式を使わないこと」だ。
対策1:JSONへの完全移行
シリアライズ形式(PHPの serialize() や Pythonの pickle など)は、データ構造だけでなくクラス定義の復元まで含んでしまう。一方、JSONなどのデータ交換フォーマットは「単なる値の集まり」として解析されるため、コード実行の余地が極めて小さい。
Pythonでの安全な実装例(pickleを禁止し、jsonを使う):
import json
# 危険な実装: pickle.loads(data) は絶対にNG
# 安全な実装: json.loads() を使用し、スキーマバリデーションを通す
def safe_deserialize(json_str):
try:
data = json.loads(json_str)
# 必須キーが存在するか、型が正しいかを確認する(スキーマチェック)
if "user_id" in data and isinstance(data["user_id"], int):
return data
else:
raise ValueError("不正なデータ形式です")
except json.JSONDecodeError:
return None
対策2:署名による改ざん検知
どうしてもシリアライズデータを使わざるを得ない場合(レガシーシステムとの連携など)は、HMAC(Hash-based Message Authentication Code)を使用して、データが改ざんされていないことを必ず検証すること。
PHPでの署名検証サンプル:
// 秘密鍵は環境変数から読み込む(コードに直書き厳禁)
$secret_key = getenv('APP_SECRET_KEY');
$received_data = $_COOKIE['payload'];
$received_hmac = $_COOKIE['signature'];
// 計算したHMACと受信したHMACを比較
$expected_hmac = hash_hmac('sha256', $received_data, $secret_key);
if (hash_equals($expected_hmac, $received_hmac)) {
$data = unserialize(base64_decode($received_data));
} else {
// 署名不一致:ログを吐いてセッションを破棄
die("不正なアクセスを検知しました");
}
—
4. インフラ・WAFレベルでの防衛
コード修正が追いつかない場合でも、防御の層を重ねるのがプロの仕事だ。
- WAFによるブロック:
base64 エンコードされた文字列が頻繁に送られてくるエンドポイントに対し、シリアライズ特有のパターン(PHPなら O:8:"stdClass":... 等の文字列)を正規表現で検知し、即座に遮断するルールを設定する。
- 最小権限の原則:
Webアプリケーションのプロセスは、システム全体を操作できる権限(rootやadmin)で動かしてはならない。万が一RCEが成功しても、コンテナの外やOSの深部まで侵入させないよう、ReadOnly ファイルシステムや Capability 制限を適用する。
Nginx設定例(特定の攻撃パターンを拒否):
# シリアライズデータに関連する怪しいパターンを拒否する簡易的な例
if ($query_string ~* "O:\d+:") {
return 403;
}
—
結び:セキュリティは「疑うこと」から始まる
エンジニア諸君、デシリアライズの脆弱性は、ライブラリのアップデートだけで解決するような単純なバグではない。それは、「データの信頼性」という設計の根幹を問う問題だ。
「このオブジェクトはどこから来たのか?」「誰がこの値を生成したのか?」「もし攻撃者がこの値を書き換えたら、システムはどう反応するのか?」――常にこの問いを頭に置いてほしい。
コードを書くとき、JSONをパースする際にも「中身が何であるか」を検証するスキーマ定義を忘れないこと。それが、君たちのシステムを泥沼のインシデントから救う唯一の盾になるはずだ。
コメント