<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
БЕЗПЕКА · SENIOR

Чим небезпечний `unserialize()` на чужих даних і що таке PHP object injection?

`unserialize()` створює обʼєкти будь-якого класу, який може завантажити автозавантажувач, з властивостями, які задав автор рядка, і потім PHP сам викликає їхні магічні методи: `__wakeup()`, `__unserialize()`, `__destruct()`, `__toString()`. Ланцюжок таких методів із класів у `vendor/` (gadget chain) дає запис файлів чи виконання коду. Чужі дані розбирають через `json_decode()`, а якщо формат PHP неминучий, то з `allowed_classes => false`, `max_depth` і перевіркою HMAC до розбору.

Що не так з рядком `$cart = unserialize($_COOKIE['cart']);`?
Як `unserialize()` може призвести до виконання коду, якщо він лише відновлює дані?
Чи рятує `['allowed_classes' => false]`?
Як витік `APP_KEY` у Laravel повʼязаний з десеріалізацією?
unserialize object injection gadget chain allowed_classes json_decode PHPGGC

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]);
Що атакувальник контролює не код, а клас і властивості обʼєкта, а код запускають магічні методи, які викликає сам рушій.
Що гаджети беруться з будь-якого класу, доступного автозавантажувачу, тобто й з залежностей, і для популярних бібліотек готові ланцюжки зібрані в PHPGGC.
Що безпечна заміна для даних з-за меж довіри це JSON, а `allowed_classes => false` лише зменшує ризик: документація PHP прямо забороняє передавати в `unserialize()` недовірені дані за будь-яких опцій.
Що недовірені дані приходять звідусіль, куди атакувальник може писати: запит, кеш, черга, сесія, база, файли і розшифровані значення, якщо ключ шифрування може витекти.
Що в Laravel сесії в JSON і `serializable_classes => false` для кешу закривають саме цей сценарій, а оновлені зі старих версій проєкти можуть цих налаштувань не мати.
Казати «у нас немає класів з `__destruct`, які щось видаляють»: гаджети шукають у всьому `vendor/`, і автор коду їх не писав.
Перевіряти рядок регуляркою на `O:` перед `unserialize()`: формат має інші способи створити обʼєкт (`C:` для `Serializable`, посилання, вкладені структури), і такі фільтри обходили неодноразово.
Вважати `base64_encode(serialize(...))` у cookie захищеним: кодування не є підписом, атакувальник так само закодує свій рядок.
Покладатися на те, що Redis для кешу і черг «внутрішній»: доступ на запис до нього, через SSRF чи відкритий порт, перетворюється на виконання коду, бо задачі черги в Laravel зберігаються як серіалізовані обʼєкти.
Підписувати дані, але порівнювати підпис через `==` або розбирати рядок до перевірки підпису.
ПОРАДА

Поясніть механіку одним реченням: «я не передаю код, я передаю обʼєкт, а код запускає рушій, коли викликає його `__destruct`». Потім назвіть місця, де серіалізовані дані живуть у звичайному Laravel-проєкті: сесія, кеш, черга, `decrypt()`.

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`allowed_classes => false` не дає створити справжні обʼєкти, і гаджети не спрацьовують, проте парсер і вкладеність лишаються ризиком, тому для чужих даних беруть JSON. Гаджети шукають у всіх класах, доступних автозавантажувачу, разом із `vendor/`. Base64 це кодування, а не підпис. `decrypt()` за замовчуванням передає розшифроване значення в `unserialize()`, і з викраденим ключем це стає object injection.