How to Update PHP After PHP2Shell: A Practical Guide

Patch PHP2Shell on Debian and Ubuntu: find every PHP build, install the fixed package from its own source, restart the workers, mitigate where no fix exists and check for compromise.

Oleksii TurovskyiFull-Stack Developer, AI & Enterprise ArchitecturePublished 14 min read

If your servers run PHP from 5.4 to 8.5.11, update them now: PHP2Shell (CVE-2026-109618 and CVE-2026-109619) lets an unauthenticated attacker run code on the server. PHP rates both 9.2 on CVSS v4.0. Amazon logged the first exploitation attempt on 17 October 2026, four days before the fix, and CISA added both CVEs to its Known Exploited Vulnerabilities catalog on 26 October, with a remediation deadline of 29 October for US federal agencies.

Title card for the article How to Update PHP After PHP2Shell: A Practical Guide. On the right, a code window shows the lines <?php and php --version next to a large PHP logo with a refresh icon and a green shield with a check mark. Below the title, a red warning triangle points through a row of arrows towards the shield.
From "probably vulnerable" to "patched, restarted and verified".

The bugs sit in PHP itself, in how it handles memory and internal state while it starts and cleans up each request, so updating WordPress, Laravel or your Composer packages does not help. Every way of serving PHP is affected: PHP-FPM behind nginx, Apache or Caddy, Apache mod_php, php-cgi, FrankenPHP and LiteSpeed. PHP rates mod_php as the highest risk.

This guide takes a Debian or Ubuntu server from "probably vulnerable" to "patched, restarted and verified" in six steps, and says what to do when your release gets no fix at all.

Step 1: find out what you actually run

Judge a server by its package revision, not by php -v. Ubuntu and Debian keep the upstream version and fix it in the package revision: a fully patched Ubuntu 24.04 still reports PHP 8.3.6, far below the upstream fix 8.3.36.

List every PHP package with its exact build, and see which ones serve requests:

# Every PHP package and its exact build
dpkg-query -W -f='${binary:Package} ${Version}\n' 'php*' 'libapache2-mod-php*' | sort

# PHP-FPM pools that run, and whether Apache loads mod_php
systemctl list-units --type=service --state=running 'php*-fpm*'
apache2ctl -M 2>/dev/null | grep -i php

# Where a build comes from: distro mirror or the ondrej/sury PPA
apt-cache policy php8.3-fpm

For a distro build, check that its changelog names both CVEs. Read the whole file, without grep -q: under pipefail, zcat ... | grep -q can report a miss on a hit, because grep exits at the first match and zcat dies on the closed pipe.

zcat /usr/share/doc/php8.3-common/changelog.Debian.gz | grep -E 'CVE-2026-1096(18|19)'

