DEV Community

Cover image for I built a PHP2Shell checker the morning the patch dropped. Here's what 650 checks showed
Kyle Clouthier
Kyle Clouthier

Posted on

I built a PHP2Shell checker the morning the patch dropped. Here's what 650 checks showed

On 21 October 2026 PHP shipped an emergency fix for two critical flaws, CVE-2026-109618 and CVE-2026-109619, now known as PHP2Shell. They allow remote code execution with no login. The details had leaked the day before, so by the time the patch landed there was exploit code going around and government sites in the Netherlands and Finland had already gone offline.

The part that worried me most: official releases only exist for PHP 8.2 to 8.5 (8.2.35, 8.3.36, 8.4.27, 8.5.12). PHP 8.1 and older got no release at all, only minimal source patches for people who build PHP themselves. A lot of small business sites on cheap shared hosting run exactly those versions, and their owners have no idea.

So I built a free checker and had it live within an hour of the patch: simgen.dev/php-patch-check. This is what I learned building it, and what the first day of data looks like.

What the data says after day one

  • 658 sites checked, PHP found on 310 of the 602 fully counted.
  • 30 of the 85 sites that expose their PHP version are end of life, with no official update to install.
  • Owners who looked up their exact version from inside their own server: 11 of 26 on end-of-life PHP. (More on how that works below.)
  • WordPress is by far the most common system underneath (159), then Laravel and Matomo.

It's a self-selected sample, so treat it as a signal, not a census. But it lines up with Detectify's estimate that around half of PHP sites run versions that no longer get patches.

How the outside check works

The checker reads only what a site shows publicly. No exploit traffic, no probing for the bug itself.

  • Headers: X-Powered-By: PHP/8.1.2 is the jackpot when it's there. Server banners sometimes leak it too.
  • Cookies: PHPSESSID, laravel_session, wordpress_* and friends tell you it's PHP even when the version is hidden.
  • Fingerprints: about 30 systems, from WordPress, WooCommerce, Drupal and Joomla to Magento, Nextcloud, Matomo and MediaWiki, picked up from paths, meta tags and asset URLs. For WordPress, the /feed/ generator tag gives away the WordPress version.
  • Verdicts: exposed version, PHP but version hidden, no PHP signal, or couldn't read it.

Most sites hide their version, which is good hygiene but means an outside check can only say "this is PHP, go look". So for the hidden case the result walks the owner to the exact version in their own dashboard (WordPress: Tools, Site Health, Info, Server; cPanel: Select PHP Version).

Things that bit me

Node's built-in fetch hangs on old Apache servers. Some old Apache setups send Connection: Upgrade in the response, and undici (the engine behind Node's fetch) waits on it forever. The old, neglected sites were exactly the ones I needed to read, so I dropped down to node:http and node:https with my own timeouts and a 200 KB body cap.

A "check any URL" tool is an SSRF waiting to happen. Every hop gets checked: http and https only, ports 80, 443, 8080 and 8443, the resolved IP rejected if it's private, loopback, link-local or a cloud metadata address, and the check repeated at connect time so DNS rebinding can't sneak past. Redirects stay on the same host, with a cap of 5 requests per check.

Old sites are slow. Some took 20 to 30 seconds to answer. The UI shows progress steps instead of a spinner, and there's an http fallback when https fails and the user didn't type a scheme.

Getting the exact version without phoning home

When the version is hidden, there's a downloadable check-php.php. You upload it, open it once, it prints PHP_VERSION and whether that branch is end of life, then deletes itself with unlink(__FILE__) so your version isn't left public. It sends nothing anywhere. It prints an optional link back to the checker with the version number, so if the owner clicks, they get the full answer, and I get an anonymous tally of real versions from inside real servers. That's the "11 of 26" number above.

The gotcha most people will miss: restarting PHP

Installing the fixed package doesn't fix a running server. PHP-FPM pools and Apache with mod_php keep the old binary in memory until they restart. And on cPanel, Plesk or CloudLinux boxes there are often four or five PHP versions side by side (ea-php74, ea-php81, alt-php and so on), and each one needs its own fix.

So I wrote a small read-only script that lists every PHP install on a server and flags PHP-FPM processes still running a binary that was replaced after they started. It compares /proc/<pid>/exe against the process start time:

curl -sO https://raw.githubusercontent.com/KyleClouthier/php2shell-check/main/check-all-php.sh && sudo bash check-all-php.sh
Enter fullscreen mode Exit fullscreen mode

It's MIT licensed: github.com/KyleClouthier/php2shell-check.

Keeping it fixed in CI

composer update won't touch PHP2Shell, because it's in PHP itself. The unfixed version tends to hide in places nobody looks: a Docker base image, config.platform.php in composer.json, a .php-version file, or the php-version you pass to setup-php in CI. I put a GitHub Action on the Marketplace that checks all of those, walks the whole repo (monorepos included), and annotates the exact line in the pull request:

- uses: KyleClouthier/php2shell-action@v1
Enter fullscreen mode Exit fullscreen mode

github.com/marketplace/actions/php2shell-check

Myths I kept correcting on day one

  • "I updated WordPress." Doesn't matter. The bug is in PHP underneath. WordPress auto-updates won't touch it.
  • "I turned off session.upload_progress." That blocks one attack path. There are two bugs, and PHP says no config change is a complete fix.
  • "I'm behind Cloudflare." Cloudflare itself calls its rules a stopgap.
  • "My site hides its version." Attackers don't check your version first. They just send the attack.
  • "Only mod_php is affected." PHP lists PHP-FPM, php-cgi, FrankenPHP and LiteSpeed too. mod_php is just the worst case.

What I'd tell a site owner

  1. Find your exact PHP version.
  2. If it's 8.2 to 8.5, make sure it's on the fixed release and that PHP was restarted.
  3. If it's 8.1 or older, ask your host in writing whether they applied a backport, and plan the move to 8.2 or newer on a staging copy first. That move is what breaks old plugins.
  4. Check nothing got in while it was open: new admin users, files you didn't upload, recently changed files.

If you want to try the checker on your own sites (or ten at once), it's free and stores no addresses: simgen.dev/php-patch-check. Happy to answer questions about the build in the comments.

Top comments (0)