安全でない逆シリアル化

信頼できないPHP unserialize入力によるオブジェクトインジェクション

説明

PHPの unserialize() は、serialize() 形式の文字列からスカラー値、配列、オブジェクトを復元します。オブジェクトの復元では、オートロードが行われたり、__unserialize() や __wakeup() が自動的に呼び出されたりします。攻撃者がリクエスト、Cookie、ヘッダーなどを通じてシリアライズ済みのバイト列を操作できると、利用可能なクラスの処理を組み合わせたガジェットチェーンによって、状態変更、ファイル操作、コード実行を引き起こすおそれがあります。

PHPマニュアルは、allowed_classes の値にかかわらず、信頼できないユーザー入力を unserialize() に渡さないよう求めています。allowed_classes => false や限定的なクラスの許可リストは、オブジェクトの復元を制限する多層防御になりますが、悪意あるシリアライズ形式を安全なデータ交換形式に変えるものではありません。

危険が生じる理由

  • PHPのシリアライズ形式は、通常のデータだけでなく、オブジェクトのクラスやプロパティの状態も表現します。
  • 復元時にオートロードやマジックメソッドが実行され、利用可能なライブラリーのクラスが攻撃に使われる場合があります。
  • Base64のデコード、形式の確認、逆シリアル化後の検証では、危険なオブジェクトの生成前にリスクを取り除けません。
  • max_depth は深い入れ子によるリソース枯渇を抑えるためのもので、オブジェクトインジェクションを防ぐものではありません。
  • HMACは鍵を持たない主体の改ざんを検出できますが、正規の作成者が悪意を持つ場合や侵害された場合のデータを安全にはしません。

想定される影響

  • アプリケーションの権限での任意のコードやコマンドの実行
  • ファイルの作成・削除・改ざん、内部サービスへのアクセス
  • 認証・認可の状態や、その他のアプリケーションデータの操作
  • 例外、深い構造、過大なデータによるサービス拒否

対処方法

  1. 信頼できない入力が到達する経路から unserialize() を取り除きます。
  2. 外部データはJSONなどのデータ専用形式でデコードします。デコード前にエンドポイントに合ったバイト数の上限を設け、デコード後に最上位の型、必須・追加フィールド、長さ、範囲、業務上の制約を検証します。
  3. 検証済みのフィールドだけを新しい配列や値オブジェクトにコピーします。デコードや検証の結果を再び unserialize() に渡さないでください。
  4. 既存のPHPシリアライズ済みレコードは、ネットワークリクエストから分離した管理下のオフライン処理で、データ専用形式に移行します。一時的に読み込む必要がある場合は、作成者を認可し、デコード前に、サーバーが保管する鍵で保存済みの正確なバイト列のHMACを検証します。
  5. allowed_classes、max_depth、最小権限、隔離は、すでに信頼できる既存データに対する多層防御として使います。外部入力への恒久的な対策にはしないでください。

例

変更前

php
<?php
$payload = $_POST['payload'] ?? '';
$profile = unserialize($payload);

payload がオブジェクトのシリアライズ文字列であれば、復元時に利用可能なクラスやマジックメソッドが関与することがあります。

変更後

php
<?php
function parseProfile(string $body): array
{
    if (strlen($body) > 65_536) {
        throw new InvalidArgumentException('profile payload is too large');
    }

    $value = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    $allowedKeys = ['displayName' => true, 'age' => true];

    if (
        !is_array($value) ||
        array_is_list($value) ||
        count($value) !== 2 ||
        array_diff_key($value, $allowedKeys) !== [] ||
        !array_key_exists('displayName', $value) ||
        !array_key_exists('age', $value) ||
        !is_string($value['displayName']) ||
        strlen($value['displayName']) < 1 ||
        strlen($value['displayName']) > 80 ||
        !is_int($value['age']) ||
        $value['age'] < 0 ||
        $value['age'] > 130
    ) {
        throw new InvalidArgumentException('invalid profile');
    }

    return [
        'displayName' => $value['displayName'],
        'age' => $value['age'],
    ];
}

$body = file_get_contents('php://input', false, null, 0, 65_537);
if ($body === false) {
    throw new RuntimeException('request body read failed');
}

try {
    $profile = parseProfile($body);
    http_response_code(201);
} catch (JsonException | InvalidArgumentException $error) {
    http_response_code(400);
    $profile = ['error' => 'invalid profile'];
}

header('Content-Type: application/json');
echo json_encode($profile, JSON_THROW_ON_ERROR);

許可する最大サイズより1バイトだけ多く読み、超過を検出します。その後、JSONオブジェクトのフィールドと値の範囲を検証して、新しい配列を作ります。65_536 バイトや各フィールドの制約は例なので、実際のエンドポイントの仕様に合わせて調整してください。

適用時の注意点

入力の受け取り方や関数の呼び出し方にかかわらず、外部データにはデータ専用形式を使ってください。信頼できないYAMLを扱う場合も、PHPオブジェクトの復元を許可しないよう yaml.decode_php=0 を維持します。例の array_is_list はPHP 8.1以降で利用できます。

参考資料