
PHP's security team has scheduled a release for 21 October 2026 at 14:00 UTC that fixes two critical remote code execution flaws, both rated 9.2 on CVSS v4.0. When we last checked php.net, at 11:19 UTC on release day, the root cause, affected versions and fixed versions were still unpublished. Installing the fixed release is the fix. Everything below is defence in depth: PHP webshell detection you can run today, plus hardening that limits what an attacker gets if a PHP worker is ever made to run their code. We tested the commands and config in a Docker lab and label the ones we could not run.
Researchers call the pair "php2shell" and have reported them as CVE-2026-109618 and CVE-2026-109619; PHP's advance notice names neither. Our companion guides cover the news, upgrading PHP and end-of-life branches; this one is for whoever has a shell on the server.
The short version
- Plan to install the fixed release, or your distro's backport, as soon as it lands. Internet-facing hosts go first.
- Watch for PHP worker processes starting shells, interpreters or download tools.
- List PHP files created or changed in the web root in the last few days, and every PHP file in upload folders.
- Compare code against git, package checksums or WordPress.org checksums.
- Check cron, systemd timers, scheduled tasks and accounts for anything new.
- Look for POST requests to PHP files that should never get them.
- Run PHP-FPM as a dedicated low-privilege user that cannot write to its own code.
- Block PHP execution in upload directories, and test that the block works.
- Restrict outbound connections from the PHP user.
- Remove php-cgi if nothing uses it, and stop advertising the PHP version.
PHP webshell detection starts with the process tree
Huntress's 20 October write-up predicts that attacks on these flaws will surface as PHP worker processes (php-fpm, php-cgi, php-cgi.exe, httpd, apache2) launching something they normally never launch: a shell, Python or Perl, curl, wget, nc or certutil.exe. Huntress also suggests reviewing command lines for reverse-shell patterns and quick recon such as whoami, id and uname -a. Most PHP sites start few child processes, so this check is cheap and usually quiet.
Detect a PHP web shell in Linux: what did PHP-FPM start?
The quickest look is a process forest:
ps -eo pid,ppid,user,cmd --forest
# only the children of PHP-FPM processes, without the workers themselves
for pid in $(pgrep php-fpm); do ps --ppid "$pid" -o pid=,user=,args=; done | grep -v 'php-fpm:'Tested on Ubuntu 24.04 (procps-ng 4.0.4) against php:8.4-fpm (PHP 8.4.26). After a request to a test script calling shell_exec('id; sleep 20'), the forest showed php-fpm: pool www (www-data) with sh -c -- id; sleep 20 and sleep 20 under it; the loop printed just the sh line. In Debian's package the binary is php-fpm8.4 (for 8.4), and pgrep php-fpm still matches it. To see when a suspicious process started, use ps -o lstart=,etime=,args= -p <pid>.
A snapshot only catches what is running at that moment. For a record, add an audit rule for execve by the PHP user:
# /etc/audit/rules.d/50-php-exec.rules
-a always,exit -F arch=b64 -S execve -F euid=www-data -k php_exec
-a always,exit -F arch=b32 -S execve -F euid=www-data -k php_execsudo augenrules --load
sudo auditctl -l
sudo ausearch -k php_exec -i -ts recentUntested: auditd can't run inside our Docker Desktop lab. Syntax per auditctl(8) (arch before -S; uid fields accept a user name) and augenrules(8), which only merges .rules files; ausearch -ts recent means the last 10 minutes. Cron jobs running as www-data add noise; a per-site pool user makes the rule quieter.
process-watcher, if your kernel supports it
Florian Roth's process-watcher (GPL-3.0) uses eBPF to follow a service and every process it starts, and writes JSON events. Its repo has a PHP guide for this release with an observe-only config:
./process-watcher check-config --config php/php-observe.yaml
sudo ./process-watcher doctor
sudo ./process-watcher --config php/php-observe.yaml
sudo tail -f /var/log/process-watcher/events.jsonlUntested: it needs eBPF on the host and refuses to run in a container. The README asks for Linux amd64 or arm64, kernel 5.15+ with BTF, and root; doctor checks your host. The optional kill rules ship with dry_run: true, and the author is clear that it "prevents nothing and misses code that runs inside PHP without starting a process".
Webshell detection for Windows servers
On Windows the parents to watch are php-cgi.exe, php.exe, httpd.exe and w3wp.exe. PowerShell can list their children directly:
$ids = (Get-CimInstance Win32_Process -Filter "Name='php-cgi.exe'").ProcessId
Get-CimInstance Win32_Process | Where-Object { $ids -contains $_.ParentProcessId } |
Select-Object ProcessId,ParentProcessId,Name,CommandLineTested on Windows PowerShell 5.1 (Windows 11), using cmd.exe as the parent and a timeout child as the stand-in. It listed timeout.exe with its full command line. One trap we hit: piping a foreach block straight into Format-Table is a parse error in 5.1 ("An empty pipe element is not allowed"), so collect the ids first as shown.
For a record, Sysmon Event ID 1 logs every process creation with the parent's image and command line. A ProcessCreate include rule on ParentImage looks like this:
<Sysmon schemaversion="4.82">
<EventFiltering>
<RuleGroup name="php parent" groupRelation="or">
<ProcessCreate onmatch="include">
<ParentImage condition="end with">\php-cgi.exe</ParentImage>
<ParentImage condition="end with">\php.exe</ParentImage>
<ParentImage condition="end with">\httpd.exe</ParentImage>
<ParentImage condition="end with">\w3wp.exe</ParentImage>
</ProcessCreate>
</RuleGroup>
</EventFiltering>
</Sysmon>Untested: Sysmon isn't installed on our test machine. Install with sysmon -accepteula -i php-parent.xml or update a running one with sysmon -c php-parent.xml. Alone, this include filter logs only these process starts, so merge it into an existing config instead of replacing it. For a SIEM, SigmaHQ's rule Suspicious Process By Web Server Process covers the same parent list. It warns that some web apps spawn shells legitimately.
Detect PHP web shells on disk
The other half of PHP webshell detection is the file system: a shell dropped by an exploit, or left behind for later, is usually a new or changed PHP file. Start with timestamps:
touch -d "2026-10-20 00:00" /tmp/ref
find /var/www/html -type f -newer /tmp/ref
find /var/www/html -type f -mtime -2
find /var/www/html -type f -ctime -2
find /var/www/html -type f -newermt "2026-10-19" -printf "%TY-%Tm-%Td %TH:%TM %u %p\n"
# any PHP-executable file in uploads is suspect
find /var/www/html/wp-content/uploads -type f \( -iname "*.php*" -o -iname "*.phtml" -o -iname "*.phar" \)Tested on Ubuntu 24.04, GNU findutils 4.9.0. A new file in uploads turned up in the -newer, -mtime -2 and -newermt results. Then we backdated a second file's modification time to 1 September with touch -d. That hid it from -mtime, but -ctime -2 still found it. Attackers can fake mtime with touch, but ctime changes on every metadata update and touch cannot set it. Run both.
A quick content check for code that passes request data straight into an execution function:
grep -rlE --include='*.php' '(eval|assert|system|shell_exec|passthru)\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)' /var/www/htmlTested on Ubuntu 24.04, GNU grep: it flagged our one-line test shell and ignored a benign file. Obfuscated shells (base64 blobs, variable function names) will not match, so treat a clean result as "nothing obvious", not "clean". While testing, Microsoft Defender on our Windows host quarantined a script file that contained the sample shell. Endpoint antivirus can catch the crude ones, so don't keep samples lying around.
Compare against a known-good copy
Timestamps can mislead; checksums are harder to fake. Pick whichever source of truth you have:
# deployed from git: modified and untracked files
git status --porcelain
git ls-files --others --exclude-standard
# Debian/Ubuntu: files that differ from their package
dpkg --verify | grep -v '^missing'
# WordPress core and WordPress.org plugins (WP-CLI)
wp core verify-checksums --include-root
wp plugin verify-checksums --allTested: dpkg --verify on Ubuntu 24.04. After we appended a line to /usr/bin/which.debianutils, it printed ??5?????? /usr/bin/which.debianutils. The 5 marks a checksum mismatch. The grep hides the many missing lines that slimmed-down images produce. Untested: the git and WP-CLI lines (our lab containers had no network to fetch checksums). According to the WP-CLI docs, wp core verify-checksums checks core files against WordPress.org without loading WordPress, and --include-root also warns about non-WordPress files in the root. wp plugin verify-checksums compares against WordPress.org's checksums too, so premium or custom plugins need your own copy to compare against.
Persistence: cron, timers, scheduled tasks and accounts
Attackers who get in tend to leave a way back. On Linux:
ls -la /etc/cron.d /etc/cron.daily /var/spool/cron/crontabs
crontab -l -u www-data
systemctl list-timers --all
awk -F: '$3==0' /etc/passwd # every UID 0 account
awk -F: '$7 !~ /(nologin|false)$/' /etc/passwd # accounts with a login shellTested: the two awk lines on Ubuntu 24.04. Untested: crontab and systemctl (not in the container image). The cron paths are the Debian/Ubuntu defaults. More than one UID 0 account, or a login shell on www-data, needs an explanation.
On Windows (tested in PowerShell 5.1):
Get-ScheduledTask | Where-Object TaskPath -notlike '\Microsoft\*' | Select-Object TaskName,TaskPath,State
Get-LocalUser | Select-Object Name,Enabled,LastLogon
Get-ChildItem C:\inetpub -Recurse -File -Include *.php,*.phtml,*.phar |
Where-Object { $_.CreationTime -gt (Get-Date).AddDays(-2) }Don't filter scheduled tasks on their Date property. In our test it behaved as a string and returned tasks from 2010 for "the last 7 days". In the file check, a backdated LastWriteTime slipped past the filter but CreationTime caught it, so we filter on creation time. Point the path at your own web root.
Access logs: POSTs to PHP files that shouldn't get them
A web shell is usually driven by POST requests to a PHP file nobody else requests. Two short pipelines for the combined log format (if awk and pipes are new to you, our guide to Linux pipes and redirection covers the basics):
# POST count per script and status, busiest first
awk '$6=="\"POST" {split($7,u,"?"); print $9, u[1]}' access.log | sort | uniq -c | sort -rn
# any request for a PHP-like file under uploads
grep -E '"(GET|POST) /wp-content/uploads/[^ ]*\.ph(p|ar|tml)' access.logTested with GNU Awk 5.3.2 on our lab nginx log. The first pipeline printed 2 200 /wp-content/uploads/evil.php above 1 200 /index.php. The second printed all three hits on the uploads file. On a real WordPress site, expect POSTs to wp-login.php, admin-ajax.php, wp-cron.php and xmlrpc.php. A small file you don't recognise, with a 200 and a handful of client IPs, deserves a closer look. If your log format isn't the default, the field numbers change.
PHP hardening that limits the damage of an RCE
None of this stops a bug in PHP itself, but it decides what an attacker can do next. Each item below was tested unless it says otherwise.
Hardening PHP-FPM: a dedicated pool user and admin-locked settings
This is the pool file from our lab (official image path /usr/local/etc/php-fpm.d/www.conf; on Debian's php8.4-fpm package it's /etc/php/8.4/fpm/pool.d/www.conf):
[www]
user = www-data
group = www-data
listen = 9000
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
security.limit_extensions = .php
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,popen
php_admin_value[open_basedir] = /var/www/html:/tmp
php_admin_flag[expose_php] = offTested on php:8.4-fpm (PHP 8.4.26):php-fpm -t reported "test is successful", and php-fpm -tt echoed each value. Some notes from the FPM configuration docs:
- user is mandatory. In production, give each site its own user rather than sharing www-data, so one compromised site can't read the next one's files.
- security.limit_extensions defaults to
.php .phar. Setting it to.phpstops FPM from parsing a main script with any other extension if the web server config is wrong. - php_admin_value settings can't be overridden by
ini_set()in the app. In a pool,disable_functionsis appended to the php.ini value rather than replacing it. - Don't bind FPM to a public address. Settings travel as FastCGI headers, so anyone who can reach the port can change them. A unix socket or 127.0.0.1 is the norm. Our lab used port 9000 on a private Docker network.
PHP hardening: disable_functions and its limits
With the pool above, our test script's system('id') failed with Error: Call to undefined function system(). Since PHP 8.0, a disabled function's definition is removed, so calling it throws an Error. proc_open(['id'], ...) in the same script still returned uid=33(www-data) gid=33(www-data), because it wasn't on the list. The php.ini core docs are blunt that the directive "can be circumvented". It only covers internal functions, and it can't help once a bug gives an attacker native code execution. Use it to shrink the easy paths, add proc_open and pcntl_exec only after checking that your app (mail, image tools, queue workers) doesn't need them, and don't count on it. Older guides also recommend disable_classes, which was removed in PHP 8.5.0.
open_basedir: a safety net, not a wall
With open_basedir = /var/www/html:/tmp, reading /etc/passwd failed with "open_basedir restriction in effect. File(/etc/passwd) is not within the allowed path(s): (/var/www/html:/tmp)". It's useful against path-traversal bugs in your app. php.net still calls it "just an extra safety net", notes that it does not restrict Unix domain sockets (so a reachable /var/run/docker.sock stays reachable), and notes that it disables the realpath cache. Filesystem permissions are the real boundary.
Read-only code, writable uploads
chown -R root:root /srv/app/code && chmod -R u=rwX,go=rX /srv/app/code
chown www-data:www-data /srv/app/uploadsRun as www-data, file_put_contents() into the code directory returned false, and into uploads it wrote the byte. A compromised worker then can't plant a shell in your theme or edit index.php. The cost is that WordPress dashboard updates and plugin installs stop working, so you update through your deploy process or WP-CLI as the owning user.
No PHP execution in upload directories (test it)
This was the most useful finding in the lab. Both of these nginx configs pass nginx -t; only one works:
# Looks right, does NOT stop PHP in uploads
location /wp-content/uploads/ {
deny all;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}With this config (nginx 1.30.6), /wp-content/uploads/evil.php returned 200 with X-Powered-By: PHP/8.4.26 and the script's output, while a harmless readme.txt in the same folder got 403. The location docs explain why: nginx remembers the longest prefix match, then checks regex locations, and the first regex that matches wins. The ^~ modifier skips the regex step:
location ^~ /wp-content/uploads/ {
location ~* \.(php|phar|phtml)$ { return 403; }
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}Result: the PHP file got 403, readme.txt 200, /index.php 200.
Apache has its own trap. On php:8.3-apache (Apache 2.4.69 with mod_php), php_admin_flag engine off in the uploads <Directory> stopped execution but returned the PHP source as plain text: a source leak instead of a shell. A FilesMatch deny returned 403 and passed apache2ctl -t:
<Directory "/var/www/html/wp-content/uploads">
AllowOverride None
<FilesMatch "\.(php|phar|phtml)$">
Require all denied
</FilesMatch>
</Directory>Whatever server you run, check the result with curl -i https://your-site/wp-content/uploads/test.php after dropping a harmless test file there, then delete the file.
noexec on uploads: what it does and doesn't stop
noexec means "Do not permit direct execution of any binaries" (mount(8)). On a noexec tmpfs in our lab, a copied binary failed with Permission denied (exit code 126), but php /up/x.php and sh /up/x.sh both ran. So mount uploads with noexec,nosuid,nodev to stop dropped binaries from being run directly, and keep the web server block above to stop PHP. The fstab line depends on your disk layout; we didn't test one.
Egress: stop the PHP user from phoning home
Many attacks need to download a tool or call back to the attacker. Owner matching only works on locally generated traffic, in the OUTPUT and POSTROUTING chains (iptables-extensions):
iptables -A OUTPUT -m owner --uid-owner www-data -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner www-data -d 127.0.0.1 -j ACCEPT
# add ACCEPT rules for your database, SMTP relay and any payment or update APIs here
iptables -A OUTPUT -m owner --uid-owner www-data -j REJECTUntested: our lab containers had no firewall tooling. The nftables equivalent matches on meta skuid www-data. Expect breakage: plugin updates, payment gateways, SMTP and external APIs all go out as that user. List what your site actually calls before you add the final REJECT, and keep a console session open in case you lock yourself out.
Remove php-cgi if nothing uses it, and stop advertising versions
Check what is installed with command -v php-cgi, dpkg -l 'php*-cgi' and apache2ctl -M | grep -i php. If no virtual host uses CGI, remove the package. php-cgi on Windows was behind CVE-2024-4577, where Windows "Best-Fit" character conversion let attackers pass options to the PHP binary. Set expose_php = Off (default On) and nginx's server_tokens off (default on). In our lab the default image sent X-Powered-By: PHP/8.4.26 and the hardened pool sent no such header. This doesn't stop exploitation; it only makes version-based scanning slightly harder. Confirm with curl -I.
What each control stops, and what it doesn't
Hardening and PHP webshell detection overlap but don't replace each other, and none of them replaces the patch:
| Control | Helps with | Does not stop |
|---|---|---|
| Installing the fixed PHP release | The vulnerability itself | A shell already planted before you patched |
| Process-tree checks, auditd, Sysmon, process-watcher | Spotting shells, interpreters, curl or wget started by PHP | Anything done inside PHP without a new process (file reads, sockets, eval) |
| New-file and checksum checks | Dropped or modified PHP files | Memory-only activity; files in paths you didn't check |
| Dedicated pool user, read-only code | Planting shells in code, reading other sites' files | Reading anything that user can already read (wp-config.php, the database) |
| No PHP in uploads (web server) | Running an uploaded .php file through the web server | Code execution through a bug in PHP itself |
| noexec on uploads | Running dropped binaries directly | php file.php, sh file.sh |
| disable_functions | The common one-liner shells built on system() and exec() | Functions you didn't list (proc_open worked in our test), native-code exploits |
| open_basedir | Path traversal in app code | Unix sockets; anything php.net calls out as outside its scope |
| Egress firewall | Downloading tools, reverse shells, exfiltration to new hosts | Abuse over allowed destinations; inbound-only web shells |
| expose_php Off, server_tokens off | Casual version fingerprinting | Exploitation |
If you find something
- Isolate the server from the internet (security group, firewall or your host's panel) without powering it off.
- Preserve evidence before you clean anything: web server access and error logs, PHP-FPM logs, auth logs and the suspicious files with their timestamps (
ls -la --time-style=full-iso). - Rotate credentials the PHP user could read: database passwords, API keys, WordPress salts and admin passwords.
- Rebuild or restore from a known-good state rather than deleting one file and hoping. For WordPress, that might mean you restore from a known-good UpdraftPlus backup made before the earliest suspicious timestamp, then patch PHP before going back online.
- Bring in your hosting provider or an incident-response firm if customer data may be involved.
FAQ
Will disable_functions stop php2shell?
Nobody outside the PHP security team knows yet, because the root cause isn't public. php.net says the directive can be circumvented, and it only removes the internal functions you list. Patch, and treat it as one layer.
Is open_basedir a security boundary?
No. php.net calls it "just an extra safety net" and notes that it does not restrict Unix domain sockets. Use filesystem permissions and separate pool users for real separation.
Does mounting uploads with noexec stop PHP web shells?
No. noexec blocks direct execution of binaries; in our test php and sh still ran files from a noexec mount. Block PHP execution in the web server config as well.
Should I wait for IOCs before hunting?
No. As of 20 October, Huntress said no indicators of compromise had been confirmed and that it would publish Sigma and YARA rules once technical details are out. Behaviour-based PHP webshell detection (PHP starting processes, new PHP files, odd POSTs) doesn't depend on IOCs. Run them now, and again after the release.
Is process-watcher a fix?
No. Its README says it "prevents nothing". It records what PHP starts so you can see it, needs kernel 5.15+ with BTF and root, and runs on the host, not in a container.
If you'd like a hand reviewing your PHP or WordPress setup, upgrading PHP, or cleaning up and updating plugins, Pravinya Studio, the studio behind this blog, can help. Our work is WordPress plugin development, Shopify, mobile apps and AI; we are not an incident-response firm, so for a suspected active compromise follow the steps above and contact your host or an IR provider first.
Last updated / tested on: 21 October 2026, 11:20 UTC (php.net last checked at 11:19 UTC; the fixed release was not yet published). Tested on PHP 8.4.26 (php:8.4-fpm), nginx 1.30.6, Apache 2.4.69 with PHP 8.3 (php:8.3-apache), Ubuntu 24.04 (procps-ng 4.0.4, findutils 4.9.0), GNU Awk 5.3.2 and Windows PowerShell 5.1.