Who is affected by the process driver, how to upgrade safely, and the new slow-job and replica-lag tools in the same release
Picture an export endpoint that fans work out with Concurrency::run() and prints a progress line from inside each task so the logs show what it’s doing: Exporting rows for: <the user's search term>. It’s the kind of line nobody reviews twice, because it’s just a log message. Until 13.40, that line could be the entire attack. The Laravel team’s release notes say a task that printed user-controlled text could write a fake result, which the parent process then unserialized. The pull request that fixed it calls the unpatched behavior a potential unauthenticated remote code execution, and it names progress lines, streamed exports and debug statements as the ways the text gets in.
Laravel 13.40.0 fixes it. The report and the patch (#62297) both come from the researcher who goes by @Arr0way. Whether you’re exposed depends on two conditions you can check in about ten minutes, and the upgrade has one trap that’s easy to miss: the process that unserializes the result may be a long-running worker that keeps running the old code after you deploy. The same release also adds slow-job and slow-task callbacks and a way to ask a read replica whether it has caught up to a specific write, and both are covered below.
What I could and couldn’t verify, up front. The mechanism and the fix come from the Laravel News release post, the pull request, and the official Concurrency docs. I did not find a CVE or a GitHub security advisory for this issue in the sources I checked, so I’ve assigned no severity score of my own. Those sources also don’t mention exploitation in the wild, which isn’t the same as there being none. And the fix is described for the 13.x branch; I’ll come back to what that means for 12.x and 11.x. I haven’t reproduced the vulnerability, and I’m deliberately not including a proof of concept. The PR’s tests contain one.
How the Bug Works
Laravel’s Concurrency docs describe the process driver, the default, in one sentence: it serializes your closures, hands them to a hidden Artisan command, which unserializes and runs each one in its own PHP process, and the result is serialized back to the parent. The question is how the result travels back.
It travels over the child’s standard output. That’s the whole problem, because a task’s own output shares that same stream. The parent read the child’s stdout, looked for the result envelope, and unserialized it. Anything the task printed was therefore data the parent might treat as the result.
Parent (web request / queue worker) Child (hidden artisan command)
serialize closure ───────────────────► unserialize, run the closure
closure prints: Exporting rows for: <user text>
read child's stdout ◄────────────────── prints the real result envelope
unserialize the envelope
▲
└── a forged envelope inside <user text> gets unserialized here too
Unserializing attacker-influenced data in a PHP application is the classic CWE-502 pattern, and the PR’s tests demonstrate a gadget chain (PendingBroadcast, then the bus dispatcher, then system()) that turns it into command execution in the parent process. The fix changes who can write a valid envelope. The child now wraps its result in a frame signed with a random key that the parent generates for each task, and the parent ignores any frame the key doesn’t verify. User text echoed by a task can no longer pass as a result because it can’t be signed. A side benefit noted in the release: the “Trying to access array offset on null” error that any task printing output could cause also goes away.
Until this release, the PR says, the only defenses were disabling the process driver or auditing every task for anything that writes to stdout. That second one is hard to do honestly, because “writes to stdout” includes code you call, not just code you wrote.
Are You Exposed? Four Checks
1. Your framework version. The fix is in 13.40.0.
composer show laravel/framework | grep -E "^versions"
On the 13.x line, anything below 13.40.0 is unpatched. Check composer.lock too, not just composer.json, since the lock decides what’s installed.
2. Whether you use Concurrency at all, including through dependencies.
grep -rn "Concurrency::" app routes database config
# Packages can call it too; exclude the framework itself
grep -rln "Concurrency::" vendor --include=*.php | grep -v "vendor/laravel/framework"
No hits, no exposure to this issue.
3. Which driver is in play. The docs list three drivers: process (the default), fork, and sync. If you’ve never published config/concurrency.php, you’re on the default. If you have, read its default option, and also search for explicit Concurrency::driver('process') calls, since those override it. The sources describe the vulnerability as specific to the process driver and don’t name fork or sync as affected. That’s a statement about what was reported, not a guarantee, so don’t treat it as certification of safety. Note also that the docs say fork only works in the CLI context, since PHP can’t fork during web requests, so a web endpoint using Concurrency is almost certainly on the process driver.
4. Whether anything in a task can print attacker-influenced text. This is the real exposure condition. A task needs to write to stdout, and some of what it writes needs to be influenced by a request. Start with the obvious writers:
grep -rlE "Concurrency::" app | xargs grep -nE "\b(echo|print|printf|print_r|var_dump|dump|dd)\b"
Then think about the less obvious ones, which is my inference rather than something the sources spell out: a logger configured to write to stdout, or a library that prints diagnostics, would put output on the same stream. Finally, trace the input. A task closure captures variables, and if one of them came from a request and ends up inside a printed string, that’s the path.
// ❌ Request text ends up on the child's stdout
Concurrency::run([
function () use ($search) {
echo "Exporting rows for: {$search}\n";
return Export::run($search);
},
]);
// ✅ Nothing is printed; the result travels back as the return value only
Concurrency::run([
fn () => Export::run($search),
]);
How the checks combine:
| Your situation | What it means |
|---|---|
Not using Concurrency | Not affected by this issue |
fork or sync driver | Not described as affected; confirm against the advisory when one appears |
| Process driver, tasks never print | The attack path the PR describes isn’t open, but stray output from libraries is hard to rule out; upgrade anyway |
| Process driver, tasks print request-influenced text | The case the PR describes as potentially unauthenticated RCE; upgrade now |
Upgrading Safely
composer update laravel/framework
composer show laravel/framework | grep -E "^versions" # confirm >= 13.40.0
php artisan test
If your composer.json pins a lower constraint, edit it first. And if you run an update bot with a cooldown, a grouping rule, or a delayed-merge policy, check that it hasn’t held back a release this fresh. The only version number that matters is the one composer show prints on the server that runs your code.
The trap: restart the long-running parents. The process that unserializes a task’s result is the parent, which is a PHP-FPM request for web traffic but a queue worker or an Octane worker for a lot of apps. Those keep the old framework code in memory until they’re restarted, so a deploy that doesn’t restart them leaves the vulnerable code running.
php artisan queue:restart # workers exit after their current job
php artisan octane:reload # if you run Octane
php artisan schedule:interrupt # for long scheduled commands that call Concurrency
A post-upgrade smoke test. The release notes say the fix also stops the error that a task printing output could cause. That makes a task that prints and returns a handy check that you’re on the fixed code and that signed frames work in your environment:
use Illuminate\Support\Facades\Concurrency;
it('returns the result even when the task prints output', function () {
[$result] = Concurrency::run([
function () {
echo "progress: halfway\n";
return 42;
},
]);
expect($result)->toBe(42);
});
It spawns a real child process, so run it against your actual setup instead of a test environment configured with the sync driver, which would pass without exercising the fix.
If You’re on 12.x or 11.x, or Can’t Upgrade Today
The Concurrency facade has been in the framework since Laravel 11, and the 12.x line has the process driver too; an earlier fix for gzip-compressed output from concurrent processes landed there. The release notes for this issue name 13.x only, the PR is labeled [13.x], and I couldn’t find a 12.x or 11.x release containing the fix or a published advisory covering them. Laravel 12’s security-fix window runs until February 24, 2027, so a backport is plausible, but I can’t confirm it. Don’t read “13.x only” as “older versions are fine”; read it as “unknown, so mitigate.”
The mitigations are the ones the PR lists as the old workarounds:
// config/concurrency.php: removes the vulnerable path, at the cost of concurrency
'default' => 'sync',
Or keep the process driver and make sure no task ever writes to stdout, which means auditing every closure and everything it calls. Neither is pleasant; the sync driver is the one you can verify. Watch the 12.x and 11.x release notes and the framework’s security advisories page.
The Other Tools in 13.40
Slow Job and Slow Task Alerts
Jack Bayliss added Queue::whenJobRunsLongerThan() (#62141) and Schedule::whenTaskRunsLongerThan() (#62233), completing a family that already included DB::whenQueryingForLongerThan().
use Carbon\CarbonInterval;
use Illuminate\Support\Facades\Queue;
Queue::whenJobRunsLongerThan(CarbonInterval::seconds(90), function ($job, $duration) {
Log::warning('Slow job', ['job' => $job->resolveName(), 'ms' => $duration]);
});
use Illuminate\Console\Scheduling\Event;
use Illuminate\Support\Facades\Schedule;
Schedule::whenTaskRunsLongerThan(CarbonInterval::minutes(45), function (Event $task, float $runtime) {
Log::warning('Slow scheduled task', ['task' => $task->getSummaryForDisplay(), 'seconds' => $runtime]);
});
Three details matter. These are alerts, not timeouts: the job callback listens to the JobAttempted event, so it fires after an attempt ends, including attempts that fail or are released, and the task callback runs after the task finishes or fails. A job or task that hangs forever never reaches either callback, so you still need worker timeouts and a monitor that notices silence. Second, the units differ: a plain number means milliseconds for the job callback and seconds for the task callback, and the values passed in are milliseconds and seconds respectively. Pass a CarbonInterval to remove the ambiguity. Third, the job callback runs for every attempt, so a job that fails three times can alert three times.
Commit IDs: Ask the Replica Instead of Guessing
Tobias Petry added commit IDs (#62198), the natural follow-up to the fixed-window middleware from 13.38, which sends a user’s reads to the primary for a set time after a write. With commit IDs you can ask a replica whether it has actually received a given write instead of guessing the lag.
use Illuminate\Support\Facades\DB;
// After a write, on the primary
$order = $user->orders()->create($attributes);
$commitId = DB::getLastCommitId();
// Later, on a replica
DB::hasCommitId($commitId); // check now
DB::hasCommitId($commitId, wait: 2); // wait up to two seconds
mergeCommitIds() combines several IDs so you can wait for all of them at once. The requirements are specific: MySQL with GTIDs enabled, MariaDB with the binary log enabled, or PostgreSQL, where waiting requires PostgreSQL 19. Other drivers throw a RuntimeException, so check this against your database before designing around it.
A sketch of how this could replace a guessed window follows. It’s my illustration of the pattern, not code from the release notes, and it rests on two things I couldn’t confirm from the sources: what getLastCommitId() returns on a request that hasn’t written, and which connection hasCommitId() queries in your setup. Verify both on a real replica before relying on it:
// After a write: remember what the replica must reach
session(['commit_id' => DB::getLastCommitId()]);
// On later requests: read the primary only while the replica is still behind
if ($commitId = session('commit_id')) {
if (DB::hasCommitId($commitId)) {
session()->forget('commit_id'); // caught up: back to the replica
} else {
return DB::connection()->usingWritePdo(fn () => $next($request));
}
}
Also in This Release
| Change | Why you might care |
|---|---|
| Transaction reconnect (#62211) | After a lost connection mid-transaction, Laravel discards the dead PDO and reconnects, instead of failing every later transaction with “already an active transaction” until a worker restarts |
| Nested savepoint rollback (#62248) | A lock wait timeout or deadlock in a nested transaction rolls back to its savepoint, not committing the nested writes if the outer transaction catches the exception |
insertBefore() / insertAfter() (#62230, #62261) | Position your middleware relative to a built-in one without redeclaring the stack |
strict flag on integer(), float(), boolean(), string() (#62254) | ?count=abc123 gives 0 today; with strict: true it returns your default |
ScheduledTaskSkipped event (#62256) | Dispatched when an onOneServer() task already ran elsewhere, which makes the scheduler’s skips observable |
RELEASED in queue:work (#62180) | Released jobs no longer show as DONE |
The two transaction fixes are the ones worth a closer look if you run queue workers or Octane, because both describe states that previously persisted until a restart or silently changed what got committed.
What to Do, in Order
Do you use Concurrency anywhere (app code or vendor)?
├─ No → Nothing to patch for this issue. Upgrade on your normal cadence.
└─ Yes → Which driver?
├─ fork / sync → Not described as affected. Upgrade on your normal cadence,
│ and watch for an advisory.
└─ process (the default)
├─ On 13.x → composer update laravel/framework → confirm >= 13.40.0
│ → test → deploy → restart queue workers / Octane
│ Do tasks print request-influenced text?
│ └─ Yes → do this today, not this sprint
└─ On 12.x / 11.x → fix not confirmed for your branch
→ set 'default' => 'sync', or guarantee no task output
→ watch release notes and advisories
The Verdict
The vulnerability isn’t exotic. It’s a result channel that shared a stream with everything the task prints, plus an unserialize on the receiving end, and the fix is the standard one: authenticate the result so only the child that ran the task can produce one. What makes it worth acting on promptly is how innocent the trigger looks, since a progress message containing a search term is exactly the line a reviewer approves without a second glance. The exposure check is quick, and the one step people will skip is restarting the queue and Octane workers that keep running the old code after the deploy. Do the check, upgrade on 13.x, restart the workers, and if you’re on an older branch, treat the silence from the sources as a reason to mitigate, not as a clean bill of health. The slow-job callbacks and commit IDs are good additions to a release you were going to take anyway.
