Security Guides

What to check after an emergency PHP update: web PHP vs command-line PHP, set -e and diff exit codes, and an intrusion re-check

After an urgent PHP patch, web PHP and command-line (CLI) PHP can differ. How to check each, set -e and diff exit codes, and a post-patch intrusion check.

Published 2026-11-01 Updated 2026-11-01 Last verified 2026-11-01 13 min read
On this page

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.

  1. After the announcement

    Switch to the maintenance page and wait for the fixed version
  2. After the release

    Swap in the fixed version and lift the maintenance page
  3. Post-update check

    The web side was updated, but the command-line PHP used by cron still pointed to an old version, so it was repointed
  4. Same 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 rebuilt
  5. Afterwards

    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

On the same server, the PHP the web uses and the PHP the command line uses can be separate programs on separate versions. After patching, check both.

Check web and CLI separately

1

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.

2

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.

3

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.

4

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).

5

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"

CommandMeaning of 1Meaning of 2Source
diffDifferences foundTroubleGNU diffutils: diff
cmpDifferences foundTroubleGNU diffutils: cmp
grepNo lines selected (no match)An error occurredGNU 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 b under set -e (stops just because there are differences)
  • diff a b || true (swallows both "differences" and a real error (2))
  • Using || true on grep so 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 ;;
esac

set -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.

ItemWhat to look forExample command
Login keysPublic keys you do not recall registeringOpen authorized_keys
UsersUnknown users, changes in admin groupsgetent passwd, getent group sudo
Modified package-owned filesFiles a package installed that have changed since it shippeddebsums -s (Debian family), rpm -Va (RHEL family)
Executables owned by no packageNew executables under /usr/local, /opt or on the PATH that no package ownsdpkg -S <file> (Debian family) or rpm -qf <file> (RHEL family); look for files with no owning package
SUIDNew files that run with their owner's privilegesfind / -xdev -perm -4000 -type f
cron and systemdUnknown scheduled jobs and timerscrontab -l, /etc/crontab, /etc/cron.d, systemctl list-timers
/tmpExecutable files placed therefind /tmp /var/tmp -type f -perm /111
Outbound connectionsConnections to destinations you do not recognizess -tunp
Inside containersRunning processes and files written after startdocker 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.

1

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.

2

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.

3

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.

4

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

  1. If you can log in to the server, run php -v and which php, and write the full path to PHP in your cron jobs
  2. Check the web PHP version from the web side, such as Site Health (and delete any phpinfo() page right after use)
  3. Find places in set -e scripts that call diff, grep or cmp, and decide how exit status 1 is handled
  4. Keep a copy of what you published outside the server, and compare it after updates
  5. Make the intrusion checklist identical on every server and run through it item by item

Sources

FAQ

QCan PHP still be on an old version after I updated it?
A

Yes. PHP that handles web requests (PHP-FPM or an Apache module) and the command-line PHP (CLI) used from a shell or cron can be separate programs with different versions. php.net calls the way PHP runs the SAPI, and says the CLI SAPI differs from the others in many ways. The web side can be patched while the PHP that cron uses is still old.

QHow do I check which PHP version is actually running?
A

For the command line, run php -v. For the web, check from the web side; on WordPress that is Site Health in the admin screen. If you use a phpinfo() page, delete it as soon as you are done. which php shows which executable is called, and on Debian-style systems with several versions, update-alternatives --display php shows where the php command points. In cron jobs, writing the full path to PHP removes any doubt about which one runs.

QWhy does a shell script with set -e stop when I use diff?
A

diff returns exit status 1 when it finds differences. The GNU manual defines 0 as no differences, 1 as differences, and 2 as trouble. set -e (errexit) stops the script on a non-zero status, so merely finding a difference stops it. Put the command in an if condition, or capture the exit status in a variable and handle 0, 1 and 2 separately. grep (1 means no match) and cmp (1 means different) behave the same way.

QHow do I decide whether I was breached when a log entry looks suspicious?
A

The log alone cannot decide it. A log records an attempt that arrived, not necessarily a success. Compare your live public files with the copy you kept at deploy time, and check that they match and that no extra PHP files exist. A match lets you say that no file tampering was found. It is not proof of safety, so combine it with the other checks.