Tutorials · PHP vulnerability 2026-10-21

Developer guide: fixing PHP2Shell

CVE-2026-109618 and CVE-2026-109619 · unauthenticated RCE · CVSS v4.0 9.2 · updated 22 Oct 2026

In short

  1. Upgrade to at least 8.2.35, 8.3.36, 8.4.27 or 8.5.12, or to a distribution package that includes the fix.
  2. Restart every PHP worker (FPM, Apache, LiteSpeed, FrankenPHP, containers).
  3. Check the version served over HTTP, not just php -v.
  4. If you can't upgrade right away: apply the temporary mitigations or take the service offline.
  5. An exploitation attempt was seen on 17 Oct: run a compromise check.

Contents

Am I exposed?

Any PHP install that serves web requests is affected. According to the official advisories:

SAPIStatus per the PHP advisories
Apache mod_phpRCE confirmed over HTTP with the default configuration. Handle this first.
PHP-FPMRCE confirmed through the session lifecycle. Higher exposure if the front end passes PHP_VALUE / PHP_ADMIN_VALUE.
FrankenPHPProbably vulnerable, based on source code analysis.
LiteSpeed (LSAPI)Conditional candidate, exploitation not publicly confirmed.
php-cgiListed as affected.
CLI-based application servers (Laravel Octane / Swoole / RoadRunner, php -S, artisan serve)They handle HTTP requests: treat them as exposed.
CLI only (scripts, cron)Not exposed to web requests, but upgrade anyway.

Configuration used by the public exploit chain: session.upload_progress.enabled=On (the default) and multipart requests accepted. The bug triggers before your code runs: how well the application is written makes no difference.

Diagnosis in 9 steps

Nothing to download here. Don't run a "detection tool" you found online either: some booby-trapped ones are going around. Every command below is standard, fits on one line and only reads. Replace 8.2 with your versions.

1. Which OS?

cat /etc/os-release

Tells you which upgrade section to follow (Debian/Ubuntu, RHEL, Alpine…).

2. Which PHP installs are there?

ls -d /usr/bin/php /usr/bin/php[0-9]* /usr/sbin/php-fpm* /opt/remi/php* /opt/cpanel/ea-php* /opt/plesk/php/* /opt/alt/php* 2>/dev/null

Each line is an install to check and upgrade, even if the site doesn't use it.

3. Which version is each one?

php -v | head -1
for b in /usr/sbin/php-fpm* /opt/remi/php*/root/usr/sbin/php-fpm /opt/cpanel/ea-php*/root/usr/sbin/php-fpm; do [ -x "$b" ] && echo "$b: $("$b" -v | head -1)"; done

The FPM binaries live in /usr/sbin, which isn't in a non-root user's PATH on Debian, hence the full paths. php -v doesn't always show the distribution suffix (Ubuntu 22.04 does; Debian 12, Ubuntu 24.04 and RHEL don't): the exact package version comes from step 4.

4. Where do the packages come from?

dpkg -l | grep -E '^ii +(php|libapache2-mod-php)'     # Debian / Ubuntu
rpm -qa | grep -iE '^(php|ea-php|alt-php)'            # RHEL, cPanel, CloudLinux
apt-cache policy php8.2-common | grep -A1 '^ \*\*\*'   # repository the installed version came from (adjust the version)

+deb.sury.org = Sury / ondrej repository; ~deb12u, ubuntu, .el9 = distribution packages, which may carry a backported fix.

5. Is the fix in the package?

zgrep -E 'CVE-2026-10961[89]' /usr/share/doc/php*-common/changelog.Debian.gz           # Debian / Ubuntu / Sury
rpm -qa '*php*-common' | xargs -r rpm -q --changelog | grep -E 'CVE-2026-10961[89]'     # RHEL / Remi / cPanel

