PHP のシリアル化は様々な情報を文字列表現に出来ます。シリアル化は JSON と異なりクラスのインスタンスの情報をそのまま保存できる点で便利であり、これを利用するとリクエストをまたいだ処理の連携を作るのが楽です。
PHP: serialize – Manual
PHP: unserialize – Manual
便利なシリアル化ですが、セキュリティ的に問題が起きやすくもあります。特に問題となるのが外部から与えられたシリアル値を復元する場合です。もし何も疑いもせず外部から与えられたシリアル値を復元した場合、処理の乗っ取りが起こることになります。特に危険なパターンがプロジェクトの採用しているオープンソースなライブラリの中にシリアル値の復元時の副作用(PHP のマジックメソッドに復元時に自動実行されるメソッドがあります)を利用しているライブラリがある場合です。
PHP: マジックメソッド – Manual#object.wakeup
その様なライブラリを採用しつつ自由なシリアル値の復元を許している場合、プロジェクト自体のソースコードを知らずともライブラリを経由して攻撃を仕掛けることができます。
PHP の unserialize 関数の中の注意書きには次の説明があります。
警告
allowed_classesのoptionsの値にかかわらず、
ユーザーからの入力をそのまま unserialize() に渡してはいけません。
アンシリアライズの時には、オブジェクトのインスタンス生成やオートローディングなどで
コードが実行されることがあり、悪意のあるユーザーがこれを悪用するかもしれないからです。
シリアル化したデータをユーザーに渡す必要がある場合は、安全で標準的なデータ交換フォーマットである
JSON などを使うようにしましょう。
json_decode() および json_encode()
を利用します。外部に保存されているシリアル化されたデータをアンシリアライズする必要がある場合は、
hash_hmac() を使ったデータの検証を検討しましょう。
他者によるデータの改ざんがないことを確かめるためです。
要するに「危険な機能なのでユーザとデータをやり取りするのみなら素直に JSON を使いなさい、もしそれでも unserialize を使いたいのであれば hash_hmac などで改ざんされたデータでないことを確かめなさい」、ということです。この記事では hash_hmac でセキュアにした serialize, unserialize を楽に扱うための方法を紹介します。
使う関数は以下の二つです。
PHP: hash_hkdf – Manual
PHP: hash_equals – Manual
hash_hkdf 関数は HMAC を用いた Key Derivation Function の略です。HMAC は Hash-based Message Authentication Code の略で、データと秘密鍵に基づくハッシュ値を出力します(ソルトもつけれます。複雑なソルトを常につければレインボーテーブル(ハッシュと元値対応の辞書)の作成が困難になるのでつけるべきです)。このハッシュ値とデータをまとめて送り、返ってきたデータと秘密鍵を元に改めてハッシュ値を出力し、返ってきたハッシュ値と比較することでデータが正当かを検証できます。今回の場合、このデータがシリアライズデータであり、意図せぬ値のアンシリアライズを防げます。これをまとめたクラスが次です。
<?php
namespace App\Lib\Serializer;
use Exception;
use InvalidArgumentException;
use JsonException;
use function config;
/**
* 外部に一旦渡しても安全なシリアライズ、アンシリアライズをするためのクラス
*/
class Serializer
{
/* @var string 外部でデータの中身を見られないために使うハッシュのキー */
private string $secretKey;
/* @var string 外部でデータの中身を見られないために使うハッシュのアルゴリズム */
private string $algo;
/* @var int シリアライズ化したデータの期限(秒) */
private int $expireSec;
/**
* Serializer constructor.
* @param string|null $secretKey 外部でデータの中身を見られないために使うハッシュのキー
* @param string $algo 外部でデータの中身を見られないために使うハッシュのアルゴリズム
* @param int $expireSec シリアライズ化したデータの期限(秒)
*/
public function __construct(?string $secretKey = null, string $algo = 'SHA256', int $expireSec = 1800)
{
$this->algo = $algo;
$this->expireSec = $expireSec;
if($secretKey === null && function_exists('config')) {
// Laravel で使う時は設定ファイルから十分な強度のキーを呼び出して楽をできます
$this->secretKey = config('app.key');
} elseif($secretKey !== null) {
$this->secretKey = $secretKey;
}
if(empty($this->secretKey)) {
throw new InvalidArgumentException('暗号化キーが指定されていません。');
}
}
/**
* 外部に一旦渡しても安全なアンシリアライズをするためのシリアライズを実行するメソッド
* @param $data
* @return string
* @throws Exception
*/
public function serialize($data): string
{
$serializedData = serialize([
'data' => $data, // シリアライズしたいデータそのものを serialize 内に格納
'expire' => time() + $this->expireSec, // 指定時間後に期限切れ。期限切れをごまかされない様に serialize 内に格納
]);
// キーの強度を上げるためのソルト
$salt = bin2hex(random_bytes(16));
/** @var string $openKey 外部に渡す HKDF キー */
$openKey = $this->makeHkdfKeyEncodedBase64($this->secretKey, $serializedData, $salt);
// 後の検証に必要なデータとシリアライズしたデータをまとめた文字列を返します。
return base64_encode(json_encode(
compact('salt', 'openKey', 'serializedData'),
JSON_THROW_ON_ERROR
));
}
/**
* 外部に一旦渡しても安全なアンシリアライズをするメソッド
* @param $serializedData
* @return mixed
* @noinspection UnserializeExploitsInspection PhpStormのアンシリアライズ警告を黙らせるためのコメント
*/
public function unserialize($serializedData)
{
// self::serialize メソッドで作った文字列を元の配列に直します。
try {
$data = json_decode(base64_decode($serializedData), true, 512, JSON_THROW_ON_ERROR);
} catch(JsonException $e) {
throw new InvalidSerializedData('JSON形式でない不正なデータが検出されました。 JSONError: '.$e->getMessage());
}
// 元に戻した値が配列であるか、想定通りの型の値が格納されているかを確認します。
if(
!is_array($data)
|| !isset($data['salt'], $data['openKey'], $data['serializedData'])
|| !is_string($data['salt'])
|| !is_string($data['openKey'])
|| !is_string($data['serializedData'])
) {
throw new InvalidSerializedData('想定した形式でない不正なデータが検出されました。 Data: ' . serialize($data));
}
// 送られてきたソルトとシリアライズしたデータを元に再導出した HKDF キーと送られてきた HKDFキー を比較します。
$calculateKey = $this->makeHkdfKeyEncodedBase64($this->secretKey, $data['serializedData'], $data['salt']);
// 比較には hash_equals 関数を使います。hash_equals 関数はタイミング攻撃に対して安全な文字列比較関数です。
// @see https://www.php.net/manual/ja/function.hash-equals.php
// タイミング攻撃はざっくばらんに言えば、処理にかかる時間を元に秘密となっている文字列(パスワードなど)を推測する手法です。
// 例えば 1 文字目から末尾まで順に比較し、異なる文字が見つかった時点で処理を打ち切る比較アルゴリズムは
// 比較対象と一致する部分の長さが長くなるにつれて処理時間が長くなります。このため処理時間の前後を見ることで秘密の情報を速く特定できます。
if(hash_equals($calculateKey, $data['openKey']) !== true) {
throw new InvalidSerializedData('キーと合わない不正なデータが検出されました。');
}
// シリアライズしたデータは改ざんされていないので安全にアンシリアライズ可能です。
$unserializedData = unserialize($data['serializedData']);
// 有効期限をチェック
if($unserializedData['expire'] < time()) {
throw new ExpiredSerializedData('有効期限切れのデータが検出されました。');
}
// 有効期限内で改ざんされていないことが保証されているデータを返します
return $unserializedData['data'];
}
/**
* HKDF キーの導出。導出された HKDF キーは Base64 でエンコードされた文字列として返されます。
* 素の HKDF キーはバイナリのため、外部次第では文字化け等によって正当な操作をしても処理が壊れる場合があります。
* この処理が壊れる事態を防ぐために Base64 でエンコードしました。
*
* @param string $secretKey HKDFキーを作るために使うキー。絶対に外部に漏らしてはいけないキー
* @param string $serializedData ハッシュ対象のシリアライズデータ
* @param string $salt HKDFキーの強度上昇用のソルト
* @return string HKDFキーを base64 でエンコードした文字列
*/
private function makeHkdfKeyEncodedBase64(string $secretKey, string $serializedData, string $salt): string
{
return base64_encode(hash_hkdf($this->algo, $secretKey, 0, $serializedData, $salt));
}
}
// 使い方
$raw = [
7,
'a'=> 12,
'ba' => $object
];
$serialized = $serializer->serialize($raw);
$unserializedData = $serializer->unserialize($serialized);
self::assertEquals($raw, $unserializedData);// no error