---
title: "Вразливість у Concurrency: вивід задачі міг призвести до виконання коду в Laravel"
url: https://laravelukraine.com/blog/vrazlivist-u-concurrency-vivid-zadaci-mig-prizvesti-do-vikonannia-kodu-v-laravel
date: 2026-11-04
---

# Вразливість у Concurrency: вивід задачі міг призвести до виконання коду в Laravel

У [Laravel 13.40.0](https://github.com/laravel/framework/releases/tag/v13.40.0) закрили вразливість у фасаді `Concurrency`, яка за певних умов давала віддалене виконання коду (RCE) без автентифікації. Драйвер `process`, що стоїть за замовчуванням, читав результат задачі з її stdout і робив `unserialize()` того, що там знаходив. Якщо задача друкувала хоч щось, на що впливає користувач, зловмисник міг підробити цей результат. Того ж дня вийшов і 12.71.0 з тим самим виправленням.

Це окрема від [нещодавньої вразливості в TokenGuard](https://laravelukraine.com/blog/kriticna-vrazlivist-u-tokenguard-obxid-avtentifikaciyi-v-laravel) проблема, і цього разу окремого попередження безпеки (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](https://github.com/laravel/framework/issues/59112)), у [PR #59224](https://github.com/laravel/framework/pull/59224) додали обрізання виводу на першій парі байтів `\x1f\x8b` (початок gzip-потоку). Ця зміна вийшла в 12.55.0 і 13.0.0.

### Як це перетворювалося на RCE

За [описом виправлення](https://github.com/laravel/framework/pull/62297), якщо задача друкує текст із запиту, зловмисник передає в параметрі готовий 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()` виконує тільки для перевіреного вмісту.

```php
$result = $this->parseEnvelope($result->output(), $envelopeKeys[$key]);
```

Опис PR згадує ключ застосунку, але в коді ключ саме випадковий для кожної задачі, а тест `testEnvelopeSignedWithTheApplicationKeyIsRejected` прямо перевіряє, що рамку, підписану `APP_KEY`, відхилено. Тож витік `APP_KEY` цей захист не обходить.

Вивід, у якому немає валідної рамки, тепер дає `RuntimeException` з першими 200 символами виводу, а не загадкову помилку про `null`. Повернені значення, як і раніше, десеріалізуються в будь-який тип, а винятки із задач приходять зі своїм класом.

### Що зробити

Оновіть фреймворк:

```bash
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 варто пришвидшити.