Then look for PHP that dpkg does not know about. Control panels ship their own builds (cPanel in /opt/cpanel/ea-php*, Plesk in /opt/plesk/php/*, LiteSpeed as lsphp), and containers carry the PHP of their image. A quick check for running PHP outside the package manager:

for p in $(pgrep -f 'php-fpm|php-cgi|lsphp'); do
  exe=$(readlink -f /proc/$p/exe)
  dpkg -S "$exe" >/dev/null 2>&1 || echo "not from dpkg: $exe (pid $p)"
done

Anything this prints needs its own update route, covered under Other platforms below.

Step 2: compare with the fixed builds

A build is fixed when it is at or above the row for its own source and release. Thresholds never cross sources: a PPA build numbered above Ubuntu's fix is not Ubuntu's fix, and Ubuntu 26.10's unfixed 8.5.9-0ubuntu2 sorts above 26.04's fixed 8.5.4-0ubuntu1.5.

Source and releaseBranchFixed build or higherAdvisory
php.net, PPA, Remi, Docker official images8.58.5.12PHP security advisory, 21 Oct
php.net, PPA, Remi, Docker official images8.48.4.27same
php.net, PPA, Remi, Docker official images8.38.3.36same
php.net, PPA, Remi, Docker official images8.28.2.35same
Ubuntu 26.108.58.5.9-0ubuntu2.1USN-8948-1
Ubuntu 26.04 LTS8.58.5.4-0ubuntu1.5USN-8948-1
Ubuntu 24.04 LTS8.38.3.6-0ubuntu0.24.04.13USN-8948-1
Ubuntu 22.04 LTS8.18.1.2-1ubuntu2.28USN-8948-1
Ubuntu 20.04 (Ubuntu Pro only)7.47.4.3-4ubuntu2.29+esm5USN-8948-2
Ubuntu 18.04 (Ubuntu Pro only)7.27.2.24-0ubuntu0.18.04.17+esm14USN-8948-2
Ubuntu 16.04 (Ubuntu Pro, Legacy add-on)7.07.0.33-0ubuntu0.16.04.16+esm22USN-8948-2
Ubuntu 14.04 (Ubuntu Pro, Legacy add-on)5.55.5.9+dfsg-1ubuntu4.29+esm18USN-8948-2
Debian 138.48.4.27-1~deb13u1DSA-6574-1
Debian 128.28.2.35-1~deb12u1DLA-4848-1
Debian 117.4none from DebianLTS ended 31 Aug 2026

PHP 7.4, 8.0 and 8.1 get no official release from the PHP project, only minimal source backports; 5.4 to 7.3 get nothing. If your row says "none" or needs a subscription you lack, jump to When no fix exists for your release below.

Step 3: update on Debian and Ubuntu

Install the fixed build from the same source the branch already comes from, and pin every package of the branch to that one version. Two shortcuts cause most of the breakage we see:

  • apt-get install --only-upgrade '^php.*' lets apt pick the highest version anywhere. On a server with Ubuntu's PHP and the ondrej PPA configured, that moves Ubuntu's 8.3.6 to the PPA's 8.3.36 without anyone deciding it.
  • Pinning PECL extensions such as php8.3-redis to the PHP version makes apt refuse the whole install: they come from their own source package with their own versions.

First refresh the lists and read the candidate:

sudo apt-get update
apt-cache policy php8.3-fpm   # the fixed build, under the source your installed build lists

If apt-get update stops with changed its 'Label' value for the PPA, apt refuses that source until you accept the change. Check that it is expected, then run sudo apt-get update --allow-releaseinfo-change-label.

Then install the fixed build for every package built from the branch's own source:

BRANCH=8.3
FIXED=8.3.6-0ubuntu0.24.04.13   # your row from Step 2, or the PPA build apt-cache policy shows
PKGS=$(dpkg-query -W -f='${db:Status-Abbrev}|${binary:Package}|${source:Package}\n' \
  "php$BRANCH*" "libapache2-mod-php$BRANCH" 2>/dev/null \
  | awk -F'|' -v s="php$BRANCH" '$1 ~ /^ii/ && $3 == s {print $2}')
sudo cp -a /etc/php "/root/etc-php-$(date +%F)"   # config backup before the change
sudo apt-get install $(for p in $PKGS; do printf '%s=%s ' "$p" "$FIXED"; done)

Repeat per branch. A branch that no site uses is better stopped and removed than patched: one less thing to keep current.

If you want a real way back, keep the installed packages before the upgrade: sudo apt-get install dpkg-repack, then dpkg-repack $PKGS in a folder under /root. Ubuntu and Debian keep only the newest revision in their security pockets, so apt-get install pkg=old-version usually finds nothing once the fix is out.

Other platforms in brief

The principle is the same everywhere: update through the channel that installed PHP, every version on the box, then restart.

  • RHEL, AlmaLinux, Rocky with Remi. On a supported stream (php:remi-8.3, php:remi-8.4), sudo dnf upgrade --refresh 'php*' is enough. On an end-of-life stream, dnf upgrade will not leave it: switch with dnf module reset php and dnf module enable php:remi-8.4, then dnf distro-sync, after testing the application on 8.4. Software Collections (php74-php-fpm, php81-php-fpm) are separate packages and need their own update.
  • cPanel, Plesk and similar panels. They ship their own PHP builds per version and per site, so php -v in the shell says nothing about the PHP a given site runs. Use the panel's own updater, then check every version a site is assigned to.
  • Docker and Kubernetes. Never patch inside a running container: the next restart brings the old image back. Rebuild from a fixed tag such as php:8.4.27-fpm and pin it by digest (php:8.4.27-fpm@sha256:...); a latest tag tells you nothing about what you deployed. In official images php -v is honest, because they build upstream releases.

Step 4: restart every worker, then prove it

A fixed package on disk does not fix the server: each PHP-FPM pool and each Apache with mod_php keeps running the code it started with. A service that started before the last PHP install still serves the vulnerable build.

Compare the start time of every PHP service with the newest PHP install:

for u in $(systemctl list-units --type=service --state=running --plain --no-legend 'php*-fpm*' apache2 | awk '{print $1}'); do
  echo "$u started $(systemctl show -p ActiveEnterTimestamp --value "$u")"
done
ls -lt --time-style=full-iso /var/lib/dpkg/info/php*.list | head -3   # newest PHP install

Restart each branch's pool, and Apache when it loads mod_php:

sudo php-fpm8.3 -t && sudo systemctl restart php8.3-fpm
sudo apachectl configtest && sudo systemctl restart apache2   # mod_php only

Run the config test first: a failing config takes the pool down on restart. An Apache restart stops every site on the server for a few seconds, so schedule it. nginx needs no restart. Afterwards, sudo needrestart -r l lists any service still holding old PHP libraries.

Finish with the HTTP status of your main pages, before and after: a site that answered 200 and now answers 502 points at a pool that did not come back.

Step 5: turn off session upload progress

Set session.upload_progress.enabled = 0 on every server, patched or not. On 23 October PHP warned of CVE-2026-115560, a further RCE path through session upload progress that the October releases do not fix. PHP calls it unlikely: it needs an application that switches session.serialize_handler at runtime. Still, PHP recommends the setting until its next regular release, and suggests keeping it off afterwards if nothing needs it. On unpatched builds it also closes the session path of CVE-2026-109619. Ordinary file uploads keep working; only upload progress bars that rely on this feature stop.

Put it in each web SAPI's own config, not the CLI's:

; /etc/php/8.3/fpm/conf.d/99-security.ini  (and /etc/php/8.3/apache2/conf.d/ for mod_php)
session.upload_progress.enabled = 0

Do not set it with ini_set() or in .user.ini: both apply too late in the request to count. In the other direction, a site can turn it back on through .htaccess (mod_php) or a fastcgi_param PHP_VALUE in nginx. Where you do not control the sites, lock it in the pool or vhost instead:

; /etc/php/8.3/fpm/pool.d/www.conf
php_admin_flag[session.upload_progress.enabled] = off

Then test, restart and check the value the SAPI actually uses:

sudo php-fpm8.3 -t && sudo systemctl restart php8.3-fpm
php-fpm8.3 -i | grep 'session.upload_progress.enabled'   # expect Off

A PHP version you install next month starts with upload progress on again. Add the file to your provisioning, or re-check after every PHP install.

When no fix exists for your release

If your release gets no free fix, apt-get update will never bring one; the way out is a different source, a subscription or a newer release.

SituationRouteCost
Debian 11, Debian's php7.4Move to Debian 12 or 13; meanwhile the sury php7.4 build 1:7.4.33-31 from packages.sury.org, which backports both fixes, or Freexian ELTSRelease upgrade; or PHP from a third-party repo; or a paid subscription
Ubuntu 20.04 or 18.04Ubuntu Pro: sudo pro attach, then sudo pro fix CVE-2026-109618 and CVE-2026-109619Free for personal use on up to 5 machines, paid for business
Ubuntu 16.04 or 14.04Ubuntu Pro with the Legacy add-on, or a release upgradePaid
PHP 7.4 to 8.1 from any sourceMigrate to 8.3 or 8.4; check first with composer check-platform-reqsCode and plugin compatibility work

Until the route is in place, narrow the attack surface. These controls come from PHP's own advisories (GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp). PHP is clear they are not a fix, and each has a price:

; /etc/php/7.4/fpm/pool.d/www.conf - admin values, so ini_set() cannot change them
php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[memory_limit] = -1
php_admin_value[post_max_size] = 8M        ; 32M if the site takes uploads
php_admin_value[upload_max_filesize] = 7M  ; keep it below post_max_size
pm.max_requests = 1
  • memory_limit = -1 narrows the out-of-memory path of CVE-2026-109618 but does not close it: an allocation the system refuses still takes the vulnerable route. It also lets one runaway request eat the server's RAM. Do not use RLIMIT_AS or RLIMIT_DATA against that: they trigger the same vulnerable failure. A plain memory_limit in php.ini does not hold either, because applications such as WordPress raise it with ini_set().
  • A cgroup cap (MemoryMax= via sudo systemctl edit php7.4-fpm) is not a fix either, and PHP warns that killing the whole group takes down the FPM master with every worker. systemd stops the whole unit after an OOM kill by default (OOMPolicy=, default stop), so add OOMPolicy=continue next to MemoryMax= if you set one: then the kernel kills one worker and the pool keeps running.
  • pm.max_requests = 1 recycles each worker after one request, which shortens how long it carries stale state from CVE-2026-109619. PHP says recycling lowers the odds but does not remove them. Expect noticeably more CPU and slower responses.
  • On an unpatched PHP 8.5 add fatal_error_backtraces = 0: it removes the confirmed RCE path of CVE-2026-109619 on that branch.
  • If the application takes no multipart forms, block multipart requests at the proxy, and keep max_input_nesting_level low: multipart parsing is how the out-of-memory condition gets triggered.
  • A WAF in front buys time: AWS WAF's managed PHP rule set (AWSManagedRulesPHPRuleSet) covers PHP2Shell, and other providers ship their own rules. AWS itself calls it defense in depth, not a replacement for the upgrade.

Step 6: check that you were not hit already

Patching closes the door, but it does not remove anyone already inside. Amazon saw the first exploitation attempt on 17 October, four days before the fixes, and advises investigating from that date rather than from the disclosure. Look at everything since then.

  1. Crashes. The exploit forces out-of-memory errors at the wrong moment, and attempts that miss tend to crash the worker. A burst of segfaults is the clearest trace:

    sudo journalctl -k --since 2026-10-17 | grep -i segfault | grep -Ei 'php|apache2|httpd'
    sudo zgrep -h 'exited on signal 11' /var/log/php*-fpm.log*
    sudo zgrep -h 'Segmentation fault' /var/log/apache2/error.log*
    
  2. Shells under PHP. A web server has no reason to start a shell or a downloader:

    ps -eo ppid=,pid=,comm=,args= | while read -r pp p c a; do
      pc=$(ps -o comm= -p "$pp")
      case $pc in php-fpm*|apache2|httpd)
        case $c in sh|bash|dash|nc|ncat|curl|wget|python*|perl) echo "$pc ($pp) -> $a" ;; esac ;;
      esac
    done
    
  3. New PHP files. Web shells land in the document root or in temp folders:

    sudo find /var/www /tmp /var/tmp /dev/shm -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \) \
      -newermt 2026-10-17 -printf '%TY-%Tm-%Td %p\n' | sort -r | head -50
    

    A CMS or plugin update creates new files too, so read the list rather than count it. A clean result is not proof: file times can be forged.

If any check finds something, treat the server as compromised. Isolate it, rebuild it from a clean image rather than cleaning it, and rotate every secret PHP could read: database passwords, API keys, session secrets and TLS keys. PHP lists memory disclosure as a confirmed impact of CVE-2026-109619, so those secrets may already be out even without a web shell.

Keep it patched

Ubuntu's and Debian's own PHP updates itself through unattended-upgrades, where it is enabled (the default on Ubuntu, not on every Debian install); PHP from the ondrej PPA does not. unattended-upgrades accepts only the distro's security pockets by default, so a PPA server stays on whatever build was installed until someone upgrades it by hand.

Adding the PPA to unattended-upgrades fixes that, at a price: the PPA has no security-only section, so the server then installs every new PPA build of PHP and its libraries on its own schedule and restarts PHP-FPM as it does. Before you add it, pin any distro-built PHP branch on the same server away from the PPA (Pin-Priority: -1 in a file under /etc/apt/preferences.d/ ending in .pref; apt silently skips other extensions), or the distro branch will start pulling PPA builds.

A short checklist per server:

  • Every PHP package at or above its row in Step 2, from its own source
  • Every PHP-FPM pool and mod_php Apache restarted after the last PHP install
  • session.upload_progress.enabled reports Off in every web SAPI
  • No PHP outside the package manager left unpatched (panels, containers, /usr/local)
  • Crash, process and file checks since 17 October done and clean
  • A plan for every branch or release with no free fix
  • Automatic updates confirmed for the source each PHP comes from

How Mettevo can help

We audit and patch PHP servers for clients in three stages: a read-only audit first, then the patch in an agreed window, then a report per server.

  • Audit. Every PHP branch and SAPI on the server, its exact build and source, whether the running workers use it, the upload progress setting, and the checks from Step 6. Nothing on the server changes.
  • Patch. The pinned upgrade from the branch's own source, the mitigation, config tests, restarts of only what changed, and an HTTP check of your sites before and after, with rollback steps written down.
  • Plan. For servers with no free fix: the cheapest safe route, whether that is Ubuntu Pro, a release upgrade or a move to PHP 8.3 or 8.4.

The audit and patch tooling we use is open source: php2shell-fixer on GitHub. If you would rather hand it over, get in touch and tell us how many servers you run.

Sources

Are You Ready To Grow Your Website?

Understanding the ins and outs of website growth, we help ensure that your site grows over time with ever-increasing reach and accessibility. Not only do we employ the latest digital marketing techniques for driving traffic directly to your website, but our strategies also focus on gaining loyalty from those visitors so they come back again and again.
Leave your contacts to get a comprehensive and aggressive digital marketing plan taking your business to new heights.