
Read-only triage script for WordPress sites after PHP2Shell (CVE-2026-109618, CVE-2026-109619). Checks for signs of compromise and writes a report per site.
A read-only triage script for WordPress sites after PHP2Shell (CVE-2026-109618 and CVE-2026-109619), the pair of critical remote code execution bugs in the PHP engine disclosed on October 21, 2026.
Patching PHP closes the door, but Amazon's honeypots logged an exploitation attempt on October 17, four days before the public disclosure, while the bugs were still under embargo. That is the earliest activity anyone has published, not the day attacks began, so treat it as a floor. Any site that was reachable on an unpatched PHP during that window may already have a backdoor, and updating PHP won't remove it. This script checks one or many WordPress sites for the things an attacker with code execution typically leaves behind, and writes a Markdown report for each.
A third bug, CVE-2026-115560, was announced on October 23. It has no
fixed build yet: the October point releases (8.2.35, 8.3.36, 8.4.27, 8.5.12)
do not cover it, and the fix is due with the next regular release in
November. php.net asks everyone to set session.upload_progress.enabled=0
in php.ini or the PHP-FPM pool until then. The script checks whether that
has been done.
It never modifies or deletes anything. It lists, verifies and reports. What to do with the findings is up to you.
| Area | Check |
|---|---|
| PHP | Installed CLI version vs. the fixed releases (8.2.35, 8.3.36, 8.4.27, 8.5.12). Flags end-of-life branches, which got no release from php.net; see Older PHP branches. |
| Upload progress | Whether session.upload_progress.enabled=0, the CVE-2026-115560 mitigation, is set where it counts: PHP-FPM's own view (php-fpm -i), every readable php.ini, conf.d, FPM pool and Apache config. Site-level .user.ini and .htaccess are reported but not credited, because php.net says they don't work for this setting. |
| Core integrity | wp core verify-checksums against wordpress.org |
| Plugin integrity | wp plugin verify-checksums --all, and lists premium/custom plugins it can't verify |
| Uploads | Any PHP-executable file under wp-content/uploads (ignores "Silence is golden" stubs) |
| Recent changes | PHP and config files modified since the window start (default 2026-10-17). WordPress translation packs (wp-content/languages/**/*.l10n.php) are counted separately once they pass a strict shape test; one that fails it is flagged. |
| Prepend hooks | auto_prepend_file / auto_append_file in .user.ini, .htaccess, php.ini |
| Must-use plugins | Contents of wp-content/mu-plugins (runs on every request, hidden from the normal plugin list) |
| Code patterns | Classic web-shell signatures (eval(base64_decode(...)), $_POST[...](), etc.) in wp-content |
| Users | All administrators, and any account registered inside the window |
| Database | siteurl/home, options and posts containing injected scripts or obfuscated code |
| Cron | WP-Cron scheduled events |
| Logs | Access logs: 5xx responses per day for the window vs. a baseline of up to 7 days before it, plus the URLs returning 5xx in the window. Error and PHP-FPM logs: worker crashes (segfault/bus error). |
| Server | The site user's crontab and SSH authorized_keys |
| Docker | Every running container with a php binary: the PHP version inside it, and whether the container still runs an older build than the image on disk (pulled but not recreated) |
WP-CLI is run with --skip-plugins --skip-themes so a backdoored plugin or
theme can't execute while the check runs.
On each machine being checked:
bash 3.2+, find, grep and sort -V (standard on Linux hosts; also works from a stock macOS workstation)php on the path, for the version checkFor remote sites: SSH key access from the machine you run the script on. The script runs itself over SSH; nothing needs to be installed on the remote host beyond the above.
curl -O https://raw.githubusercontent.com/brentmartinmiller/php2shell-check/main/php2shell-check.sh
chmod +x php2shell-check.sh
# One site
./php2shell-check.sh /var/www/example.com
less php2shell-reports/*.md
# Every site on the box
printf '%s\n' /var/www/*/public_html > sites.txt
./php2shell-check.sh sites.txt
cat php2shell-reports/SUMMARY.md
Run it as the user that owns the site files where you can. It works as root
(WP-CLI gets --allow-root), but the crontab and SSH-key checks then inspect
root's instead of the web user's.
git clone https://github.com/brentmartinmiller/php2shell-check.git
cd php2shell-check
cp sites.txt.example sites.txt # edit: one site per line
./php2shell-check.sh sites.txt
For each user@host:path entry the script SSHes in, runs itself there, and
brings the report back to php2shell-reports/ on your machine. Nothing is
installed on the remote host. SSH runs with BatchMode=yes, so key-based
access that works without a password prompt is required.
Most shared hosts (Hostinger, SiteGround, Bluehost and the like) offer SSH but
often don't have wp on the path. Check with which wp. If it's missing,
download WP-CLI once into your home directory and point the script at it:
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
WP="php $HOME/wp-cli.phar" ./php2shell-check.sh domains/example.com/public_html
Without WP-CLI the file-system checks still run and the report notes what was skipped. Either way, ask the host directly when they rolled out the patched PHP, since that's the one thing an account-level check can't see.
sites.txt has one site per line, either a local path or user@host:path.
Relative remote paths are relative to the SSH user's home directory. Lines
starting with # are ignored. See sites.txt.example. The file is in
.gitignore so client hostnames don't end up in a commit.
Non-standard SSH ports, keys and jump hosts belong in ~/.ssh/config:
Host client-a
HostName 203.0.113.10
Port 2222
User deploy
IdentityFile ~/.ssh/client-a
Then list the site as client-a:/var/www/html.
php -v on a Docker host tells you nothing about the containers, and pulling
a fixed image patches nothing until the container is recreated from it. One
team's site sat on 8.3.35 for three days after the pull for exactly that
reason.
When the script runs on a host where docker ps works, the report gets a
Docker containers section listing every running container that has a
php binary, the PHP version inside it, and whether the container is stale
(a newer build of its image is on disk but the container was never
recreated). Recreate with docker compose up -d, or stop, remove and run the
container again.
To run the full check inside a container, list it as a target:
docker:wordpress-php-1:/var/www/html
The script is piped into docker exec and runs there; nothing is installed
in the container. It needs bash inside the container. The official
php:* and wordpress:* Debian-based images have it; Alpine-based ones
(-alpine) don't, so either docker exec NAME apk add bash first or settle
for docker exec NAME php -v. The database is usually in a separate
container, so the user, options and cron checks are skipped unless the PHP
container can reach it.
Set these as environment variables: