PHP2Shell: you patched, but were you hit? A read-only triage script for your server

Patching PHP2Shell (CVE-2026-109618 and CVE-2026-109619) closes the hole, but it does not tell you whether someone got in before you patched. Here is a read-only bash script that checks your PHP builds and looks for the few traces an exploitation attempt leaves behind.

Zacharias Creutznacher Zacharias Creutznacher

PHP2Shell: you patched, but were you hit?

Today PHP shipped fixes for PHP2Shell, two use-after-free bugs tracked as CVE-2026-109618 and CVE-2026-109619. Both are rated CVSS 9.2, need no login, and are triggered by a multipart/form-data request that PHP parses before a single line of your application runs. The vulnerable code goes back to PHP 5.4, and default configurations are affected, with mod_php and PHP-FPM alike.

The patched releases are:

Branch Fixed in
PHP 8.5 8.5.12
PHP 8.4 8.4.27
PHP 8.3 8.3.36
PHP 8.2 8.2.35

PHP 8.1 and older are end of life. The PHP team published source backports for 7.4, 8.0 and 8.1, but there are no official packages for them.

So you updated. Good. But there is an uncomfortable detail: Cloudflare saw the first attacks at 07:43 UTC, more than six hours before the fix was released at 14:00 UTC. A patch closes the door. It does not throw out anyone who already walked through it.

#What you can and cannot detect

PHP2Shell can be abused in two ways, and they are very different to investigate after the fact.

Memory disclosure. The bug can leak worker memory into a PHPSESSID response cookie. That memory may contain passwords, session tokens, API keys or even TLS private keys. This leaves almost nothing on disk. You cannot prove after the fact that nothing leaked.

Remote code execution. If someone got code execution, they usually leave things behind: changed files, new cron jobs, SSH keys, odd outbound connections. That is classic incident response territory, and it is where a script helps.

At the time of writing, none of the public writeups ship concrete indicators of compromise. The best signal we have is the trigger itself: the bug is reached through a fatal error, typically Allowed memory size exhausted. A cluster of those in your FPM log around October 21 is worth a closer look.

#The script

I put the checks I ran on our own servers into a single read-only bash script. It changes nothing. It only reads logs and lists files.

Be clear about what it is: a triage, not forensics. It tells you whether you should look closer, not that your server is clean. It runs on the very host it checks, so an attacker who had code execution and cleaned up after themselves can hide from it. It reliably catches the sloppy cases (an outdated PHP build, a dropped webshell, an unexpected file in your release), and those are the majority.

Get it here: php2shell-triage.sh on GitHub Gist

SSH into your server, download the script and read it before you run it. It is a script you run as root, so piping it straight from curl into sudo bash is exactly the habit you want to avoid during a security incident.

 1# download the latest version of the script from the gist
 2curl -fsSL -o php2shell-triage.sh \
 3  https://gist.githubusercontent.com/Sairahcaz/2c22b4a68fd5b75e410c7f9f69f485a0/raw/php2shell-triage.sh
 4
 5# read it
 6less php2shell-triage.sh
 7
 8# run it (root is needed to read the FPM and auth logs)
 9sudo bash php2shell-triage.sh
10
11# or point it at a specific Laravel root
12sudo bash php2shell-triage.sh /home/forge/example.com/current

When you are done, remove it again with rm php2shell-triage.sh.

It works through five sections:

  1. PHP versions. Every CLI and FPM binary is compared against the patched builds above. The FPM version is the one that matters, since that is what serves your site, and it can differ from the CLI.
  2. OOM fatals. Counts Allowed memory size errors in your PHP-FPM logs, including rotated .gz files. A handful is normal on most apps. A burst is a flag.
  3. POST volume. Lists the busiest POST targets on October 21. Standard access logs do not record the Content-Type, so this is a lead, not proof. Look for endpoints that never take uploads.
  4. Unexpected files. Flags any PHP file in public/ or storage/ other than index.php and runs git status if your release is a Git checkout. On a Laravel app, a clean working tree is the most reliable answer you will get. Without Git, it lists PHP files modified after the current release was deployed, which works with zero-downtime deployments on Forge, Envoyer or Deployer.
  5. Persistence. Cron entries, authorized_keys, recent SSH logins and established connections on unusual ports.

It ends with a verdict: flags, warnings, or clean.

#Reading the output

On a public server, expect warnings even when everything is fine. On our own Forge box, a clean run still produced these:

  • POST targets like /cgi-bin/.%2e/.%2e/bin/sh, ?allow_url_include=1 or random Java servlet paths. That is the background noise of internet-wide scanners hitting every server around the clock. It is not PHP2Shell.
  • SSH logins as root and forge from DigitalOcean IP ranges. That was Laravel Forge itself, rolling out the PHP update. You can verify against Forge's published IPs.
  • Modified generated files, like a sitemap your app writes into public/. Check them, but they are usually yours.

What you are really looking for is a FLAG: an unpatched build, a PHP file where none belongs, or a burst of OOM fatals.

#A note on distro packages

The version check compares against upstream version numbers. That is correct for Laravel Forge, which uses the ondrej PPA and follows upstream releases. Official Debian, Ubuntu and RHEL packages often backport security fixes without bumping the upstream version, so the script may flag a build that is actually patched. If that happens, check the package changelog for the CVE numbers:

 1apt changelog php8.3-fpm | grep -E '109618|109619'
 2rpm -q --changelog php-fpm | grep -E '109618|109619'

#A side note: missing extensions after the update

Right after updating PHP on one of our Forge servers, Sentry reported this:

 1Call to undefined function Illuminate\Support\mb_split()

That looks scary at midnight during a security incident, but it is harmless. mb_split() comes from the mbstring extension, and for a short moment the new PHP build was running before the extension was enabled for it. In our case it was gone once the update had finished. If it persists on your server, check php -m | grep mbstring for the FPM version that serves your site and install the matching php8.x-mbstring package. It has nothing to do with PHP2Shell, but you may well see it tonight too.

#If you are behind Cloudflare

Cloudflare rolled out WAF rules for the known exploit vectors, including on the free plan. That only helps if attackers cannot reach your origin directly. Check that no DNS record points at your origin without the orange cloud (staging., ftp. and direct. are common culprits), and restrict ports 80 and 443 on the origin to Cloudflare's IP ranges. Otherwise the WAF is a door with a wall missing next to it.

#Takeaway

Patch first, then check. If the script comes back clean and git status shows nothing unexpected, a silent compromise is unlikely. But because PHP2Shell is a memory disclosure bug with a real zero day window, I would rotate the secrets that live in your PHP workers anyway: APP_KEY, database credentials, third-party API keys and, if they sit on the same box, your origin TLS keys. When you rotate APP_KEY, put the old one into APP_PREVIOUS_KEYS so existing encrypted data and sessions keep working. That is a cheap price compared to finding out in three months that something leaked.