Audience: people who run PHP sites and are applying an emergency PHP update, or have just done so and worry about gaps.
In October 2026, fixed versions were released for two critical PHP vulnerabilities (CVE-2026-109618 and CVE-2026-109619). A site this site's operator runs was updated that same night. This page turns what happened there, two first assumptions that turned out wrong and the intrusion re-check applied to every server afterwards, into general steps. The vulnerabilities and the fixed versions are covered in Two critical PHP vulnerabilities and the emergency fixed releases (October 2026).
What happened (at a site this site's operator runs)
The site uses PHP in more than one environment. Because the PHP team had announced when the fixed versions would be released, the operator switched to a maintenance page before the release and stayed offline until the fixed version was out. After it was out, they swapped it in and lifted the maintenance page.
After the announcement
Switch to the maintenance page and wait for the fixed versionAfter the release
Swap in the fixed version and lift the maintenance pagePost-update check
The web side was updated, but the command-line PHP used by cron still pointed to an old version, so it was repointedSame night
A temporary script that lifted maintenance mode stopped before writing anything. Cause: set -e combined with diff. No harm was done, and the script was rebuiltAfterwards
The same intrusion checklist was applied to every server, and one suspicious request that had arrived before the update was checked by comparing files
The US CISA later added both CVEs to its catalog of vulnerabilities known to be exploited (KEV) (CISA announcement). That supports the decision to patch in a hurry, but a patch only closes the door. Checking that nobody had already come in is a separate job.
Assumption 1: "The web PHP is updated, so we're done"
The first assumption was that if the site runs on the new version, all PHP on that server is updated. In fact the command-line PHP that cron calls still pointed to an old version.
PHP can be a different program depending on how it runs (the SAPI, Server API). php.net notes that the CLI SAPI differs from the other SAPIs in many ways (PHP: Using PHP from the command line). With several versions installed on one machine, the web can run a new version while the command line runs an old one.
Web requests → PHP-FPM / Apache module
Runs the updated version (visible in Site Health and similar screens)
cron and shell scripts → command-line PHP
Still points to the old version, which a web-side check cannot show
Check web and CLI separately
Check the command-line PHP version
php -v shows the version of the PHP that this shell calls. php.net says php -v also tells you whether PHP is the CGI or CLI build. which php shows the location of the executable that is actually called.
On Debian-style systems with several versions, check where the command points
update-alternatives --display php shows which version the php command points to (update-alternatives manual). --config changes it, but that is a server configuration change, so confirm the procedure and how to undo it first.
Write the full path to PHP in cron jobs
cron runs jobs in its own environment, separate from a login shell (crontab(5)). If you write only php, which PHP gets picked depends on that environment. This site's advice: write the full path to the version you patched, so the post-update check has one answer.
Check the web PHP version from the web side
On WordPress, Site Health in the admin screen shows it. In code, the constants PHP_VERSION (version) and PHP_SAPI (how PHP runs: cli for the CLI, fpm-fcgi for PHP-FPM, apache2handler for the Apache module) are available (php_sapi_name).
Delete any phpinfo() page right after checking
A public file that calls phpinfo() shows the version, but it also exposes configuration and internal server details. Delete it on the spot when you are done. Not leaving it up is the condition for using this method.
Assumption 2: "diff either succeeds or fails"
The temporary script that lifts maintenance mode was written to look at the difference between the current and new contents with diff before writing anything. It had set -e turned on.
diff returns exit status 1 when it finds differences. In the GNU manual, 0 means no differences, 1 means differences were found, and 2 means trouble (GNU diffutils: diff). Finding differences was exactly what the script expected. But set -e (errexit) stops the shell on a non-zero status, except in places such as conditions. The script stopped because there were differences, and never reached the write.
So the step that lifts maintenance mode stopped without writing anything, and there was no harm. It failed on the safe side, but the reverse combination, not stopping where it should, would be dangerous. The script was rebuilt.
Commands where exit status 1 is not an "error"
| Command | Meaning of 1 | Meaning of 2 | Source |
|---|---|---|---|
diff | Differences found | Trouble | GNU diffutils: diff |
cmp | Differences found | Trouble | GNU diffutils: cmp |
grep | No lines selected (no match) | An error occurred | GNU grep |
Safe patterns
The GNU Bash manual says that under set -e the shell does not exit when a command returns non-zero as part of an if test, in an && or || list other than the last command, or when its status is inverted with ! (GNU Bash: The Set Builtin). Use that.
Stops, or hides a real failure
- Writing a bare
diff a bunderset -e(stops just because there are differences) diff a b || true(swallows both "differences" and a real error (2))- Using
|| trueongrepso that "no match" and "could not read the file" look the same
Handle the exit codes separately
- Capture the exit status in a variable and branch on 0, 1 and 2 (the recommended pattern)
- If you only need to know whether there are differences,
if diff -q a b; then …works, but note that 2 (an error) lands on the same side as "differences" - If you use
|| true, first decide whether missing a 2 (a real error) is acceptable
Here is an example that separates 0, 1 and 2. A failure on the left side of || is exempt from errexit, so the script does not stop and rc holds the exit status.
#!/usr/bin/env bash
set -euo pipefail
rc=0
diff -rq deployed/ live/ || rc=$?
case "$rc" in
0) echo "no differences" ;;
1) echo "differences: review before continuing" ;;
*) echo "diff failed" >&2; exit 2 ;;
esacset -e is not a complete safety net. It has many exceptions that can hide mistakes, so for important branches it is easier to read the exit status yourself and decide (this site's advice).
After patching: run the same checks on every server
Patching closes the entry point. It does not tell you whether someone got in before the update. For KEV vulnerabilities, CISA asks for a check for intrusion before updating (see "Confirmed exploitation and addition to CISA's KEV" in the PHP article). To keep the checks from varying from server to server, the operator applied the same checklist to every server.
| Item | What to look for | Example command |
|---|---|---|
| Login keys | Public keys you do not recall registering | Open authorized_keys |
| Users | Unknown users, changes in admin groups | getent passwd, getent group sudo |
| Modified package-owned files | Files a package installed that have changed since it shipped | debsums -s (Debian family), rpm -Va (RHEL family) |
| Executables owned by no package | New executables under /usr/local, /opt or on the PATH that no package owns | dpkg -S <file> (Debian family) or rpm -qf <file> (RHEL family); look for files with no owning package |
| SUID | New files that run with their owner's privileges | find / -xdev -perm -4000 -type f |
| cron and systemd | Unknown scheduled jobs and timers | crontab -l, /etc/crontab, /etc/cron.d, systemctl list-timers |
| /tmp | Executable files placed there | find /tmp /var/tmp -type f -perm /111 |
| Outbound connections | Connections to destinations you do not recognize | ss -tunp |
| Inside containers | Running processes and files written after start | docker top, docker diff |
crontab -l shows only the current user's jobs, so check /etc/crontab, /etc/cron.d and the per-user cron spools separately. ss -tunp needs root to show the process behind every connection. dpkg -S and rpm -qf report which package owns a file; "no owning package" may still be a file you placed yourself, so check it against what you remember installing.
find -perm -4000 matches files that have all the given bits set (find(1)). docker diff lists files changed since the container was created (A: added, C: changed, D: deleted), and docker top displays the running processes (docker container diff, docker container top). Containers are normally rebuilt rather than modified, so treat an executable shown by docker diff as unexpected and investigate it (this site's advice).
How to read each item, and the steps for shared hosting, WordPress and a VPS, are in How to check if your website or server was hacked.
Settle a suspicious log entry by comparing files
During this check, the team found that before the update a POST had reached the contact form from a Tor exit node (an exit point of a network that hides the sender). A log only shows that the request arrived. It does not show whether anything was written as a result.
So they compared the live public files with the copy kept at deploy time. They matched exactly, and there were no extra PHP files. This check found no sign that any file had been altered.
Keep a copy of what you deployed, outside the server
If the only thing to compare against lives on the server, a simultaneous change would go unnoticed. At every deploy, keep the published state outside the server.
Compare it with the live files
Use diff -rq to find files that differ, or compare lists of per-file hashes (sha256sum). If anything differs, check whether it is a change you intended.
Look for added files too
Even when contents match, a newly placed PHP file is not in the original copy, so it must be looked for separately. diff -rq also reports files that exist on only one side.
A match is not proof of safety
This method only shows that the files you compared are unchanged. It does not see the database, upload folders, or changes outside the server. Judge it together with the other checks (this site's advice).
This site's view: fix the checklist before the emergency night
On a rushed night, what you looked at on which server tends to vary. If the list of items is decided in advance, every server gets the same checks and the gaps are visible. The old command-line PHP was found this time because the post-update check did not end at the web.
What to do today
- If you can log in to the server, run
php -vandwhich php, and write the full path to PHP in your cron jobs - Check the web PHP version from the web side, such as Site Health (and delete any
phpinfo()page right after use) - Find places in
set -escripts that calldiff,greporcmp, and decide how exit status 1 is handled - Keep a copy of what you published outside the server, and compare it after updates
- Make the intrusion checklist identical on every server and run through it item by item
Sources
- PHP: Using PHP from the command line
- PHP: php_sapi_name
- GNU Bash Reference Manual: The Set Builtin
- GNU diffutils: diff, cmp, GNU grep
- update-alternatives(1), crontab(5), find(1)
- docker container diff, docker container top
- CISA: adds two vulnerabilities to the Known Exploited Vulnerabilities Catalog
Read next
- The vulnerabilities and the fixed versions: Two critical PHP vulnerabilities and the emergency fixed releases (October 2026)
- Checking for intrusion: How to check if your website or server was hacked
- Preparation: Backup and recovery essentials / Security inventory checklist
- Terms: What is RCE (remote code execution)? / What is an IOC (indicator of compromise)?