【PHPセキュリティ】unserialize()の脆弱性とオブジェクトインジェクション対策|安全な代替コード解説

PHPでWebアプリケーションやWordPressのプラグイン・テーマを開発・保守する際、データの保存や受け渡しに unserialize() 関数を利用した経験がある方は多いのではないでしょうか。しかし、unserialize() は利便性が高い一方で、「PHP Object Injection(オブジェクトインジェクション)」と呼ばれる深刻な脆弱性の温床になりやすい関数です。

攻撃者が細工したシリアライズ文字列を送信することで、意図しないクラスがインスタンス化され、マジックメソッドを連鎖させた「POPチェーン(Property-Oriented Programming)」によって任意コード実行(RCE)や不正なファイル操作、管理者権限の奪取につながるリスクがあります。

また、開発現場では「Notice: unserialize(): Error at offset ...」や「__PHP_Incomplete_Class」といったデータ破損・不整合エラーのトラブルシューティングに追われるケースも少なくありません。

本記事では、PHPの unserialize() に潜む脆弱性の仕組みと危険性を徹底解説するとともに、allowed_classes オプションの適切な利用法、json_encode / json_decode への安全なリファクタリング実装コード、破損データの復元テクニックまで、実務で今すぐ使える対策を網羅して解説します。

  • unserialize() の基本動作とエラーが発生する3大原因
  • オブジェクトインジェクションの脅威とマジックメソッド悪用の仕組み(POPチェーン)
  • 【実務コード】安全な代替実装とデータ移行の4ステップ
  • WordPress環境における注意点(maybe_unserialize とプラグイン脆弱性事例)
  • 安全なPHP開発のためのセキュリティチェックリスト

1. PHP unserialize() の基礎とエラーが発生する3大原因

serialize() と unserialize() は、PHPの変数(配列やオブジェクト)を文字列化して保存・転送し、後から元の型と値に復元するための関数です。シリアライズされた文字列は、データ型と長さ、値が以下のような独自フォーマットで記録されます。

// 配列のシリアライズ例
$data = ['name' => 'MozCheck', 'count' => 3];
$serialized = serialize($data);
// 出力: a:2:{s:4:"name";s:8:"MozCheck";s:5:"count";i:3;}

このシリアライズデータは内部構造が厳格に定義されているため、以下のような要因でエラーが発生します。

原因①:バイト数と宣言された長さの不一致(最も頻出するエラー)

フォーマット内の s:8:"MozCheck"; の s:8 は、文字列の「バイト数」を表しています。以下の処理が行われると、記録されたバイト数と実際のバイト数に食い違いが生じ、Notice: unserialize(): Error at offset XX of YY bytes が発生します。

  • 文字コード変換: Shift_JISやEUC-JPからUTF-8へ変換した際、マルチバイト文字(日本語など)のバイト数が変化した。
  • 改行コードの変換: Windows(CRLF: 2バイト)とLinux(LF: 1バイト)間でのテキスト転送で改行バイト数が変化した。
  • SQL一括置換ツールの誤用: データベース内のURL置換を通常のSQL REPLACE() で実行した結果、文字列長が変わりシリアライズデータが破損した。

原因②:クラス定義の未ロード(__PHP_Incomplete_Class)

シリアライズされたオブジェクトを復元する際、そのクラス定義がメモリ上に読み込まれていない(require されていない、またはオートローダーの対象外)場合、PHPは __PHP_Incomplete_Class という不完全なオブジェクトを生成します。この状態のオブジェクトに対してメソッドを呼び出すと、致命的なエラー(Fatal Error)となります。

原因③:外部改ざんデータや不正な攻撃コードの混入

ユーザーからのリクエストパラメータ(GET/POST)、クッキー、HTTPヘッダー、外部APIからのレスポンスなど、外部入力を直接 unserialize() に渡している場合、攻撃者によって細工された文字列が注入されている可能性があります。これは単なるエラーにとどまらず、次節で解説する致命的なセキュリティ被害を引き起こします。

2. PHPオブジェクトインジェクション(PHP Object Injection)の仕組み

PHPオブジェクトインジェクションは、信頼できないシリアライズ文字列を unserialize() に渡すことで、攻撃者がアプリケーション内の任意のクラスを意図しないプロパティ値とともにインスタンス化できてしまう脆弱性です。

マジックメソッド(Magic Methods)が悪用されるメカニズム

PHPには、特定のイベントが発生した際に自動的に実行される「マジックメソッド」が存在します。攻撃者はオブジェクトをインスタンス化させた上で、これらのメソッドが自動実行されるタイミングを狙って攻撃コードを実行させます。

マジックメソッド実行されるタイミング攻撃者による悪用のリスク
__wakeup()unserialize() の実行直後DB接続やキャッシュ再取得のロジックを悪用し、不正なSQL実行や外部通信を誘発
__destruct()オブジェクト破棄時(スクリプト終了時など)一時ファイルの削除(任意ファイル削除)、バッファ書き出し(任意コード書き込み)
__toString()オブジェクトが文字列として評価された時テンプレート出力やログ出力時に文字列化され、追加コードやXSS/SQLiを実行
__get() / __set()未定義または非公開プロパティへのアクセス時内部プロパティの上書きや制御フローの変更
__call() / __invoke()未定義メソッド呼出し / オブジェクトを関数呼出し時コールバック関数を悪用した任意関数(system(), eval() 等)の実行

POPチェーン(Property-Oriented Programming)の具体例

単一のクラスだけでは重大な被害に至らなくても、アプリケーションやライブラリ(ベンダーパッケージ含む)に存在する複数のクラスのプロパティを組み合わせることで、最終的に任意コード実行(RCE)に至る連鎖を「POPチェーン」と呼びます。

以下の脆弱なサンプルコードで、攻撃の流れを確認してみましょう。

<?php
// アプリケーション内に存在するクラス(一時ファイル管理)
class TempLogManager {
    public string $logFile;
    public string $content;

    public function __destruct() {
        // オブジェクト破棄時にログをファイルへフラッシュする実装
        if (!empty($this->logFile)) {
            file_put_contents($this->logFile, $this->content, FILE_APPEND);
        }
    }
}

// 脆弱なエンドポイント: ユーザー入力をそのままデシリアライズ
$userInput = $_COOKIE['user_session'] ?? '';
if (!empty($userInput)) {
    // 危険: 信頼できない入力を unserialize している
    $session = unserialize($userInput);
}

攻撃者は TempLogManager クラスの存在を把握すると、次のようなシリアライズ文字列を作成して送信します。

// 攻撃者が生成する悪意あるペイロード例
// 任意のPHPファイル(Webシェル)を公開ディレクトリに生成させる
$payload = 'O:14:"TempLogManager":2:{s:7:"logFile";s:29:"/var/www/html/public/backdoor.php";s:7:

このペイロードが unserialize() に渡されると、スクリプト終了時に __destruct() が自動実行され、/var/www/html/public/backdoor.php にWebシェルが書き込まれてしまいます。その結果、サーバーが完全に遠隔操作される危険に晒されます。

3. 【実務コード付き】安全な対策と代替実装の4ステップ

unserialize() の脆弱性を根絶し、安全なシステムを構築するための4つのステップと実践コード例を解説します。

ステップ①:allowed_classes オプションでオブジェクト生成を遮断する

PHP 7.0以降、unserialize() には第2引数として $options が追加されました。既存のシリアライズ形式のデータを読み込む必要がある場合は、必ず allowed_classes を明示してください。

<?php
// 【最も安全】配列やスカラー値のみを許可し、すべてのオブジェクト生成を禁止する
$data = unserialize($serializedString, ['allowed_classes' => false]);

if ($data === false && $serializedString !== 'b:0;') {
    // デシリアライズ失敗時の適切なエラーハンドリング
    error_log('Failed to unserialize data safely.');
    $data = [];
}

オブジェクトが不要な設定配列やキャッシュデータであれば、allowed_classes => false を指定するだけで、オブジェクトインジェクション攻撃を完全に防御できます。もしオブジェクトの復元が不可欠な場合でも、信頼できるクラスのみをホワイトリスト形式で配列指定します。

// 特定の安全なクラスのみ復元を許可する場合(ホワイトリスト方式)
$data = unserialize($serializedString, [
    'allowed_classes' => [
        \App\DTO\UserProfileData::class,
        \DateTimeImmutable::class,
    ]
]);

※ホワイトリストを指定した場合でも、許可したクラス自体に脆弱なマジックメソッドが含まれていると攻撃を受けるリスクが残ります。そのため、極力 allowed_classes => false を使用し、次に紹介するJSON形式への移行を優先してください。

ステップ②:JSON形式(json_encode / json_decode)へ完全移行する

新規の開発やリファクタリングにおいて、データの保存形式には serialize ではなく json_encode() / json_decode() を採用することがPHP公式およびセキュリティ標準で強く推奨されています。

比較項目JSON形式(json_encode / json_decode)Serialize形式(serialize / unserialize)
安全性極めて安全(実行可能なPHPオブジェクトを生成しない)危険(オブジェクトインジェクションのリスク)
汎用性言語非依存(JavaScript, Python, Go, 各種APIと連携可能)PHP独自のバイナリ/テキスト形式に依存
エラー耐性文字コード変換でバイト数が変化しても壊れにくいバイト数が1バイトでもずれると全体が破損
パフォーマンスPHP拡張モジュール(C言語)で高速パース複雑なオブジェクト探索が入りオーバーヘッド大

以下は、実務で安全にJSONシリアライズ・デシリアライズを行うリファクタリング例です。PHP 7.3以降で導入された JSON_THROW_ON_ERROR フラグを活用し、構文エラーを確実に例外処理します。

<?php
class JsonDataHandler {
    /**
     * データを安全にJSON文字列化する
     */
    public static function encode(mixed $data): string {
        try {
            return json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        } catch (\JsonException $e) {
            error_log('JSON Encode Error: ' . $e->getMessage());
            throw new \RuntimeException('データのエンコードに失敗しました。', 0, $e);
        }
    }

    /**
     * JSON文字列から連想配列として安全に復元する
     */
    public static function decode(string $json): array {
        try {
            $result = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
            return is_array($result) ? $result : [];
        } catch (\JsonException $e) {
            error_log('JSON Decode Error: ' . $e->getMessage());
            return [];
        }
    }
}

// 使用例
$userData = [
    'user_id' => 1001,
    'username' => 'mozcheck_dev',
    'permissions' => ['read', 'write'],
    'updated_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)
];

$savedJson = JsonDataHandler::encode($userData);
$restored = JsonDataHandler::decode($savedJson);

クラスオブジェクトをJSONで扱う場合(JsonSerializableの実装)

クラスオブジェクトの構造を維持して保存・復元したい場合は、JsonSerializable インターフェースとスタティックファクトリメソッドを実装するのが最もセキュアでクリーンな設計です。

<?php
class UserSetting implements \JsonSerializable {
    public function __construct(
        private string $theme,
        private bool $notificationsEnabled,
        private array $dashboardWidgets
    ) {}

    /**
     * json_encode 時に出力する配列を定義
     */
    public function jsonSerialize(): array {
        return [
            'theme' => $this->theme,
            'notifications_enabled' => $this->notificationsEnabled,
            'dashboard_widgets' => $this->dashboardWidgets,
        ];
    }

    /**
     * デコードした配列から安全にインスタンスを再構築
     */
    public static function fromArray(array $data): self {
        return new self(
            (string) ($data['theme'] ?? 'light'),
            (bool) ($data['notifications_enabled'] ?? true),
            (array) ($data['dashboard_widgets'] ?? [])
        );
    }
}

ステップ③:破損したシリアライズ文字列の自動修復関数

データベース移行や文字コード変換によって「Error at offset ...」が発生し、過去のデータが読み込めなくなった場合は、以下の自動修復ヘルパー関数を利用して文字列のバイト長を再計算して安全にデシリアライズできます。

<?php
/**
 * 破損したシリアライズ文字列のバイト数を自動補正して安全に復元する
 *
 * @param string $serializedString 破損したシリアライズ文字列
 * @return mixed 復元されたデータ(復元不可の場合は null)
 */
function repair_and_unserialize_safe(string $serializedString): mixed {
    if (empty($serializedString)) {
        return null;
    }

    // まずは allowed_classes => false で安全にそのまま試行
    $result = @unserialize($serializedString, ['allowed_classes' => false]);
    if ($result !== false || $serializedString === 'b:0;') {
        return $result;
    }

    // 文字列長宣言 s:XX:"..." を実際のバイト数(strlen)で再計算して置換
    $repaired = preg_replace_callback(
        '/s:(\d+):"(.*?)";/s',
        function ($matches) {
            $realByteLength = strlen($matches[2]);
            return 's:' . $realByteLength . ':"' . $matches[2] . '";';
        },
        $serializedString
    );

    $result = @unserialize($repaired, ['allowed_classes' => false]);
    return $result !== false ? $result : null;
}

// テスト実行例
// 本来「セキュリティ」は UTF-8で15バイトだが、Shift_JIS移行で s:10 に破損したデータ
$brokenSerialized = 's:10:"セキュリティ";';
$restoredData = repair_and_unserialize_safe($brokenSerialized);
var_dump($restoredData); // string(15) "セキュリティ" が正常に復元される

ステップ④:改ざん検知(HMAC署名)の導入(必要な場合)

Cookieや外部ストレージなどにやむを得ずシリアライズデータを保存する場合は、秘密鍵を用いた HMAC署名 を付与し、改ざんされていないことを検証してからデシリアライズする仕組みが必須です。

