Maggie 3.0Meet the newly updated AI tutor

See what's new
php2shell Explained: What PHP Developers Need to Know

php2shell Explained: What PHP Developers Need to Know

The php2shell bugs (CVE-2026-109618 and CVE-2026-109619) lived inside PHP itself, not in your app. Here's what went wrong in plain PHP terms, what the patch changed, and what to check on your own servers.

Every PHP developer has seen "Allowed memory size of 134217728 bytes exhausted" at some point. It's one of the most common fatal errors there is, and it's usually harmless: the request dies, you bump memory_limit or fix the loop, and you move on.

But that same error was at the center of php2shell, the two PHP bugs patched on October 21st (CVE-2026-109618 and CVE-2026-109619). Both are rated 9.2 on CVSS, both allowed remote code execution without logging in, and both lived inside PHP itself, which is why no Composer update or code change in your app could fix them.

I patched our own server that day and then read through the fix, because "update PHP" is the right advice, but it doesn't tell you what actually went wrong. The good news is you don't need to know any C to understand it. If you've ever dealt with state leaking between requests, you already know the shape of this bug.

A half-finished job that never gets cleaned up

Say you have some code that rebuilds a cached list and keeps it in a static property:

class ProductCache
{
    private static ?array $products = null;
 
    public static function rebuild(array $ids): void
    {
        self::$products = [];
 
        foreach ($ids as $id) {
            self::$products[$id] = loadProduct($id);
        }
    }
}

Now say the request runs out of memory halfway through that loop. Running out of memory in PHP is a fatal error, not an exception, so there's no catch and no finally. The request just stops, and self::$products is left half-built.

In a normal PHP request that doesn't matter, because everything gets thrown away at the end. But if you've ever used Laravel Octane, RoadRunner, Swoole or FrankenPHP's worker mode, you know where this is going. Those keep the same process alive across requests, so a static property left in a weird state by one request is still sitting there when the next one comes in (which is exactly why the Octane docs warn you so much about statics and singletons).

php2shell is that same bug, just inside PHP itself instead of inside your app.

PHP keeps its own internal state between requests too, and PHP-FPM and Apache's mod_php both reuse the same worker process for many requests. Before the patch, when PHP ran out of memory it bailed out immediately from wherever it was, the same way your rebuild() method stopped mid-loop. If PHP happened to be in the middle of setting something up, the cleanup never ran, and the next request on that worker could end up using memory that had already been freed. In the wrong hands, that's enough to take over the process.

The part that makes this one worse than a normal bug is timing. PHP does a lot of work on a request before your script starts (parsing the body into $_POST and $_FILES, starting sessions, applying settings), and the problems were in that early work. So a crafted request could cause trouble before any of your validation, middleware or framework code ever ran.

The two CVEs

The two bugs are two sides of the same problem.

CVE-2026-109618 is the "bailing out mid-setup" half. Running out of memory during that early request work could leave PHP's internal state broken. The PHP team confirmed full remote code execution against Apache mod_php with default settings. PHP-FPM handles a failed startup by killing the worker, which blocks the confirmed path, but the advisory still lists it as affected.

CVE-2026-109619 is the "leftovers from the last request" half. Any fatal error (running out of memory, hitting max_execution_time, an uncaught error) could leave PHP's global state pointing at memory from a request that was already finished. The next request on the same worker could then touch it before its own setup replaced it.

Both advisories are worth reading in full if you run your own servers: GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp.

What the patch changed

The fix is one large commit, and most of it is tests. The core idea is simple though: PHP no longer bails out on the spot when a request runs out of memory.

Instead, when a request goes over memory_limit, PHP makes a note of it and keeps going just long enough to reach a safe stopping point, then throws the same "Allowed memory size exhausted" error you're used to. Nothing gets left half-finished, because PHP stops between steps instead of in the middle of one.

That means a request can now briefly go a little past memory_limit, so there's a second, higher limit as a safety net. If a request blows past that one, PHP doesn't try to recover; it kills that worker outright. You can tune it with a new setting, memory_hard_limit_percent. By default it's 200% of memory_limit when your limit is under 1 GB, or your limit plus 1 GB above that. So a Magento store running with memory_limit = 756M gets a hard limit of about 1.5 GB.

