unserialize() уміє більше, ніж відновлювати масиви. Формат дозволяє описати обʼєкт будь-якого класу, який може завантажити автозавантажувач, разом із його властивостями, приватними теж, і PHP створить цей обʼєкт без виклику конструктора. Далі рушій сам викликає магічні методи: __unserialize() або __wakeup() одразу, __destruct() в кінці запиту чи при збиранні сміття, __toString(), якщо обʼєкт десь виведуть як рядок. Атакувальник не передає код, він передає обʼєкт із потрібними значеннями властивостей, а код, який з ними працює, уже є у вашому проєкті. В OWASP Top 10:2025 це A08 Software or Data Integrity Failures.
Такий код називають гаджетом, а послідовність, де деструктор одного обʼєкта викликає метод іншого, gadget chain. Шукають їх не в застосунку, а в усьому vendor/: логери, HTTP-клієнти, ORM і самі фреймворки мають класи з деструкторами, що пишуть файли, викликають колбеки чи закривають ресурси. Для популярних бібліотек готові ланцюжки зібрано в PHPGGC, тож аргумент «у нас немає небезпечних класів» на співбесіді не працює. Наслідки залежать від доступного ланцюжка: від видалення файлу до запису веб-шелла і виконання команд.
Захист починається з формату. Дані з-за меж довіри розбирають через json_decode(), який створює лише масиви і скаляри. Опція ['allowed_classes' => false] (PHP 7.0+) перетворює будь-який обʼєкт на __PHP_Incomplete_Class і вимикає гаджети, а max_depth (PHP 7.4+) обмежує вкладеність, але документація PHP прямо радить не передавати в unserialize() недовірені дані за будь-яких опцій: у самому парсері історично знаходили помилки памʼяті. Якщо PHP-формат неминучий, дані підписують HMAC і перевіряють підпис через hash_equals() до розбору; base64 підписом не є.
Найцікавіше на рівні Senior: де в звичайному проєкті живуть серіалізовані дані. Сесія при php-серіалізації, кеш, задачі черги, які Laravel зберігає як серіалізовані обʼєкти, колонки бази з serialize() з легасі і все, що проходить через decrypt(), бо той за замовчуванням виконує unserialize(). Кожне з цих сховищ стає точкою входу, щойно атакувальник отримує на нього запис: відкритий Redis, SSRF до нього або витік APP_KEY, яким можна зашифрувати свій рядок. Саме тому скелет Laravel 13 тримає сесії в JSON і забороняє класи в кеші, а в проєкті, оновленому зі старої версії, ці два ключі в конфігах варто перевірити першими.
// Звичайний клас десь у vendor/: деструктор прибирає тимчасовий файл.
final class TempFile
{
public function __construct(public string $path) {}
public function __destruct()
{
@unlink($this->path);
}
}
// Було: рядок з cookie йде прямо в unserialize().
// Атакувальник присилає O:8:"TempFile":1:{s:4:"path";s:9:".htaccess";}
// і в кінці запиту деструктор видаляє файл, який обрав він.
$cart = unserialize($_COOKIE['cart']);
// Стало: JSON не створює обʼєктів і не викликає жодних методів.
$cart = json_decode($_COOKIE['cart'] ?? '[]', true, 8, JSON_THROW_ON_ERROR);
// Легасі, де PHP-формат поки неминучий: спершу підпис, потім розбір без класів.
[$mac, $payload] = explode('.', $raw, 2) + [1 => ''];
if (! hash_equals(hash_hmac('sha256', $payload, $secret), $mac)) {
throw new RuntimeException('Підпис не збігається.');
}
$data = unserialize($payload, ['allowed_classes' => false, 'max_depth' => 16]);
Поясніть механіку одним реченням: «я не передаю код, я передаю обʼєкт, а код запускає рушій, коли викликає його `__destruct`». Потім назвіть місця, де серіалізовані дані живуть у звичайному Laravel-проєкті: сесія, кеш, черга, `decrypt()`.