<?php
class SecurePayload {
    private const SECRET_KEY = 'YOUR-VERY-SECURE-SECRET-KEY-CHANGE-ME';

    /**
     * データにHMAC署名を付与してシリアライズ
     */
    public static function sign(mixed $data): string {
        $json = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
        $signature = hash_hmac('sha256', $json, self::SECRET_KEY);
        return base64_encode($signature . '::' . $json);
    }

    /**
     * 署名を検証して安全にデータを復元
     */
    public static function verifyAndUnpack(string $payload): ?array {
        $decoded = base64_decode($payload, true);
        if ($decoded === false || !str_contains($decoded, '::')) {
            return null;
        }

        [$signature, $json] = explode('::', $decoded, 2);
        $expectedSignature = hash_hmac('sha256', $json, self::SECRET_KEY);

        // タイミング攻撃を防ぐため hash_equals で比較
        if (!hash_equals($expectedSignature, $signature)) {
            error_log('Payload signature verification failed! Possible tampering attempt.');
            return null;
        }

        return json_decode($json, true);
    }
}

4. WordPress環境における注意点と脆弱性事例

WordPressは歴史的経緯から、データベースの wp_options や wp_postmeta テーブル内のデータ保存にシリアライズ形式を多用しています。WordPressの開発・運用においては以下の点に留意が必要です。

WordPressコア関数 maybe_unserialize() の挙動

WordPressには、渡されたデータがシリアライズ文字列であればデシリアライズして返し、そうでなければそのまま返す maybe_unserialize() というヘルパー関数があります。get_post_meta() や get_option() の内部でも自動的に呼び出されます。

WordPressコアでは is_serialized() による事前判定や内部的な対策が進められていますが、プラグインやテーマの独自処理でユーザー入力をそのまま maybe_unserialize() や unserialize() に渡してしまうと、即座にオブジェクトインジェクションの標的となります。

実際に発生したWordPressプラグインの脆弱性事例

過去、著名なWordPressプラグインでも unserialize() の不適切な利用に起因する脆弱性が数多く報告されています。

  • CVE-2025-7696(Integration for Contact Form 7 and Pipedrive): フォーム送信データ等のデシリアライズ処理において、未認証の攻撃者が任意のPHPオブジェクトを注入可能な深刻な脆弱性が発見されました(詳細: [CVE-2025-7696] プラグインにおける未認証PHPオブジェクトインジェクションの危険性と対応策)。
  • CVE-2024-10957(UpdraftPlus): バックアップ処理に関連するデータ解析で unserialize() の直接利用が問題視され、独自ラッパー関数による厳格な型チェックと allowed_classes 制御への移行修正が行われました。

WordPressプラグインやカスタムテーマの開発では、ユーザー入力のサニタイズだけでなく、nonceによるリクエスト正当性の検証や適切な権限チェックを組み合わせることが必須です。

5. 自社サイトの安全性をチェック!MozCheck無料診断

WebサイトやWordPress環境のセキュリティリスクは、コードレベルの脆弱性だけでなく、古いプラグインの放置、不要なファイルの露出、SSL/ヘッダー設定の不備など多岐にわたります。

WordPress安心診断ツール「MozCheck」なら、サイトのURLを入力するだけで、インストールされているプラグインの既知の脆弱性やセキュリティヘッダー、更新状況を無料で包括的に自動診断できます。

開発中のテスト環境や本番運用の定期チェックに、ぜひご活用ください。

▶ WordPress安心診断 by MozCheck で無料セキュリティ診断を試す

6. まとめ:PHPセキュリティのベストプラクティス

unserialize() に潜む脆弱性とエラー対策の要点をまとめます。

  • 新規実装では unserialize() を原則使わない: データ構造の保存には json_encode() / json_decode() を標準採用する。
  • 既存データのデシリアライズには allowed_classes を必須指定: 配列・スカラー値の復元なら ['allowed_classes' => false] を必ず付与する。
  • ユーザー入力・外部入力を直接デシリアライズしない: 正規表現によるフィルタリングだけではPOPチェーンを防ぎきれないため、入力経路そのものを遮断する。
  • 定期的なセキュリティ診断とアップデートを実施: 依存ライブラリやWordPressプラグインの脆弱性情報を常に把握し、安全な運用体制を維持する。

堅牢なコード設計と適切なデータフォーマットの選択を行い、セキュアなPHPアプリケーション開発を推進しましょう。

投稿者

🧰 WordPress無料診断

サイト改善の第一歩をお届け

当サイト「MozCheck」は、WordPressサイトの不安をチェックできる無料診断サービスです。
URLを入力するだけ。登録不要、すぐに診断結果が表示されます。

コメント

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です