Developer guide: fixing PHP2Shell
CVE-2026-109618 and CVE-2026-109619 · unauthenticated RCE · CVSS v4.0 9.2 · updated 22 Oct 2026
In short
- 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.
- Restart every PHP worker (FPM, Apache, LiteSpeed, FrankenPHP, containers).
- Check the version served over HTTP, not just
php -v. - If you can't upgrade right away: apply the temporary mitigations or take the service offline.
- An exploitation attempt was seen on 17 Oct: run a compromise check.
Contents
- Am I exposed?
- Diagnosis in 9 steps
- Reading the results
- Pitfalls
- Debian / Ubuntu
- RHEL / Alma / Rocky
- Alpine
- Docker
- cPanel, Plesk, DirectAdmin
- Windows / IIS
- PHP ≤ 8.1
- Temporary mitigations
- Verification
- Compromise check
Am I exposed?
Any PHP install that serves web requests is affected. According to the official advisories:
| SAPI | Status per the PHP advisories |
|---|---|
| Apache mod_php | RCE confirmed over HTTP with the default configuration. Handle this first. |
| PHP-FPM | RCE confirmed through the session lifecycle. Higher exposure if the front end passes PHP_VALUE / PHP_ADMIN_VALUE. |
| FrankenPHP | Probably vulnerable, based on source code analysis. |
| LiteSpeed (LSAPI) | Conditional candidate, exploitation not publicly confirmed. |
| php-cgi | Listed 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
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 see | Verdict | What to do |
|---|---|---|
| A PHP or Apache process listed in step 8 | Still exposed | Restart 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 suffix | Patched | Check that the workers have been restarted (step 8). |
| Lower version, but CVE found in step 5 | Patched (backported) | Restart if needed. |
| 8.2 to 8.5, lower version, no CVE in step 5 | Vulnerable | Upgrade (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 available | Upgrade 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…) | Unsupported | Source 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
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
--pulland 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
- Download the fixed version from windows.php.net (same NTS/TS variant and same architecture).
- Stop the web server before replacing the files (otherwise
php-cgi.exekeeps the DLLs locked and you end up with a mix of old and new versions):iisreset /stop(or stop the Apache service). - Back up the current PHP folder and your
php.ini, replace the files, keep yourphp.ini. - Check that the Visual C++ Redistributable required by the new version is installed, and that the IIS handler mappings point to the right folder.
- 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)
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_sizelow and limit request size at the front end (nginx, proxy, WAF). - Lower
max_input_nesting_leveland 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_VALUEto 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.