A line printed = fix present. Nothing printed: look at the version number. If it's equal to or higher than the fixed version, you're patched (some repositories don't mention CVEs). Debian / Ubuntu Docker images have /usr/share/doc stripped, so this check doesn't work there: go by the version.

6. Which PHP actually serves the site?

ls /etc/apache2/mods-enabled/ 2>/dev/null | grep php                              # mod_php: which version (httpd -M on RHEL)
grep -RhvE '^\s*#' /etc/apache2/conf-enabled/ /etc/apache2/sites-enabled/ /etc/httpd/conf.d/ 2>/dev/null | grep -oE 'proxy:unix:[^|"]+' | sort | uniq -c  # Apache → FPM socket
sudo nginx -T 2>/dev/null | grep -v '^\s*#' | grep -oE '(server_name|fastcgi_pass)[^;]+'  # nginx: site → socket
systemctl list-units --type=service --all '*php*fpm*'                               # FPM services

The socket (e.g. php8.2-fpm.sock) tells you which version each site uses. nginx -T dumps the whole loaded configuration; the grep -v strips commented lines. To confirm over HTTP, see Verification.

7. Is the temporary mitigation active in FPM?

/usr/sbin/php-fpm8.2 -i | grep -i 'upload_progress.enabled'      # RHEL: /usr/sbin/php-fpm -i; Remi: /opt/remi/php82/root/usr/sbin/php-fpm -i; cPanel: /opt/cpanel/ea-php82/root/usr/sbin/php-fpm -i
grep -r upload_progress /etc/php/*/fpm/pool.d/ /etc/php-fpm.d/ /etc/opt/remi/php*/php-fpm.d/ /opt/cpanel/ea-php*/root/etc/php-fpm.d/ 2>/dev/null

Reads FPM's php.ini (not the CLI one), then the pool settings. With mod_php, only the HTTP check is reliable.

8. Are processes still running the old code?

sudo grep -l 'php.*(deleted)' /proc/[0-9]*/maps 2>/dev/null | cut -d/ -f3 | while read p; do echo "$p $(tr '\0' ' ' < /proc/$p/cmdline)"; done   # works everywhere, Alpine included
sudo needrestart -b              # Debian / Ubuntu (not installed by default on Debian: sudo apt install needrestart)
sudo dnf needs-restarting -s     # RHEL and derivatives

The first command lists processes still using PHP files that the upgrade replaced. Nothing printed = nothing to restart. If php-fpm, apache2 or httpd shows up, restart it.

9. What about containers?

for c in $(docker ps --format '{{.Names}}'); do echo "== $c"; docker exec "$c" php -v 2>&1 | grep -m1 '^PHP '; done

One block per container; a version is printed if it contains PHP.

Across several servers

for h in web1 web2 web3; do echo "== $h"; ssh "$h" 'php -v | head -1; ls -d /usr/sbin/php-fpm* 2>/dev/null'; done
ansible all -m shell -a 'php -v | head -1'

Reading the results

Apply the table to every install found in step 2 (CLI, FPM, mod_php, containers) and take the first row that matches. The first row overrides the others: a process running the old code leaves the server exposed, whatever version is installed.

What you seeVerdictWhat to do
A PHP or Apache process listed in step 8Still exposedRestart the service.
Version (in dpkg -l / rpm -qa) equal to or higher than 8.2.35, 8.3.36, 8.4.27 or 8.5.12, with or without a suffixPatchedCheck that the workers have been restarted (step 8).
Lower version, but CVE found in step 5Patched (backported)Restart if needed.
8.2 to 8.5, lower version, no CVE in step 5VulnerableUpgrade (sections below).
8.1 or older, no CVE, shipped by a distribution that is still supported (Ubuntu 22.04, RHEL / Alma / Rocky 8 and 9…)Vulnerable, fix likely availableUpgrade the distribution packages, then run step 5 again.
8.1 or older, from php.net, Sury, a source build or an end-of-life distribution (Debian 10, Ubuntu 20.04, CentOS 7…)UnsupportedSource patch, extended support vendor, or migration (see below).

Step 7 is separate: upload_progress.enabled set to On (or 1) means the temporary mitigation isn't active. That's expected once PHP is patched; otherwise, see the temporary mitigations.

Pitfalls

1. Backported fixes: an old version number isn't necessarily vulnerable

Debian, Ubuntu and RHEL apply fixes to their own version (e.g. 8.2.x-1~deb12uN). Comparing the upstream number isn't enough: look for the CVEs in the package changelog (commands below). The distributions got the fixes before disclosure.

2. php -v doesn't tell you which PHP serves the site

The CLI binary can be up to date while PHP-FPM, mod_php or another installed version (/opt/remi, /opt/cpanel/ea-php*, /opt/plesk/php/*) is still serving the site. On a multi-version server, every installed version has to be upgraded.

3. Old workers stay in memory

Until the processes are restarted, the old code keeps running. A reload is usually enough for FPM, but a restart is safer. Don't forget containers: a docker pull without recreating the container changes nothing.

4. Remi repository

According to Help Net Security (21 Oct), no update was available on rpms.remirepo.net at the time of disclosure. Check the repository's status; if it's still not there, switch to the distribution packages or build from source.

5. Fake PoCs

Fake exploits are circulating on GitHub, including one with a Base64-encoded payload. Don't run any unverified PoC, and certainly not in production.

Debian / Ubuntu (distribution packages or Sury / ondrej repository)

# Current state
sudo apt update
dpkg -l | grep -E '^ii +(php|libapache2-mod-php)'
apt list --upgradable 2>/dev/null | grep -i php
Added the ondrej / Sury repository on Ubuntu or Debian? It also ships the versions your distribution already provides (e.g. 8.1 on Ubuntu 22.04) with a higher version number, so the upgrade below would replace the distribution package with Sury's. Check first which repository will be used (the Candidate line):
apt-cache policy libapache2-mod-php8.1 php8.1-common | grep -E '^\S|Installed|Candidate'
# 1. Upgrade the installed versioned PHP packages (cli, fpm, mod_php, extensions…)
#    The pattern skips phpmyadmin and the php-* libraries, which aren't affected
sudo apt install --only-upgrade $(dpkg -l | awk '/^ii +(php[0-9.]+-|libapache2-mod-php)/ {print $2}')

Ubuntu 22.04 and later: after apt, needrestart shows a list of services to restart, already ticked. Confirming restarts everything that's ticked. Untick PHP-FPM if you'd rather reload it one version at a time (step 3).

# 2. Is the fix in the packages?
zgrep -E 'CVE-2026-10961[89]' /usr/share/doc/php*-common/changelog.Debian.gz
# 3. Reload PHP-FPM one version at a time (avoids taking every site down at once)
#    Repeat for each installed version (8.2, 8.3…)
sudo php-fpm8.2 -t && sudo systemctl reload php8.2-fpm
# 4. Only if using mod_php (diagnosis step 6)
sudo systemctl restart apache2

Then run diagnosis step 8 to check that no process is still running the old code.

  • Sury / ondrej repository: the changelog usually lists the CVEs; if not, the version number must be at least equal to the fixed version for that branch.
  • PHP 8.1 on Ubuntu 22.04 (or any branch shipped by the distribution): the "8.1 = migrate" rule doesn't apply straight away, because the distribution maintains its own version. Read its security advisory (USN for Ubuntu, DSA/DLA for Debian) and check the changelog.

RHEL / AlmaLinux / Rocky / Fedora

rpm -qa | grep -i php
sudo dnf upgrade --refresh 'php*'

# Is the fix in the packages?
rpm -qa '*php*-common' | xargs -r rpm -q --changelog | grep -E 'CVE-2026-10961[89]'

# Reload PHP-FPM one version at a time
sudo php-fpm -t && sudo systemctl reload php-fpm
sudo systemctl reload php82-php-fpm   # Remi: one service per version (php82-php-fpm, php83-php-fpm…)
sudo systemctl restart httpd          # if using mod_php

# Any processes left running the old code?
sudo dnf needs-restarting -s

Remi packages (php82-php-*, /opt/remi/...): see pitfall 4.

Alpine

Official php:*-alpine Docker images: this section doesn't apply. PHP is compiled into /usr/local there, and apk upgrade doesn't update it: see the Docker section. What follows is for Alpine's own php83, php84… packages.

apk info -v | grep -E '^php'
apk update && apk upgrade
php -v   # then restart the php-fpm service (e.g. rc-service php-fpm83 restart)

If php -v is still below the fixed version after apk upgrade, your Alpine branch no longer gets this fix (that's the case for 3.20, stuck on 8.3.15): move to a supported branch.

Docker (official php image)

# 1. Pin a tag at least equal to the fixed version (example)
#    FROM php:8.3.36-fpm   (check on Docker Hub that the tag has been published)

# 2. BEFORE recreating: keep a record of the files changed inside the containers
#    (the compromise check needs it, and recreating wipes them)
for c in $(docker compose ps --format '{{.Name}}'); do echo "== $c"; docker diff "$c"; done > docker-diff-before-update.txt
# 3. Pull the images and rebuild, then recreate only the PHP service
#    (replace app with the name of your PHP service in compose.yaml)
docker compose pull --ignore-buildable
docker compose build --pull --no-cache app
docker compose up -d --force-recreate app

# 4. Check INSIDE the container
docker compose exec app php -v
docker compose exec app php-fpm -i | grep upload_progress.enabled
  • Derived images (WordPress, Drupal…): check that a new version of the image has been published, then recreate the containers. The free Bitnami images haven't been updated since 2025: check where yours come from.
  • CI: rebuild with --pull and no cache, otherwise the old PHP layer gets reused. Go through your internal registries too.

Kubernetes

# Which PHP version is running in each pod? (the image name doesn't always contain "php";
# kubectl exec queries the pod's first container)
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' \
  | while read ns p; do echo "$ns/$p: $(kubectl exec -n "$ns" "$p" -- php -v 2>/dev/null | head -1)"; done

# Deploy the fixed image by digest: with a reused tag,
# nodes may start the old image again from their cache
kubectl set image deployment/<name> <container>=<registry>/<image>@sha256:<digest> -n <namespace>
kubectl rollout status deployment/<name> -n <namespace>

Same for StatefulSets, DaemonSets and CronJobs (kubectl set image statefulset/<name> …).

cPanel / WHM, Plesk, DirectAdmin

cPanel / WHM (EasyApache 4)

rpm -qa | grep -E '^ea-php'            # (Ubuntu: dpkg -l | grep ea-php)
sudo dnf upgrade 'ea-php*'              # or yum / apt depending on the OS
for b in /opt/cpanel/ea-php*/root/usr/bin/php; do echo "$b"; "$b" -v | head -1; done
sudo /scripts/restartsrv_apache_php_fpm
sudo /scripts/restartsrv_httpd

CloudLinux (PHP Selector / alt-php): also run sudo yum update 'alt-php*', since each account can use its own version.

Plesk

Tools & Settings → Updates, or from the command line:

sudo plesk installer --select-release-current --upgrade-installed-components
for b in /opt/plesk/php/*/bin/php; do echo "$b"; "$b" -v | head -1; done

DirectAdmin (CustomBuild)

cd /usr/local/directadmin/custombuild
./build update
./build versions   # is the fixed version offered?
./build php
./build versions   # check after the build

Windows / IIS

  1. Download the fixed version from windows.php.net (same NTS/TS variant and same architecture).
  2. Stop the web server before replacing the files (otherwise php-cgi.exe keeps the DLLs locked and you end up with a mix of old and new versions): iisreset /stop (or stop the Apache service).
  3. Back up the current PHP folder and your php.ini, replace the files, keep your php.ini.
  4. Check that the Visual C++ Redistributable required by the new version is installed, and that the IIS handler mappings point to the right folder.
  5. Restart: iisreset /start.

PHP 8.1, 8.0, 7.4 and older

  • These branches are no longer supported by PHP. The security team has published minimal fixes as source patches for 7.4, 8.0 and 8.1, which you have to build yourself.
  • Your distribution or an extended support vendor (Debian ELTS, TuxCare, Zend…) may offer a patched package: ask them.
  • In the medium term: migrate to 8.2 at least. Check compatibility first (PHPCompatibility, Rector, the error log on staging).
  • Before 7.4: no fix at all. The UK's National Cyber Security Centre (NCSC) recommends taking offline any system that can't be patched quickly.

Temporary mitigations (if you can't upgrade right away)

None of them is complete. They reduce exposure while you get the upgrade done. Apply them at the admin level, then restart every worker. A plain php.ini setting isn't enough: ini_set() can put a memory limit back, and a .user.ini or .htaccess can re-enable session.upload_progress. Use php_admin_value / php_admin_flag, which the application can't override.
; PHP-FPM pool, e.g. /etc/php/8.2/fpm/pool.d/www.conf (repeat for each pool)

; CVE-2026-109619: session path
php_admin_flag[session.upload_progress.enabled] = off

; CVE-2026-109619: PHP 8.5 only
php_admin_flag[fatal_error_backtraces] = off

; CVE-2026-109618: main mitigation (removes the memory exhaustion safeguard)
php_admin_value[memory_limit] = -1

; Breaks the "same worker" condition (expensive in performance)
pm.max_requests = 1

With mod_php: same directives, in the form php_admin_flag session.upload_progress.enabled off, in the Apache configuration (not in .htaccess).

Offset memory_limit = -1 with a system-level limit, otherwise a single request can exhaust memory and get other services killed (database included). Do not use RLIMIT_AS / RLIMIT_DATA. Set a systemd limit instead, with OOMPolicy=continue: without that line, systemd stops the whole service as soon as one worker is killed, and every site on that version goes down.

sudo systemctl edit php8.2-fpm.service   # adjust the service name

In the editor that opens, add these lines and save:

[Service]
MemoryMax=2G
MemorySwapMax=0
OOMPolicy=continue

MemorySwapMax=0: without it, on a server with swap, the request goes to swap instead of being cut off.

sudo systemctl restart php8.2-fpm
systemctl show php8.2-fpm -p MemoryMax -p MemorySwapMax -p OOMPolicy   # check

OOMPolicy needs systemd 243 or later (systemctl --version). Remove these lines with the same systemctl edit command once PHP is patched. In a container, the container's memory limit plays this role: going over it restarts the whole container.

Other measures mentioned in the advisories:

  • Keep post_max_size low and limit request size at the front end (nginx, proxy, WAF).
  • Lower max_input_nesting_level and the number of variables and multipart parts.
  • If the application doesn't use multipart forms (file uploads), block those requests before they reach PHP. nginx example, in the location ~ \.php$ block:
    client_max_body_size 1m;
    if ($http_content_type ~* "multipart/form-data") { return 403; }
  • Don't let the front end pass PHP_VALUE / PHP_ADMIN_VALUE to FPM.
  • Cloudflare has deployed WAF rules for the known vectors. That helps, but it's no substitute for upgrading.

Verification

Check the version that is actually served. Create a temporary file with an unguessable name in the site's web root. First, set that root (edit the path before copying):

DOCROOT=/var/www/mysite/public   # the site's web root (public/ directory for Laravel / Symfony)
f="v-$(cat /proc/sys/kernel/random/uuid).php"
echo '<?php header("Content-Type: text/plain"); echo PHP_VERSION, " ", PHP_SAPI, " upload_progress=", ini_get("session.upload_progress.enabled") ? "On" : "Off";' \
  | sudo tee "$DOCROOT/$f" >/dev/null
echo "Open: https://your-site/$f"

Note the result, then delete the file from the same terminal (the $f variable only exists there):

sudo rm -- "$DOCROOT/$f"

Laravel / Symfony with nginx (only index.php is served) or several FPM pools: query each socket directly, without putting anything in the site. The test file goes in /dev/shm, which FPM can see even with PrivateTmp (RHEL). An open_basedir can still block it, though.

sudo apt install libfcgi-bin   # provides cgi-fcgi (RHEL: dnf install fcgi, from EPEL)
echo '<?php echo PHP_VERSION, " upload_progress=", ini_get("session.upload_progress.enabled") ? "On" : "Off", "\n";' | sudo tee /dev/shm/v.php >/dev/null
for s in /run/php/*.sock /run/php-fpm/*.sock /var/opt/remi/php*/run/php-fpm/*.sock /opt/cpanel/ea-php*/root/usr/var/run/php-fpm/*.sock; do [ -S "$s" ] && [ ! -L "$s" ] && echo "$s: $(sudo SCRIPT_FILENAME=/dev/shm/v.php REQUEST_METHOD=GET cgi-fcgi -bind -connect "$s" | tail -1)"; done
sudo rm /dev/shm/v.php

Finally, diagnosis step 8 confirms that no process is still running the old code.

Compromise check

No official indicators of compromise have been published so far. These are the usual checks. To be safe, go back to 15 October:

Alpine: install findutils first (sudo apk add findutils). BusyBox find doesn't know -newerct, and since errors are hidden, it would find nothing without telling you.

# PHP files created or modified since 15 Oct (ctime: can't be faked with touch)
# Template caches (e.g. storage/framework/views) are excluded: they change all the time
sudo find /var/www /home /srv -type f -name '*.php' -newerct '2026-10-15' \
  -not -path '*/storage/framework/*' -not -path '*/var/cache/*' 2>/dev/null > ~/php-modified.txt
wc -l ~/php-modified.txt

# PHP files in upload directories, and suspicious files in /tmp and /dev/shm
sudo find /var/www /home /srv -type f -name '*.php*' -path '*upload*' 2>/dev/null
sudo find /tmp /var/tmp /dev/shm -type f -newerct '2026-10-15' 2>/dev/null

# Bursts of fatal memory errors (the exploit relies on fatal errors / OOM), rotated logs included
# (sudo sh -c: the globs have to be expanded by root, otherwise /var/log/httpd isn't readable)
sudo sh -c "zgrep -h 'Allowed memory size' /var/log/php*-fpm.log* /var/log/php-fpm/* /var/log/apache2/error.log* \
  /var/log/httpd/error_log* /var/log/nginx/error.log* /etc/apache2/logs/error_log* \
  /var/opt/remi/php*/log/php-fpm/* /opt/cpanel/ea-php*/root/usr/var/log/php-fpm/* 2>/dev/null" | tail -50
# (with nginx + FPM, these errors also show up as "FastCGI sent in stderr";
#  on cPanel, each site may also have its own error_log file)

# Scheduled jobs, accounts, SSH keys
sudo ls -la /etc/cron.* /etc/crontabs /var/spool/cron/crontabs/ /var/spool/cron 2>/dev/null
sudo crontab -l -u www-data 2>/dev/null   # (apache or nginx depending on the distribution)
awk -F: '$3 == 0 {print "uid 0:", $1}' /etc/passwd
awk -F: '$3 >= 1000 {print $1, $6, $7}' /etc/passwd
sudo find / -name authorized_keys -newerct '2026-10-15' 2>/dev/null

# Established connections outside local service ports (remote port 443 is NOT excluded: C2 servers use it)
sudo ss -tnp state established '( sport != :22 and sport != :80 and sport != :443 )'

If anything looks wrong, disconnect the server from the network and copy the logs. Once PHP is patched (not before), rotate your secrets: database, API keys, admin accounts. Bring in an incident response specialist if needed.

Information last updated: 22 October 2026. Sources: advisories GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp, Help Net Security, JFrog. Commands provided without warranty: test and back up before acting.