October 24, 2026
Is it patched? What php -v can't tell you about PHP2Shell
PHP2Shell on real servers: telling a patched PHP from a patched-looking one PHP2Shell is the name given to two critical PHP bugs, CVE-2026-109618 and CVE-2026-109619, fixed on 21 October 2026. Patching them sounds like one command, but on a mixed fleet the usual check, php -v , answers a different question from the one that matters. This post walks through the checks that did answer it, with real command output from Ubuntu 24.04 VPS hosts running nginx and PHP-FPM, a Docker-based mail stack and shared hosting accounts. Hostnames, IP addresses, usernames, domains and process IDs are redacted, and commands run by a normal user start with $ . A host is patched only when four separate facts agree: the version or package revision, the installed files, the start time of the workers, and the binary inside any container. The two bugs, briefly PHP2Shell is two independent memory-safety bugs in the PHP engine, both scored CVSS v4.0 9.2 and fixed on 21 October 2026 in PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12. Both share the vector AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H: reachable over the network, high attack complexity, no privileges, no user interaction. The figures below come from Abstract Security's flash report , which summarizes the PHP advisories GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp . CVE-2026-109618: out-of-memory corruption When memory runs out during request startup, PHP can abandon the request while internal request data is only partly initialized, and request shutdown then treats that data as valid. The public exploit, nicknamed rawkey, sends a urlencoded POST body larger than 1 MB with an oversized field name, which exhausts memory while PHP builds $_POST . PHP confirmed full remote code execution over HTTP against Apache mod_php with default input and memory limits. Under PHP-FPM the child process is killed when request startup fails, and PHP reported no complete FPM exploit for this CVE. CVE-2026-109619: stale global state PHP can keep request-owned state after the memory behind it has been freed, and a later request on the same worker then consumes it. The result is a heap use-after-free with memory disclosure and code execution. The demonstrated chain goes through the session.upload_progress callback, which runs on any POST endpoint before application code, using a body of about 450 bytes. Request-size limits at a proxy do not stop it. Why logs show little Both bugs trigger below the application layer, so application logs may show almost nothing. The one documented log signature is a PHP fatal error that reports a failed allocation with in Unknown on line 0 , which means the allocation failed outside normal script execution. Because the state lives in long-lived worker processes, a patched binary with old workers still serving traffic stays vulnerable until those workers restart. Affected and fixed versions Branch Affected Fixed 8.5 below 8.5.12 8.5.12 8.4 below 8.4.27 8.4.27 8.3 below 8.3.36 8.3.36 8.2 below 8.2.35 8.2.35 8.1 and earlier end of life no standard release The vulnerable code is more than a decade old and affects releases back to PHP 5.4. For end-of-life branches the PHP security team published minimal source backports for 7.4, 8.0 and 8.1, and distributions received the patches before publication. Disclosure timeline On 20 October information about the two bugs leaked, including screenshots and video of an exploit tool called PHP2Shell. PHP then moved its release one day earlier, to 21 October at 14:00 UTC, and security teams reported attacks starting the same day. Abstract advises counting the exposure window from 20 October, not from the date a host was patched. Mitigations that are not fixes PHP states that updating is the only complete remedy. For hosts that cannot update immediately, the listed settings are session.upload_progress.enabled = 0 , fatal_error_backtraces = 0 (PHP 8.5 only) and memory_limit = -1 , which removes the limit the out-of-memory path depends on but lets one request use all available memory. PHP recommends keeping upload progress disabled after patching if the feature is unused. Four kinds of PHP on one fleet A single php -v hides how many PHP runtimes a fleet has. The hosts in this post ran four kinds, and each is patched by a different party and proven by a different command: Ubuntu's own packages, packages from a third-party repository, an image inside a container, and PHP run by a hosting provider. where PHP ran · 4 kinds, 3 on VPS hosts On a VPS, apt reaches the first two boxes; the container needs its stack’s updater, and the shared-hosting build is the provider’s to replace. Ubuntu archive builds: reading a backport Ubuntu 24.04 ships PHP 8.3.6 and fixes security bugs by patching that version and raising the package revision, so the upstream number never moves. php -v prints the same version string before and after the fix: $ php -v | head -1 PHP 8.3.6 (cli) (built: Oct 19 2026 15:16:58) (NTS) Two other facts identify the fix. Ubuntu's tracker lists 8.3.6-0ubuntu0.24.04.13 as the fixed revision for 24.04 ( CVE-2026-109618 , advisories USN-8948-1 and USN-8948-2), and the package changelog carries an entry for the patch: $ dpkg -l 'php*' | awk '/^ii/{print $2, $3}' | grep -E 'php[0-9.]+-(cli|fpm|common)' php8.3-cli 8.3.6-0ubuntu0.24.04.13 php8.3-common 8.3.6-0ubuntu0.24.04.13 php8.3-fpm 8.3.6-0ubuntu0.24.04.13 $ zgrep -m2 -E 'CVE-2026-10961[89]' /usr/share/doc/php8.3-common/changelog.Debian.gz - debian/patches/CVE-2026-109618_109619.patch: Avoid state corruption via - CVE-2026-109618 The upgrade itself shows the revision moving while the version stays put: Unpacking php8.3-fpm (8.3.6-0ubuntu0.24.04.13) over (8.3.6-0ubuntu0.24.04.12) ... apt changelog is the usual way to read a changelog, but it fetches from Ubuntu's changelog server, which answered with a 404 for the new revision: $ apt changelog php8.3-fpm | head -15 Err:1 https://changelogs.ubuntu.com php8.3 8.3.6-0ubuntu0.24.04.13 Changelog Changelog unavailable for php8.3=8.3.6-0ubuntu0.24.04.13 (404 Not Found [IP: <ip> 443]) The local file under /usr/share/doc needs no network, so the zgrep form is the more dependable check. The build date does not help. This fixed build reports 19 October, two days before the public release, which fits distributions receiving the patches ahead of publication. A rule such as "built after 21 October" would call a patched host unpatched. The tracker lists these fixed revisions for the Ubuntu releases that ship PHP: Ubuntu release Package Fixed in 24.04 noble php8.3 8.3.6-0ubuntu0.24.04.13 22.04 jammy php8.1 8.1.2-1ubuntu2.28 26.04 resolute php8.5 8.5.4-0ubuntu1.5 PPA builds: version compare, and an apt repository trap Packages from the Ondřej PPA carry the upstream number, so the check is direct: an installed 8.2.x must be at least 8.2.35, an 8.4.x at least 8.4.27, an 8.5.x at least 8.5.12. One apt run on a host with three branches shows how far behind it was; all three were below their fixed versions: Unpacking php8.2-fpm (8.2.35-1+ubuntu24.04.1+deb.sury.org+1) over (8.2.31-3+ubuntu24.04.1+deb.sury.org+1) ... Unpacking php8.4-cli (8.4.27-1+ubuntu24.04.1+deb.sury.org+1) over (8.4.22-1+ubuntu24.04.1+deb.sury.org+1) ... Unpacking php8.5-cli (8.5.12-1+ubuntu24.04.1+deb.sury.org+1) over (8.5.7-1+ubuntu24.04.1+deb.sury.org+1) ... Only one of those branches served the web. The other two were command-line installs: $ systemctl list-units 'php*-fpm*' --no-legend php8.2-fpm.service loaded active running The PHP 8.2 FastCGI Process Manager The audit script near the end of this post strips the suffix after the first - , + or ~ and compares the rest with dpkg --compare-versions . The repository trap apt can refuse a PPA update after the repository's metadata changes. On one host the first update stopped here: E: Repository 'https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble InRelease' changed its 'Label' value from 'Use packages.sury.org/php instead' to 'PPA for PHP' N: This must be accepted explicitly before updates for this repository can be applied. See apt-secure(8) manpage for details. Do you want to accept these changes and continue updating from this repository? [y/N] An interactive shell can answer y . A script cannot, and the note says the update does not apply until the change is accepted, which leaves the PHP packages where they were. apt-get update --allow-releaseinfo-change accepts the change up front. Upgrading only what is installed Passing a glob such as 'php*' to apt-get install --only-upgrade prints one "Note, selecting ... for glob" line for every matching package in the archive, hundreds of lines, even though --only-upgrade stops apt from installing them. Feeding it the installed packages from dpkg avoids that: $ sudo apt-get install --only-upgrade $(dpkg -l 'php*' | awk '/^ii/{print $2}') php-common is already the newest version (2:101~+ubuntu24.04.1+deb.sury.org+1). ... The following packages will be upgraded: php8.2-cli php8.2-common php8.2-curl php8.2-fpm php8.2-gd php8.2-mbstring php8.2-mysql php8.2-opcache php8.2-readline php8.2-xml php8.2-zip php8.5-cli php8.5-common php8.5-phpdbg php8.5-readline 15 upgraded, 0 newly installed, 0 to remove and 41 not upgraded. Restarts: proving the workers run the new code Both bugs involve state held by long-lived workers, so a patched binary with old workers still serving traffic stays vulnerable until the workers restart ( Abstract Security ). Whether the package upgrade restarts FPM varies. After one upgrade, needrestart listed many services for restart but no PHP unit: Restarting services... systemctl restart cron.service fail2ban.service mariadb.service nginx.service packagekit.service polkit.service rsyslog.service ssh.service systemd-journald.service ... On another host the restart came from a third-party agent's post-install script, not from PHP's own packaging: Setting up <agent>-protect-autodetect (5.2.73-master) ... Adding <agent> to PHP installations Detected PHP version 8.3 (/usr/lib/php/20230831) Restarting service php8.3-fpm.service Please restart your webserver for changes to take effect. Neither output is proof. Read the state back instead: $ sudo systemctl restart php8.2-fpm $ systemctl show php8.2-fpm -p ActiveEnterTimestamp ActiveEnterTimestamp=Fri 2026-10-23 10:39:25 UTC $ sudo ls -l /proc/$(systemctl show -p MainPID --value php8.2-fpm)/exe lrwxrwxrwx 1 root root 0 Oct 23 10:39 /proc/<pid>/exe -> /usr/sbin/php-fpm8.2 The start time must be later than the package installation. The /proc link is a second witness: when a process keeps running after its binary was replaced on disk, Linux marks the link target with (deleted) , so a clean path means the process runs the file now installed. No (deleted) suffix appeared in any output here; this half of the check rests on how /proc works, not on a failure that was observed. systemctl reload php8.2-fpm sends SIGUSR2, which replaces the workers without dropping requests in flight, while a restart interrupts them briefly. Either is fine as long as the start time is read back afterwards. CLI versus web: which PHP serves what A host can hold several PHP branches, and the command line and the web server can use different ones. On one host the site ran on 8.4 while php printed 8.5: $ php -v | head -1 PHP 8.5.12 (cli) (built: Oct 21 2026 14:37:30) (NTS) $ sudo update-alternatives --set php /usr/bin/php8.4 update-alternatives: using /usr/bin/php8.4 to provide /usr/bin/php (php) in manual mode $ php -v | head -1 PHP 8.4.27 (cli) (built: Oct 21 2026 14:35:21) (NTS) Both builds were patched. The problem was that cron jobs and WP-CLI would run a different branch from the site. The nginx configuration says which FPM socket serves which site. The first grep attempt printed nothing, because -r skips symlinks and every file in sites-enabled is one; -R follows them: $ sudo grep -RhE '^\s*(server_name|root|fastcgi_pass)\s' /etc/nginx/sites-enabled/ /etc/nginx/conf.d/ 2>/dev/null server_name example.com; server_name example.com; server_name wp.example.com; server_name wp.example.com; root /var/www/wordpress; fastcgi_pass unix:/run/php/php8.2-fpm.sock; The first domain has no root and no fastcgi_pass , so it proxies to something else. Only wp.example.com reaches PHP, through the 8.2 socket, which makes that one socket the whole PHP exposure of the host. Moving a site to a newer branch starts with confirming that the two branches load the same extensions and use equivalent pools, then changing the socket path: $ diff <(php8.2 -m | sort) <(php8.4 -m | sort) $ diff /etc/php/8.2/fpm/pool.d/www.conf /etc/php/8.4/fpm/pool.d/www.conf 41c41 < listen = /run/php/php8.2-fpm.sock --- > listen = /run/php/php8.4-fpm.sock $ sudo sed -i 's/php8\.2-fpm\.sock/php8.4-fpm.sock/g' /etc/nginx/sites-available/wordpress.conf $ sudo nginx -t && sudo systemctl reload nginx nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful The extension diff printed nothing, so both branches load the same modules; the pool diff is trimmed here to its one meaningful line, the rest being comment wording. Afterwards the new FPM's own status line shows whether it is serving traffic: $ systemctl status php8.4-fpm --no-pager | grep Requests Status: "Processes active: 0, idle: 2, Requests: 3, slow: 0, Traffic: 0.30req/sec" A 502 after a PHP change: reading nginx upstream errors Right after a socket change like the one above, a site behind a CDN returned a 502. The PHP change was the obvious suspect, and the nginx error log pointed somewhere else: 2026/10/23 04:17:44 [error] <pid>#<pid>: *12274 connect() failed (111: Connection refused) while connecting to upstream, client: <ip>, server: example.com, request: "GET /contact-us HTTP/1.1", upstream: "http://[::1]:3000/contact-us", host: "example.com" The upstream http://[::1]:3000 is a Node app, not the PHP socket, and the log held the same error from 03:52 onward, before any PHP change. The new FPM had served nothing: $ systemctl status php8.4-fpm --no-pager | grep Status Status: "Processes active: 0, idle: 2, Requests: 0, slow: 0, Traffic: 0.00req/sec" The Node app was managed by pm2, and pm2 itself was not running: $ pm2 list [PM2] Spawning PM2 daemon with pm2_home=/home/<user>/.pm2 [PM2] PM2 Successfully daemonized ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘ "Spawning PM2 daemon" means there was no daemon to talk to, so there was no app either. Starting the app, saving the process list and registering a startup unit takes three commands: $ pm2 start "npm start" --name frontend $ pm2 save $ pm2 startup [PM2] Init System found: systemd [PM2] To setup the Startup Script, copy/paste the following command: sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u <user> --hp /home/<user> Running the printed command writes /etc/systemd/system/pm2-<user>.service and enables it. After a later reboot the app came back by itself: $ pm2 list │ 0 │ frontend │ fork │ 0 │ online │ 0% │ 51.4mb │ The order of reading matters: take the upstream address from the error log first, then check whether the component you changed has handled any requests at all. A fresh FPM with Requests: 0 cannot be the cause of a failure. PHP inside containers apt on the host cannot see inside an image. On a host running a Docker-based mail stack, every PHP package on the host was current and the stack's own PHP-FPM container was not. The image tag in docker ps gave it away, and the CREATED and STATUS columns show a side effect that comes up again below: $ docker ps CONTAINER ID IMAGE ... CREATED STATUS ... NAMES de5dff4bac6d ghcr.io/mailcow/phpfpm:8.2.29-3 ... 4 weeks ago Up 3 minutes ... mailcowdockerized-php-fpm-mailcow-1 PHP 8.2.29 is below the fixed 8.2.35. The compose file pins the tag, so pulling again returns the same image: $ grep -n 'image:.*phpfpm' docker-compose.yml 120: image: ghcr.io/mailcow/phpfpm:8.2.29-3 The stack ships its own updater, and it needs root. Run as a normal user it stopped at once, because earlier root runs had left root-owned files in the git directory: $ ./update.sh Fetching latest _modules from origin/master… ... error: cannot update the ref 'refs/remotes/origin/master': unable to append to '.git/logs/refs/remotes/origin/master': Permission denied ! ca07d8d3..55982ac7 master -> origin/master (unable to update local ref) You need to be root Run as root, it prefetches the new images, recreates every container and offers to delete the replaced images: $ sudo ./update.sh ... 8.2.35-1: Pulling from mailcow/phpfpm ... Status: Downloaded newer image for ghcr.io/mailcow/phpfpm:8.2.35-1 ... Stopping mailcow... [+] down 19/19 ... [+] up 19/19 ... The following unused mailcow images were found: ... ghcr.io/mailcow/phpfpm:8.2.29-3 (4161ede4b8b1) The proof is the binary inside the container, not the tag: $ docker compose exec php-fpm-mailcow php -v PHP 8.2.35 (cli) (built: Oct 21 2026 16:05:34) (NTS) Copyright (c) The PHP Group Zend Engine v4.2.35, Copyright (c) Zend Technologies with Zend OPcache v8.2.35, Copyright (c), by Zend Technologies The stack's watchdog adds a second signal. After the update every health check, PHP-FPM included, reported full health with a flat trend: $ docker compose logs --tail 30 watchdog-mailcow watchdog-mailcow-1 | Fri Oct 23 11:19:34 +0545 2026 PHP-FPM health level: 100% (5/5), health trend: 0 watchdog-mailcow-1 | Fri Oct 23 11:19:21 +0545 2026 Nginx health level: 100% (5/5), health trend: 0 Why the binary and not the tag A tag records what a build intended. Mailcow's phpfpm:8.2.35-1 held PHP 8.2.34 for 103 minutes on 21 October, from about 15:42 to 17:25 UTC, because the image was built on a base tag that still pointed at the older release. The issue that reported it shows PHP 8.2.34 (cli) (built: Sep 24 2026 19:18:57) for that build and the 16:05:34 timestamp seen above for the corrected one. A pipeline can catch this kind of mismatch with a one-line assertion: test "$(docker run --rm --entrypoint php <image> -v | awk 'NR==1{print $2}')" = "8.2.35" && echo tag-matches-binary A side effect of the host upgrade The same apt upgrade that patched the host also upgraded the Docker packages, which restarts every container. That is why the docker ps row above shows Up 3 minutes for a container created four weeks earlier. On a mail server it is a short delivery interruption that sending servers retry through. Whether containers return by themselves after a restart or reboot depends on their restart policy: $ sudo docker inspect -f '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $(sudo docker ps -q) /backend unless-stopped /frontend unless-stopped /postgres unless-stopped This output comes from a different host, with the names redacted. A container whose policy is no stays down. Shared hosting: what a customer account can show A customer on shared hosting can read the account's PHP and its files, but cannot see or restart the server's workers. The command line shows this much: $ php -v | head -2 PHP 8.3.35 (cli) (built: Oct 21 2026 00:00:00) (NTS) Copyright (c) The PHP Group $ php -i | grep -i -E 'build date|server api|^PHP Version' PHP Version => 8.3.35 Build Date => Oct 21 2026 00:00:00 Server API => Command Line Interface PHP Version => 8.3.35 The upstream fix for the 8.3 branch is 8.3.36, so a version check calls this vulnerable. The provider's notice, updated 22 October at 07:20 UTC, says the PHP fix is installed on all its non-VPS hosting and is built into the version a site already runs, so the number may not change. A build dated 21 October fits that statement without proving it. Server API also shows this was the command-line binary, which can differ from the one that serves the sites; a temporary phpinfo() page would show the web build. The files in the account can be checked directly. Recent PHP changes come first; a plugin update shows up as a block of files with one timestamp: $ find ~/domains -name '*.php' -mtime -3 -ls | head -3 86688036 8 -rw-r--r-- 1 <user> <group> 748 Oct 22 05:40 .../public_html/wp-content/plugins/<plugin>/vendor/autoload.php 86688035 8 -rw-r--r-- 1 <user> <group> 326 Oct 22 05:40 .../plugins/<plugin>/vendor/autoload_packages.php 86688037 36 -rw-r--r-- 1 <user> <group> 29383 Oct 22 05:40 .../plugins/<plugin>/vendor/composer/autoload_classmap.php $ find ~/domains -name '*.php' -mtime -3 -not -path '*/plugins/<plugin>/*' -ls | head -40 $ Excluding that plugin leaves nothing. Upload directories are the next place to look, because they should hold almost no PHP: $ find ~/domains -path '*/wp-content/uploads/*' -name '*.php' -ls 374180999 0 -rw-r--r-- 1 <user> <group> 0 Sep 1 09:24 .../uploads/forminator/<id>/css/index.php 363320622 8 -rw-r--r-- 1 <user> <group> 99 Sep 1 11:02 .../uploads/wpforms/cache/index.php 75727074 12 -rw-r--r-- 1 <user> <group> 4159 Oct 9 06:06 .../uploads/smush/smush-log.php 75735574 8 -rw-r--r-- 1 <user> <group> 27 Apr 15 2026 .../uploads/smush/index.php Four of the seven files are shown. The non-empty ones are small enough to read in full: $ for f in $(find ~/domains -path '*/wp-content/uploads/*' -name '*.php' -size +0); do echo "== $f"; head -c 200 "$f"; echo; done == .../uploads/wpforms/cache/index.php <?php header( $_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found' ); header( 'Status: 404 Not Found' ); == .../uploads/smush/smush-log.php <?php die(); ?> [2026-10-07T07:38:48+00:00] Notice: File saved successfully in 0 seconds [2026-10-07T07:38:52+00:00] Notice: File saved successfully in 0.27 seconds [2026-10-07T07:38:52+00:00] Notice: == .../uploads/smush/index.php <?php // Silence is golden. These are the usual placeholders. Empty index.php files stop directory listing, one stub sends a 404 header, and the log file starts with die(); , so requesting it executes nothing. A search for dangerous function calls over the same directories came back empty: $ grep -rlE 'eval\(|base64_decode|shell_exec|passthru|system\(' ~/domains/*/public_html/wp-content/uploads --include='*.php' 2>/dev/null $ Why not run a scanner Searchlight Cyber's scanner works in stages: it first tries to produce a PHP memory-exhaustion error, then looks for an empty HTTP 500 that indicates an FPM worker failure, and only then falls back to the advertised version. Its authors say that detecting the bug reliably requires triggering the out-of-memory condition, that doing so is "fundamentally risky", and that a crashed worker is normally replaced quickly but calls for care ( Searchlight Cyber ). They also report that most other scanners rely on version checks and do not model mod_php and FPM separately. Both options fail on shared hosting. A version check misreads a vendor rebuild, as the 8.3.35 output above shows. A trigger-based scan crashes workers on a server that other customers share, and it may conflict with the provider's acceptable-use terms. What stays unverified is the server itself, and a clean account says nothing about its other tenants. The remaining step is written confirmation from the provider, naming the server, that the fix is applied. Drift: stale PPAs, held kernels and a firewall test that tests nothing Three problems unrelated to the PHP bugs showed up on the same hosts. Each could hide the next one. PPA packages fall outside automatic updates Ubuntu's default unattended-upgrades configuration covers the distribution's own security pockets, not third-party PPAs. One host showed the effect: its upgrade run listed 8.2.31, 8.4.22 and 8.5.7 as the previous versions, so all three branches were below their fixed releases, and the login banner put the last interactive login in June: Last login: Mon Jun 15 06:36:55 2026 from <ip> The PPA can be added as an allowed origin in /etc/apt/apt.conf.d/50unattended-upgrades , after confirming the origin name with apt-cache policy and testing on one host: Unattended-Upgrade::Allowed-Origins { "LP-PPA-ondrej-php:${distro_codename}"; }; An automatic upgrade still leaves the restart question from the section on workers, so the FPM units need a reload afterwards. Held kernel packages On another host apt full-upgrade upgraded nothing and kept the kernel packages back: $ apt-mark showhold cloud-init linux-generic linux-image-generic linux-image-virtual linux-virtual $ apt list --upgradable 2>/dev/null | grep linux linux-headers-generic/noble-updates,noble-security 6.8.0-147.147 amd64 [upgradable from: 6.8.0-90.91] linux-headers-virtual/noble-updates,noble-security 6.8.0-147.147 amd64 [upgradable from: 6.8.0-90.91] linux-image-virtual/noble-updates,noble-security 6.8.0-147.147 amd64 [upgradable from: 6.8.0-90.91] linux-virtual/noble-updates,noble-security 6.8.0-147.147 amd64 [upgradable from: 6.8.0-90.91] The host ran 6.8.0-90 while the others ran 6.8.0-147. After removing the holds, full-upgrade installed the new kernel and reported that it was waiting for a reboot: $ sudo apt-mark unhold linux-generic linux-image-generic linux-image-virtual linux-virtual $ sudo apt full-upgrade ... Pending kernel upgrade! Running kernel version: 6.8.0-90-generic Diagnostics: The currently running kernel version is not the expected kernel version 6.8.0-147-generic. Before a reboot, check that everything will return by itself: $ systemctl is-enabled nginx php8.4-fpm docker pm2-<user> enabled enabled enabled enabled After the reboot: $ uname -r 6.8.0-147-generic $ sudo docker ps --format '{{.Names}}\t{{.Status}}' backend Up 41 seconds (healthy) frontend Up 41 seconds (healthy) postgres Up 41 seconds (healthy) A firewall test that tests nothing The Node app from the 502 section listens on every interface, and a request from the server to its own public address succeeded: $ sudo ss -ltn | grep :3000 LISTEN 0 511 *:3000 *:* $ curl -m 5 http://<public-ip>:3000 <!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/>... The request succeeded because traffic from a host to its own address takes the loopback path and never meets the firewall. What the firewall allows from outside is a separate question: $ sudo ufw status Status: active To Action From -- ------ ---- 22/tcp ALLOW Anywhere 80/tcp ALLOW Anywhere 443/tcp ALLOW Anywhere The list shows allow rules only. The default policy for incoming traffic appears in ufw status verbose and was not captured here. A meaningful test is a request from another network, which should time out. Hunting for exploitation Both bugs fire below the application layer, and a successful exploit runs shell commands as the web user without crashing the worker, so application logs may show nothing ( Abstract Security ). The one documented log signature is a PHP fatal error about a failed allocation reported as in Unknown on line 0 . The window to cover starts at the leak on 20 October, not at the patch date. These commands cover logs, temporary directories, persistence and the web root; a host with no hits prints nothing for each: # PHP fatal errors matching the documented signature sudo grep -rE 'tried to allocate.*in Unknown on line 0' /var/log/nginx /var/log/php* 2>/dev/null | head # Files owned by the web user outside the web root sudo find /tmp /dev/shm /var/tmp -user www-data -ls 2>/dev/null # Persistence under the web user sudo crontab -l -u www-data 2>/dev/null sudo ls -la /var/www/.ssh 2>/dev/null # Recent PHP files in the web root, covering the whole window sudo find /var/www -name '*.php' -mtime -4 -ls 2>/dev/null | head -40 The file checks were run on one VPS running WordPress. The first command printed nothing, and the uploads search printed placeholders only: $ sudo find /var/www -name '*.php' -mtime -3 -ls 2>/dev/null | head -40 $ sudo find /var/www/wordpress -path '*/wp-content/uploads/*' -name '*.php' -ls 2>/dev/null | head 824103 8 -rw-r--r-- 1 www-data www-data 99 Oct 18 2025 /var/www/wordpress/wp-content/uploads/wpforms/cache/index.php 819073 0 -rw-r--r-- 1 www-data www-data 0 Oct 17 2025 /var/www/wordpress/wp-content/uploads/forminator/<id>/index.php ... $ sudo grep -rlE 'eval\(|base64_decode|shell_exec|passthru|system\(' /var/www/wordpress/wp-content/uploads --include='*.php' 2>/dev/null $ An empty result needs checking too Because 2>/dev/null hides errors, an empty find is ambiguous: it can mean no hits or a path that does not exist. Confirm the directory first: $ ls -la /var/www 2>&1 | head total 16 drwxr-xr-x 4 root root 4096 Oct 6 2025 . drwxr-xr-x 14 root root 4096 Sep 28 2025 .. drwxr-xr-x 2 root root 4096 Feb 13 2026 html drwxr-xr-x 5 www-data www-data 4096 Oct 7 01:44 wordpress The -mtime -3 form covers 72 hours, which leaves the first hours of the window out, so -mtime -4 is the safer value. File times can also be set by anyone who has code execution, so none of these checks proves absence. An exposure factor worth reading nginx's default client_max_body_size is 1 MB, and Abstract puts the smallest field name that triggers the first bug at about 1 MiB. Sites that raised the limit for uploads, which WordPress sites often do, lose that margin, and the second bug needs only a 450-byte body. The effective value on a host comes from sudo nginx -T 2>/dev/null | grep client_max_body_size ; its output was not captured for the hosts in this post. An audit script that compares every package The first version of a fleet check took a branch's version from one package ( -fpm , falling back to -cli ) and read the changelog from /usr/share/doc/php<branch>-common . In Debian-style packaging the documentation directory of each sub-package links to the -common one, so a patched -common changelog could mark a branch as fixed while another package of that branch was older. On one host the script printed -> FIXED for a branch whose reported package version was 8.3.28, below the fixed 8.3.36. The corrected version compares every installed package of each branch against its own fixed version. It assumes the Debian and Ubuntu layout with one directory per branch under /etc/php : for dir in /etc/php/*/; do v=$(basename "$dir") case $v in 8.5) min=8.5.12;; 8.4) min=8.4.27;; 8.3) min=8.3.36;; 8.2) min=8.2.35;; *) min="";; esac for pkg in $(dpkg -l "php$v-*" | awk '/^ii/{print $2}' | grep -E "^php$v-(cli|fpm|common|cgi|opcache)$"); do ver=$(dpkg-query -W -f='${Version}' "$pkg"); up=${ver%%[-+~]*} case $ver in *deb.sury.org*) dpkg --compare-versions "$up" ge "$min" && st=fixed || st=VULNERABLE ;; *) st="distribution build: compare with the Ubuntu fixed revision" ;; esac echo "$pkg $ver $st" done done PPA builds are compared with the upstream fixed version. Ubuntu archive builds are compared with the revisions in the table in the backport section, because their upstream number never changes. The comparison logic is easy to test on its own, and the results below are what dpkg returned: $ c(){ dpkg --compare-versions "$1" ge "$2" && echo "$1 >= $2: yes" || echo "$1 >= $2: no"; } $ c 8.3.28 8.3.36; c 8.3.36 8.3.36; c 8.2.35 8.2.35; c 8.5.10 8.5.12 8.3.28 >= 8.3.36: no 8.3.36 >= 8.3.36: yes 8.2.35 >= 8.2.35: yes 8.5.10 >= 8.5.12: no $ c 8.3.6-0ubuntu0.24.04.13 8.3.6-0ubuntu0.24.04.13; c 8.3.6-0ubuntu0.24.04.12 8.3.6-0ubuntu0.24.04.13 8.3.6-0ubuntu0.24.04.13 >= 8.3.6-0ubuntu0.24.04.13: yes 8.3.6-0ubuntu0.24.04.12 >= 8.3.6-0ubuntu0.24.04.13: no Checklist Each check proves one thing and leaves another open, which is why a host needs several of them. Check Shows Does not show php -v , version and build date Which binary the CLI runs What the web workers run; whether a backport is present dpkg -l for every package of the branch Files on disk Processes in memory Changelog entry for the CVE patch A distribution backport is installed That the service restarted FPM ActiveEnterTimestamp after the upgrade The service restarted after installation Whether a container runs its own PHP /proc/<pid>/exe without (deleted) The running process uses the on-disk binary Anything about other copies of PHP php -v inside each container The container's own binary Anything about the host Provider's written statement The provider-side fix on shared hosting Independent verification In order: List every installed package of each branch with dpkg -l and compare each one, not only php -v . For Ubuntu archive builds, compare the revision and grep the changelog for the CVE patch. For PPA builds, compare the upstream version. Use --allow-releaseinfo-change in scripted PPA updates, and upgrade only the installed packages. Restart or reload each active FPM unit, then read ActiveEnterTimestamp and the /proc/<pid>/exe link. Map sites to FPM sockets with grep -R over sites-enabled , so you know which branch faces the internet. Run docker ps on every host and php -v inside every PHP image; compare the binary, not the tag. On a 502, read the upstream in the nginx error log before suspecting the change you just made. On shared hosting, read the build date and check the files, skip trigger-based scanners, and get the provider's confirmation in writing. Confirm a path exists before trusting an empty search, and use -mtime -4 to cover the window from 20 October. Put PPA hosts on an upgrade routine, resolve kernel holds, and test firewalls from another network. References Pages opened for this post: Abstract Security, PHP2Shell flash report , 21 October 2026 Ubuntu security tracker, CVE-2026-109618 , 21 October 2026 Mailcow issue 7538, phpfpm tag carried PHP 8.2.34 , 21 October 2026 Searchlight Cyber, detection mechanism for php2shell , 22 October 2026 Referenced but not opened separately: the PHP advisories GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp , as summarized by Abstract. The shared-hosting provider's notice was read as a search excerpt and is not linked, to keep the account anonymous.