У Laravel 13.40.0 закрили вразливість у фасаді Concurrency, яка за певних умов давала віддалене виконання коду (RCE) без автентифікації. Драйвер process, що стоїть за замовчуванням, читав результат задачі з її stdout і робив unserialize() того, що там знаходив. Якщо задача друкувала хоч щось, на що впливає користувач, зловмисник міг підробити цей результат. Того ж дня вийшов і 12.71.0 з тим самим виправленням.
Це окрема від нещодавньої вразливості в TokenGuard проблема, і цього разу окремого попередження безпеки (GHSA) у laravel/framework на момент написання немає. Тож composer audit про неї не повідомить, а номерів CVE чи оцінки CVSS поки не існує.
Як працює драйвер process
Concurrency::run() запускає кожне замикання в окремому PHP-процесі через Artisan-команду invoke-serialized-closure. Дочірній процес виконує задачу, серіалізує повернене значення і пише в stdout JSON на кшталт {"successful": true, "result": "<serialize()>"}. Батьківський процес читає stdout, декодує JSON і викликає unserialize() поля result.
Слабке місце - stdout спільний. Усе, що задача виводить через echo, print, dump() чи сторонню бібліотеку, опиняється в тому ж потоці, що й результат. У березні, щоб виправити збій із zlib.output_compression (issue #59112), у PR #59224 додали обрізання виводу на першій парі байтів \x1f\x8b (початок gzip-потоку). Ця зміна вийшла в 12.55.0 і 13.0.0.
Як це перетворювалося на RCE
За описом виправлення, якщо задача друкує текст із запиту, зловмисник передає в параметрі готовий JSON із підробленим result, а за ним байти \x1f\x8b. Батьківський процес обрізає все після них, разом зі справжнім результатом, і десеріалізує підроблений. Для виконання команд вистачає ланцюжка гаджетів із самого фреймворку (PendingBroadcast -> Bus\Dispatcher -> system()), і тести в PR це демонструють.
Типові задачі, які могли таке друкувати: рядки прогресу, потоковий експорт, налагоджувальний вивід, повідомлення сторонніх пакетів. Окремо від атаки, будь-який вивід задачі ламав JSON, і запит падав з ErrorException: Trying to access array offset on null - той самий симптом, що й у #59112.
Кого це стосується
- Драйвер
process (значення default у config/concurrency.php, або CONCURRENCY_DRIVER=process) з Concurrency::run() - вразливий.
Concurrency::defer() результатів не читає: задачі запускаються у фоні, а їхній вивід ніхто не розбирає. Тести в PR окремо перевіряють, що відкладені задачі ключа не отримують. Проблема саме в run().
- Драйвер
fork працює через spatie/fork без розбору stdout і в межах веб-запиту взагалі недоступний. Виправлення його не чіпає.
- Драйвер
sync просто викликає замикання в тому самому процесі - серіалізації немає.
Найбільше ризикують задачі, що виконуються під час HTTP-запиту і виводять щось похідне від вхідних даних.
Що змінилося в 13.40.0
Обрізання за байтами \x1f\x8b прибрали повністю. Тепер:
- для кожної задачі батьківський процес генерує випадковий 32-байтовий ключ і передає його дочірньому в змінній середовища
LARAVEL_CONCURRENCY_HMAC_KEY; дочірній одразу прибирає її з середовища;
- дочірній процес додає в кінець виводу рамку
@@LARAVEL-CONCURRENCY-V1@@<base64>@@<hmac>@@ з HMAC-SHA256 на цьому ключі;
- батьківський процес шукає рамки знизу вгору і бере лише ту, чий підпис збігся, а
unserialize() виконує тільки для перевіреного вмісту.
$result = $this->parseEnvelope($result->output(), $envelopeKeys[$key]);
Опис PR згадує ключ застосунку, але в коді ключ саме випадковий для кожної задачі, а тест testEnvelopeSignedWithTheApplicationKeyIsRejected прямо перевіряє, що рамку, підписану APP_KEY, відхилено. Тож витік APP_KEY цей захист не обходить.
Вивід, у якому немає валідної рамки, тепер дає RuntimeException з першими 200 символами виводу, а не загадкову помилку про null. Повернені значення, як і раніше, десеріалізуються в будь-який тип, а винятки із задач приходять зі своїм класом.
Що зробити
Оновіть фреймворк:
composer update laravel/framework
composer show laravel/framework | grep versions
Потрібна версія не нижча за 13.40.0 або 12.71.0. Для 12.x GitHub-реліз окремо не оформили, але тег v12.71.0 з виправленням є і на Packagist.
Якщо оновитися одразу не виходить:
- приберіть із задач
Concurrency::run() будь-який вивід, де є дані користувача, включно з dump() і логуванням у stdout;
- або тимчасово перемкніть драйвер на
sync (CONCURRENCY_DRIVER=sync), пожертвувавши паралельністю.
І ще одна річ на час оновлення: якщо у ваших тестах Process::fake() повертав для invoke-serialized-closure голий JSON, після оновлення такі тести впадуть з RuntimeException. Самому фреймворку довелося переписати свій тест з таймаутом саме так.
Laravel 11 виправлення не отримав. Його код розбирає вивід інакше, без обрізання за \x1f\x8b, але спільний stdout і unserialize() там ті самі, а гілка вже не отримує оновлень безпеки. Якщо проєкт на 11.x використовує Concurrency::run(), діють ті самі тимчасові заходи, а план переходу на 12.x чи 13.x варто пришвидшити.