The patch also makes PHP reset its internal state properly at the end of every request, so leftovers from one request can't reach the next one, and it tightens up how much memory a single upload request can make PHP use.

What you'll notice after updating

For almost every app, nothing. Your memory errors still look the same, they just happen a moment later than they used to.

The one new thing to know about is the hard limit. A request that goes way over the limit no longer produces a normal PHP fatal error. The worker just exits with code 137, and on PHP-FPM you'll see something like child 1234 exited with code 137 in the FPM log instead of anything in your application's log. If you start seeing 137s after updating, that's what they are, and it means something in that request is using a lot more memory than it should.

Why everyone said to turn off upload progress

Before the patch was out, the one setting researchers kept recommending was session.upload_progress.enabled = Off. It's worth knowing what it does, because most PHP developers have never touched it.

Upload progress lets PHP track how far along a file upload is. While a file is uploading, PHP writes the progress (bytes received, file names) into the user's session, so a second request can read it and draw a progress bar. That made sense years ago, but browsers report upload progress on their own now, and almost nothing uses it anymore (Laravel, Magento and WordPress don't).

It mattered here because of when it runs. Upload progress touches session data very early in a request, before PHP's normal session setup has happened. So if an earlier request on the same worker died partway through and left session state behind (the second bug), upload progress could be what touched those leftovers before they were cleaned up. It's also on by default, and it kicks in on any upload request that includes the right form field, even if your app never calls session_start().

The PHP team confirmed this as one of the ways the second bug could be reached, including on a normal PHP-FPM setup, which is what most of us run. Turning it off closed that path before a patch even existed.

The PHP team recommends keeping it off even after you update, "as more attackers try to find flaws in this hardly-used and hardly-tested feature." A setting that's on by default and that nobody uses is all risk and no reward. It's one line in your php.ini:

session.upload_progress.enabled = Off

Restart PHP-FPM (or Apache) afterward, since the running workers keep the old value until you do.

What to check on your servers

Here's what I'd go through, in this order:

  • confirm the PHP version from a real web request, not php -v, since the CLI and PHP-FPM can be different binaries (8.5.12, 8.4.27, 8.3.36 and 8.2.35 have the fix)
  • restart PHP-FPM or Apache after updating, plus any long-running workers like queue consumers, because the old version stays in memory until you do
  • turn off session.upload_progress.enabled (see above)
  • look through any per-request PHP settings, like php_value lines in .htaccess or PHP_VALUE in your nginx config, and remove anything deprecated or invalid, since warnings from those were one of the ways the second bug could be reached
  • if you're on PHP 8.1 or older, there's no fix coming, so it's time to upgrade

That fourth one is easy to overlook. Magento's own nginx.conf.sample sends PHP_VALUE and PHP_FLAG on every request, and a lot of servers have settings in there that were copied years ago and never looked at again. The advisory recommends moving these into php.ini or your FPM pool config, which only get applied once when a worker starts.

How it went on our server

M.academy runs on PHP-FPM behind nginx, and the server only accepts web traffic through Cloudflare, which pushed firewall rules for the known attacks early that morning. I turned off upload progress before the patch was released, then updated as soon as the packages were out.

The packages themselves were the slow part. Most Ubuntu servers get PHP from Ondřej Surý's PPA on Launchpad, and Launchpad was returning errors for most of the afternoon, probably because everyone was trying to update at the same time. A mirror of the same packages ended up delivering them first.

This bug lived in PHP, not in anyone's application code, and the only real fix was updating PHP and restarting it. But the cleanup items above are worth doing anyway, since they're the kind of thing that sits unnoticed in a server config for years.

Did your update go smoothly, or did you hit the same package mirror problems? I'd love to hear how it went on your end.

Something here out of date?

Magento moves, and so does this site. If a step no longer works, tell me and I'll fix it.

Get the next one by email

Every other week I send a short Magento newsletter: release news, ecosystem news, a question for you, and a take of my own. Enroll in the Library and it's yours. No credit card needed.

The University includes every Magento course available, and every new M.academy course as it ships.