PHP2Shell is the nickname for CVE-2026-109618 and CVE-2026-109619, two critical remote code execution bugs in PHP that the project fixed on October 21, 2026 in releases 8.5.12, 8.4.27, 8.3.36, and 8.2.35. The Canadian Centre for Cyber Security treats every build below those numbers on those branches as vulnerable, and both flaws carry a CVSS v4.0 score of 9.2. If you already run a Wazuh SIEM, you can turn that advisory into a living check across every web server you own.
This tutorial builds that check in 13 steps and about 60 minutes. You will write an audit script that compares each PHP binary against the fixed release for its branch, ship its output to Wazuh, and alert on anything vulnerable, unsupported, or patched on disk but still running old code in memory. Then you will add heuristic rules for PHP worker crashes, memory exhaustion, web 5xx bursts, and new PHP files in upload paths. As of October 25, 2026, the Wazuh documentation we checked shows no built-in rule named for PHP2Shell, so everything here is custom. A community project on GitHub also publishes PHP2Shell rules for Wazuh, reportedly validated on Wazuh 4.14.8. It is unofficial, which is one reason to understand how to write your own.
The flaw has already moved beyond security mailing lists. Several governments pulled their websites offline while they patched, and the speed of that response tells you how seriously defenders took the bug. Detection will not replace patching, but it answers the question your CISO will ask next: which of our servers are still exposed right now?
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What PHP2Shell Is and Why a SIEM Needs Custom Rules
PHP2Shell is a vulnerability nickname, not a product. It covers two bugs in PHP core. According to Palo Alto Networks’ Unit 42 threat brief, CVE-2026-109618 involves out-of-memory handling and improper control flow that can leave request memory uninitialized. CVE-2026-109619 involves stale global memory after fatal-error handling, a use-after-free condition. Both allow unauthenticated remote code execution over HTTP against PHP applications.
The attack surface is wide because PHP runs in many shapes. Advisories dated October 23 to 25, 2026 list PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP, and LiteSpeed as exposed deployments. Unit 42 reported that Palo Alto Networks Cortex Xpanse telemetry found over 22.6 million exposed instances potentially vulnerable at the time of publication. Your fleet is a rounding error in that number, but each server in it still needs an answer.
Exploitation is not theoretical. F5 Labs published a report titled PHP2Shell exploitation attempts observed after the fixes shipped, and Sophos has posted about a released proof-of-concept exploit. A revised CISA alert added a SIGMA detection rule, though a patch tracker reports CISA still said it had not observed successful exploitation when it published. Treat those details as a snapshot. They change by the hour.
So why custom rules? Two reasons. First, Wazuh’s vulnerability detection depends on a feed that can lag a fresh CVE, and a missing alert is not proof of safety. Second, distributions such as Debian and Red Hat keep the upstream version string frozen and backport fixes, which means a naive version check misfires in both directions. A script you control can compare versions, inspect package changelogs, and notice stale processes. Wazuh then handles the part it does best: collecting, correlating, alerting, and retaining the evidence.
The wider story matters for context too. The NSA issued a warning about the PHP flaw as four nations pulled sites, and researchers credited AI tooling with finding the bugs, as covered in this look at how AI found the flaw. Both stories point the same way. Attackers get faster, so your inventory has to get faster too.
What this guide does not claim
The detections for crashes, memory exhaustion, and 5xx bursts are heuristics. They follow from how the advisories describe the bugs, not from a published exploit signature. They will catch instability and probing, and they will also fire on a leaky plugin. Tune them against your own baseline before you page anyone at 3 a.m.
Prerequisites and Versions
You need a working Wazuh deployment before you start. If you do not have one, set up a lab first using the walkthrough for Wazuh SIEM with Docker or the free Wazuh tutorial. Both run an hour or less.
A separate guide on setting up Wazuh SIEM with free XDR covers the single-host install path. This article starts after the first agent shows as active.
| Component | Version used here | Notes |
|---|---|---|
| Wazuh manager, indexer, dashboard | 4.14.9 | Newest release in Wazuh’s documentation as of October 25, 2026 |
| Wazuh agent | 4.14.9 | Keep agents on the same version as the manager |
| PHP test target | 8.4.x, any build below 8.4.27 | Use a throwaway VM, never production |
| Linux host for the agent | Ubuntu 24.04 LTS or Debian 12 | RHEL-family hosts work with the dnf commands shown |
| Shell | bash 4.0 or newer | The audit script uses associative arrays |
| Tools | curl, jq, sort -V (GNU coreutils) | All ship with most server images |
Budget a lab VM with 4 vCPU and 8 GB of RAM if you run the manager, indexer, and dashboard together. The web server agent needs very little. You also need root or sudo on the manager and on every host you plan to audit, plus the Wazuh API credentials. The installer writes those credentials to a file inside wazuh-install-files.tar on the host where you ran it.
Finally, decide where you will test. Do not run any public proof-of-concept exploit against hosts you do not own, and do not run it against production. This guide never needs one. Every test uses harmless log lines, touch, and the audit script.
Step 1 and 2: Pin Down the Targets and Check Wazuh Health
Step 1: Record the fixed versions
Write the target versions into your runbook before you touch a server. The PHP project’s own advisory names four fixed releases, all dated October 21, 2026.
| PHP branch | Fixed release | Status on October 25, 2026 |
|---|---|---|
| 8.5 | 8.5.12 | Supported, fix released October 21 |
| 8.4 | 8.4.27 | Supported, fix released October 21 |
| 8.3 | 8.3.36 | Supported, fix released October 21 |
| 8.2 | 8.2.35 | Supported, fix released October 21 |
| 8.1 and older | None upstream | No longer receives updates from the PHP project, so use a vendor backport or upgrade |
| 8.6.0 RC4 | Contains the fixes | Release candidate for testing only, with a further release candidate planned |
Two details trip people up. The PHP project shipped minimal source backports for PHP 7.4, 8.0, and 8.1 to help teams that build from source or lack an LTS vendor, but it does not treat those branches as supported. And the project’s advisory page also references CVE-2026-115560 in connection with the next release, while Red Hat’s bulletin RHSB-2026-013 lists it alongside the other two. Plan for a second patch window.
Step 2: Confirm the manager is healthy and current
Log in to the manager and check the version and service state. The 4.14.9 release notes are dated October 14, 2026. They include embedded Python 3.10.21, dependency updates, and a change that allows eBPF-based file integrity whodata on capable kernels older than 5.8, such as RHEL 8.10. Updates are cumulative, so there is no reason to sit on 4.14.7 or 4.14.8.
sudo /var/ossec/bin/wazuh-control info
sudo systemctl is-active wazuh-manager wazuh-indexer wazuh-dashboard
sudo /var/ossec/bin/agent_control -l
Expect three lines of active and a list of agents with status Active. You should see something close to this:
WAZUH_VERSION="v4.14.9"
WAZUH_REVISION="..."
WAZUH_TYPE="server"
active
active
active
Wazuh agent_control. List of available agents:
ID: 000, Name: wazuh-manager (server), IP: 127.0.0.1, Active/Local
ID: 001, Name: web-01, IP: 10.0.2.15, Active
If your agent shows Disconnected, fix that first. The rest of the tutorial assumes agent 001 is a web server running PHP.
Step 3: Inventory PHP Packages Through the Wazuh API
Before you write any rule, find out what you are defending. Wazuh’s syscollector module already collects installed packages from each agent. The Wazuh API exposes that inventory, which gives you a fleet-wide list of PHP packages in one loop.
First request a token. The API listens on port 55000 on the manager. Replace the password with the one from your install files.
WAZUH_API="https://localhost:55000"
TOKEN=$(curl -sk -u wazuh:'YOUR_API_PASSWORD' -X POST "$WAZUH_API/security/user/authenticate?raw=true")
echo "${TOKEN:0:20}..."
Then loop over every active agent and list packages whose name contains php:
for id in $(curl -sk -H "Authorization: Bearer $TOKEN" \
"$WAZUH_API/agents?status=active&select=id" | jq -r '.data.affected_items[].id' | grep -v '^000$'); do
echo "== agent $id"
curl -sk -H "Authorization: Bearer $TOKEN" \
"$WAZUH_API/syscollector/$id/packages?search=php&select=name,version&limit=200" \
| jq -r '.data.affected_items[] | "\(.name)\t\(.version)"' | sort
done
A host with PHP 8.4 on Ubuntu returns something like this:
== agent 001
php8.4-cli 8.4.26-1ubuntu1
php8.4-common 8.4.26-1ubuntu1
php8.4-fpm 8.4.26-1ubuntu1
Read the output with suspicion. A version string like 8.4.26-1ubuntu1 below 8.4.27 looks vulnerable, but Debian-family and Red Hat-family vendors may ship the fix inside a package whose upstream number never changes. That is exactly why Step 4 inspects changelogs instead of trusting the number. For now, use this list to find hosts you forgot, such as the old staging box with php7.4 still installed. For an outside-in view of which of those hosts answer on the internet, pair this inventory with a scan like the one in the Nuclei scanner setup guide.
Wazuh’s built-in vulnerability detection may or may not list the two CVE IDs on your agents today. Open the dashboard, go to the vulnerability detection view for the agent, and search for CVE-2026-109618. If it shows up, good. If it does not, do not read that as a clean bill of health. The documentation we checked does not confirm feed coverage for these IDs yet.
Step 4: Write the PHP2Shell Audit Script
The audit script is the heart of the project. It finds every PHP binary on the host, compares its version to the fixed release for its branch, looks for vendor backports in package changelogs, and checks whether running web processes still map a deleted binary. Each finding prints as one key=value line, so a Wazuh rule can match it with a simple string.
Create the file as root:
sudo install -m 750 -o root -g root /dev/null /usr/local/bin/php2shell-audit.sh
sudo nano /usr/local/bin/php2shell-audit.sh
Paste this content:
#!/usr/bin/env bash
# php2shell-audit.sh: one key=value line per PHP binary and per running web stack
export PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
set -u
shopt -s nullglob
declare -A FIXED=( ["8.5"]="8.5.12" ["8.4"]="8.4.27" ["8.3"]="8.3.36" ["8.2"]="8.2.35" )
CVES=("CVE-2026-109618" "CVE-2026-109619")
has_backport() {
local real="$1" pkg log c
if command -v rpm >/dev/null 2>&1; then
pkg=$(rpm -qf "$real" 2>/dev/null) || pkg=""
if [ -n "$pkg" ]; then
log=$(rpm -q --changelog "$pkg" 2>/dev/null)
for c in "${CVES[@]}"; do grep -q "$c" <<<"$log" || return 1; done
return 0
fi
fi
if command -v dpkg >/dev/null 2>&1; then
pkg=$(dpkg -S "$real" 2>/dev/null | head -n1 | cut -d: -f1)
[ -n "$pkg" ] || return 1
for c in "${CVES[@]}"; do
zgrep -qs "$c" /usr/share/doc/"$pkg"/changelog.Debian.gz || return 1
done
return 0
fi
return 1
}
declare -A seen
bins=(/usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php* \
/opt/remi/php*/root/usr/bin/php /opt/plesk/php/*/bin/php /opt/alt/php*/usr/bin/php)
for bin in "${bins[@]}"; do
[ -x "$bin" ] || continue
real=$(readlink -f "$bin")
[ -n "${seen[$real]:-}" ] && continue
seen[$real]=1
ver=$("$real" -r 'echo PHP_VERSION;' 2>/dev/null) || continue
[ -n "$ver" ] || continue
num=${ver%%[-+~]*}
branch=${num%.*}
fixed=${FIXED[$branch]:-}
if [ -z "$fixed" ]; then
if has_backport "$real"; then status=BACKPORT_PATCHED; else status=UNSUPPORTED_BRANCH; fi
else
lowest=$(printf '%s\n%s\n' "$num" "$fixed" | sort -V | head -n1)
if [ "$lowest" = "$fixed" ]; then status=PATCHED
elif has_backport "$real"; then status=BACKPORT_PATCHED
else status=VULNERABLE; fi
fi
echo "php2shell-audit: binary=$real version=$ver branch=$branch status=$status"
done
declare -A exe_seen
for pid in $(pgrep -x 'apache2|httpd|frankenphp|php-fpm[0-9.]*|lsphp[0-9]*' 2>/dev/null); do
exe=$(readlink "/proc/$pid/exe" 2>/dev/null) || continue
[ -n "${exe_seen[$exe]:-}" ] && continue
exe_seen[$exe]=1
state=RUNTIME_OK
case "$exe" in *"(deleted)"*) state=RESTART_NEEDED ;; esac
if grep -qs 'libphp.*(deleted)' "/proc/$pid/maps"; then state=RESTART_NEEDED; fi
echo "php2shell-audit: runtime=\"$exe\" pid=$pid status=$state"
done
exit 0
Run it by hand on the web server:
sudo /usr/local/bin/php2shell-audit.sh
On a lab VM with an unpatched PHP 8.4 build, the output looks like this:
php2shell-audit: binary=/usr/bin/php8.4 version=8.4.26 branch=8.4 status=VULNERABLE
php2shell-audit: runtime="/usr/sbin/php-fpm8.4" pid=912 status=RUNTIME_OK
Here is how the logic works. The FIXED map holds the four fixed releases from Step 1. sort -V performs a version-aware comparison, so 8.4.9 correctly sorts below 8.4.27, which a plain string sort gets wrong. If the version is below the fixed release, the script asks has_backport to look for both CVE IDs in the package changelog. If both appear, the status becomes BACKPORT_PATCHED instead of VULNERABLE. A branch with no entry in the map (8.1, 8.0, 7.4) falls into UNSUPPORTED_BRANCH unless a changelog proves a vendor fix.
The second loop catches a subtler failure. A package manager can replace /usr/sbin/php-fpm8.4 on disk while the old master process keeps running the deleted binary. Linux marks that link in /proc/PID/exe with the word deleted. For Apache with mod_php, the script checks the memory map for a deleted libphp file. Either case means the server runs old code even though apt says it is current.
Step 5 and 6: Ship the Output to Wazuh and Write the Rules
Step 5: Run the script as a Wazuh command
Open the agent configuration on the web server and add a localfile block that runs the script every hour. The command log format treats each output line as its own event, which is what you want here.
sudo nano /var/ossec/etc/ossec.conf
Add this inside the <ossec_config> element, next to the other localfile blocks:
<localfile>
<log_format>command</log_format>
<command>/usr/local/bin/php2shell-audit.sh</command>
<alias>php2shell-audit</alias>
<frequency>3600</frequency>
</localfile>
Restart the agent and confirm it picked up the block:
sudo systemctl restart wazuh-agent
sudo grep -i php2shell /var/ossec/logs/ossec.log | tail -5
The agent runs the command on start and then every 3,600 seconds. When it sends a line to the manager, Wazuh prefixes it with ossec: output: and the alias, so the raw event reads ossec: output: 'php2shell-audit': php2shell-audit: binary=.... That prefix is what the parent rule matches in the next step.
Step 6: Write the audit rules and test them with wazuh-logtest
On the manager, create a rules file. Wazuh loads every XML file in /var/ossec/etc/rules/, and IDs from 100000 upward are reserved for custom rules.
sudo nano /var/ossec/etc/rules/php2shell_audit_rules.xml
<group name="php,php2shell,">
<rule id="100100" level="0">
<if_sid>530</if_sid>
<match>^ossec: output: 'php2shell-audit'</match>
<description>PHP2Shell audit output</description>
</rule>
<rule id="100101" level="3">
<if_sid>100100</if_sid>
<match>status=PATCHED|status=BACKPORT_PATCHED</match>
<description>PHP2Shell audit: PHP build carries the CVE-2026-109618 and CVE-2026-109619 fixes</description>
</rule>
<rule id="100102" level="12">
<if_sid>100100</if_sid>
<match>status=VULNERABLE</match>
<description>PHP2Shell audit: PHP build is below the fixed release for its branch</description>
<mitre>
<id>T1190</id>
</mitre>
</rule>
<rule id="100103" level="10">
<if_sid>100100</if_sid>
<match>status=RESTART_NEEDED</match>
<description>PHP2Shell audit: web process still runs a replaced PHP binary, restart required</description>
</rule>
<rule id="100104" level="9">
<if_sid>100100</if_sid>
<match>status=UNSUPPORTED_BRANCH</match>
<description>PHP2Shell audit: PHP branch has no upstream fix and no vendor backport proof</description>
</rule>
</group>
Rule 530 is Wazuh’s stock parent for command output, so the chain is 530, then 100100, then the five outcome rules. Validate the XML before you restart anything, because a syntax error in a custom file can stop the manager from starting:
sudo chown root:wazuh /var/ossec/etc/rules/php2shell_audit_rules.xml
sudo chmod 660 /var/ossec/etc/rules/php2shell_audit_rules.xml
sudo /var/ossec/bin/wazuh-analysisd -t && echo "config OK"
sudo systemctl restart wazuh-manager
Now replay a sample event through the rule engine without waiting an hour:
printf '%s\n' "ossec: output: 'php2shell-audit': php2shell-audit: binary=/usr/bin/php8.4 version=8.4.26 branch=8.4 status=VULNERABLE" \
| sudo /var/ossec/bin/wazuh-logtest
The abridged output should end like this:
**Phase 3: Completed filtering (rules).
id: '100102'
level: '12'
description: 'PHP2Shell audit: PHP build is below the fixed release for its branch'
groups: '['php', 'php2shell']'
firedtimes: '1'
**Alert to be generated.
If logtest instead stops at rule 530 or earlier, recheck the quoting in your match string. The most common typo is a missing colon after the alias.
Step 7: Patch, Restart, and Re-Audit
An alert only matters if it ends in a fix. Pick the vulnerable host that Wazuh just flagged and patch it. The PHP project says to update to 8.5.12, 8.4.27, 8.3.36, or 8.2.35, or to a vendor package that contains the fixes.
On Debian and Ubuntu, upgrade the PHP packages for your branch and restart the service that serves requests:
sudo apt-get update
sudo apt-get install --only-upgrade php8.4-cli php8.4-common php8.4-fpm
sudo systemctl restart php8.4-fpm
# Apache with mod_php instead of FPM:
# sudo systemctl restart apache2
On RHEL-family systems:
sudo dnf upgrade 'php*'
sudo systemctl restart php-fpm
Distribution timing varies. A patch tracker reports that Debian’s security tracker listed php8.4 8.4.27-1 as fixed in forky and sid by October 23, and that Remi’s repository published 8.2.35, 8.3.36, 8.4.27, and 8.5.12 RPMs for Fedora and Enterprise Linux. If your repository has not caught up, check the vendor’s bulletin, such as Red Hat’s RHSB-2026-013, before you decide to compile from source.
A stopgap for PHP 8.5 only
If you cannot upgrade immediately on PHP 8.5, the PHP project documents a partial mitigation. Set fatal_error_backtraces=0 and restart every worker. The project says this blocks the confirmed fatal backtrace path only, so treat it as a short bridge and not a fix.
php -r 'var_dump(ini_get("fatal_error_backtraces"));'
echo 'fatal_error_backtraces=0' | sudo tee /etc/php/8.5/fpm/conf.d/99-php2shell-stopgap.ini
sudo systemctl restart php8.5-fpm
Re-run the audit
Run the script again and confirm both lines flip:
sudo /usr/local/bin/php2shell-audit.sh
php2shell-audit: binary=/usr/bin/php8.4 version=8.4.27 branch=8.4 status=PATCHED
php2shell-audit: runtime="/usr/sbin/php-fpm8.4" pid=3320 status=RUNTIME_OK
Wazuh will report the change on the next hourly run, or immediately after sudo systemctl restart wazuh-agent. In the dashboard, the level 12 alert for that host stops, and a level 3 alert from rule 100101 appears. That level 3 event is your audit trail. Keep it, because it proves when each server crossed into a fixed state.
Steps 8 and 9: Collect PHP Logs and Correlate Crashes
The audit tells you what is installed. Logs tell you what is happening. Because both CVEs involve memory handling, repeated worker crashes and memory exhaustion deserve a rule. Remember the caveat from earlier. These are heuristics, not exploit signatures.
Step 8: Add the log sources and a decoder
On the web server, tell the agent to read the PHP-FPM log and the PHP error log. Paths differ by distribution. Debian and Ubuntu usually write the FPM log to /var/log/php8.4-fpm.log, while RHEL-family systems use /var/log/php-fpm/error.log. Check your pool configuration if neither file exists.
<localfile>
<log_format>syslog</log_format>
<location>/var/log/php8.4-fpm.log</location>
</localfile>
<localfile>
<log_format>syslog</log_format>
<location>/var/log/php_errors.log</location>
</localfile>
PHP and PHP-FPM write a bracketed timestamp first, and the stock syslog decoders do not recognize that shape. Add a small decoder pair on the manager:
sudo nano /var/ossec/etc/decoders/php2shell_decoders.xml
<decoder name="php-log">
<prematch>^\[\d+-\w+-\d+ \d+:\d+:\d+</prematch>
</decoder>
<decoder name="php-log-fpm-exit">
<parent>php-log</parent>
<regex>\[pool (\S+)\] child (\d+) exited on signal (\d+)</regex>
<order>pool,pid,signal</order>
</decoder>
The first decoder claims any line that begins with a date such as [25-Oct-2026 08:14:02. The child decoder extracts the pool name, the worker PID, and the signal number from a worker exit message, which makes the signal available to rules as a field. Restart the agent after saving the ossec.conf change, and validate the decoder on the manager the same way you did the rules.
Step 9: Alert on worker crashes and memory exhaustion
Create the second rules file:
sudo nano /var/ossec/etc/rules/php2shell_log_rules.xml
<group name="php,php2shell,">
<rule id="100110" level="3">
<decoded_as>php-log</decoded_as>
<match>PHP Fatal error|Allowed memory size|Out of memory</match>
<description>PHP fatal error or memory exhaustion logged</description>
</rule>
<rule id="100111" level="8">
<decoded_as>php-log</decoded_as>
<field name="signal" type="pcre2">^(11|6|7)$</field>
<description>PHP-FPM worker killed by signal $(signal) in pool $(pool)</description>
</rule>
<rule id="100112" level="12" frequency="3" timeframe="300">
<if_matched_sid>100111</if_matched_sid>
<description>PHP2Shell heuristic: 3 or more PHP worker crashes within 5 minutes</description>
<mitre>
<id>T1190</id>
</mitre>
</rule>
<rule id="100113" level="10" frequency="10" timeframe="120">
<if_matched_sid>100110</if_matched_sid>
<description>PHP2Shell heuristic: 10 or more PHP fatal or memory errors within 2 minutes</description>
</rule>
</group>
Signals 11, 6, and 7 are SIGSEGV, SIGABRT, and SIGBUS, the usual signatures of a worker dying from memory corruption. Rule 100112 fires when three of them land on the same agent within five minutes. An application stuck in an out-of-memory loop can produce the same pattern.
Fix permissions, run the config test, restart the manager, and replay a crash line through logtest:
sudo chown root:wazuh /var/ossec/etc/rules/php2shell_log_rules.xml /var/ossec/etc/decoders/php2shell_decoders.xml
sudo chmod 660 /var/ossec/etc/rules/php2shell_log_rules.xml /var/ossec/etc/decoders/php2shell_decoders.xml
sudo /var/ossec/bin/wazuh-analysisd -t && sudo systemctl restart wazuh-manager
printf '%s\n' "[25-Oct-2026 08:14:02] WARNING: [pool www] child 4187 exited on signal 11 (SIGSEGV) after 3.512 seconds from start" \
| sudo /var/ossec/bin/wazuh-logtest
**Phase 2: Completed decoding.
name: 'php-log'
pool: 'www'
pid: '4187'
signal: '11'
**Phase 3: Completed filtering (rules).
id: '100111'
level: '8'
description: 'PHP-FPM worker killed by signal 11 in pool www'
If you see name: 'php-log' and the three extracted fields, the decoder works. Paste the same line three times in quick succession on a live system and rule 100112 fires on the third event. The frequency counter needs real events within the timeframe, and logtest alone does not always carry state between lines.
Steps 10 and 11: Web Error Bursts and File Integrity Monitoring
Step 10: Correlate bursts of 5xx responses by source IP
A server that returns repeated 500 responses to one address is either under load or under probe. Wazuh already decodes common web access logs. Add the access log on the web server:
<localfile>
<log_format>apache</log_format>
<location>/var/log/apache2/access.log</location>
</localfile>
For nginx, point location at /var/log/nginx/access.log. Both use the combined format that the stock web decoders understand. Then add the correlation rules on the manager:
sudo nano /var/ossec/etc/rules/php2shell_web_rules.xml
<group name="web,php2shell,">
<rule id="100130" level="3">
<if_sid>31100</if_sid>
<id>^50\d$</id>
<description>Web server returned a 5xx response</description>
</rule>
<rule id="100131" level="10" frequency="20" timeframe="60">
<if_matched_sid>100130</if_matched_sid>
<same_source_ip />
<description>PHP2Shell heuristic: 20 or more 5xx responses to one source IP within 60 seconds</description>
<mitre>
<id>T1190</id>
</mitre>
</rule>
</group>
Build the rule on the decoded access-log lines and match on the HTTP status code. A low error count in a minute from one address is a deliberately loose starting point. If your site sits behind a CDN or load balancer, every request appears to come from the proxy, so first configure the web server to log the real client address from X-Forwarded-For. Otherwise the rule counts your whole audience as one visitor.
Step 11: Watch the web root and PHP configuration
Remote code execution usually ends with a new file or a changed setting. Wazuh’s file integrity monitoring (FIM) catches both. Add this to the agent’s ossec.conf inside the existing <syscheck> block:
<directories check_all="yes" realtime="yes">/var/www</directories>
<directories check_all="yes" realtime="yes">/etc/php</directories>
<ignore type="sregex">\.log$|/cache/|/node_modules/</ignore>
On RHEL-family systems, watch /etc/php.ini and /etc/php.d instead of /etc/php. The realtime="yes" attribute uses inotify so alerts arrive in seconds, not at the next scheduled scan. Then add rules on the manager:
sudo nano /var/ossec/etc/rules/php2shell_fim_rules.xml
<group name="syscheck,php2shell,">
<rule id="100140" level="10">
<if_sid>554</if_sid>
<field name="file" type="pcre2">(?i)/(uploads?|tmp|cache|files)/.*\.(php\d?|phtml|phar)$</field>
<description>New executable PHP file added under an upload, temp, or cache path</description>
<mitre>
<id>T1505.003</id>
</mitre>
</rule>
<rule id="100141" level="7">
<if_sid>550</if_sid>
<field name="file" type="pcre2">(?i)/etc/php.*\.(ini|conf)$</field>
<description>PHP configuration file modified</description>
</rule>
</group>
Rule 554 is the stock event for a file added to the system, and 550 covers modifications. Test the first rule on the web server with a harmless file. Adjust the path to match your own document root:
sudo mkdir -p /var/www/html/wp-content/uploads
sudo touch /var/www/html/wp-content/uploads/zz-test.php
sleep 5
sudo grep -F '"id":"100140"' /var/ossec/logs/alerts/alerts.json | tail -1 | jq '{rule: .rule.id, level: .rule.level, path: .syscheck.path, event: .syscheck.event}'
sudo rm /var/www/html/wp-content/uploads/zz-test.php
Run the grep on the manager, since alerts are written there. You should get:
{
"rule": "100140",
"level": 10,
"path": "/var/www/html/wp-content/uploads/zz-test.php",
"event": "added"
}
A caution on noise. Content management systems write PHP files into cache directories during normal operation, and deploy tools replace code in bulk. Start with level 10 on uploads only, then widen the pattern after you learn what your applications do.
Steps 12 and 13: Roll Out to a Group and Build the Dashboard View
Step 12: Push the agent configuration through a group
Editing ossec.conf by hand on one host works for a lab. For a fleet, create an agent group and put the shared localfile blocks in the group’s agent.conf.
sudo /var/ossec/bin/agent_groups -a -g php-servers -q
sudo /var/ossec/bin/agent_groups -a -i 001 -g php-servers -q
sudo nano /var/ossec/etc/shared/php-servers/agent.conf
<agent_config>
<localfile>
<log_format>command</log_format>
<command>/usr/local/bin/php2shell-audit.sh</command>
<alias>php2shell-audit</alias>
<frequency>3600</frequency>
</localfile>
<localfile>
<log_format>syslog</log_format>
<location>/var/log/php8.4-fpm.log</location>
</localfile>
<syscheck>
<directories check_all="yes" realtime="yes">/var/www</directories>
<directories check_all="yes" realtime="yes">/etc/php</directories>
</syscheck>
</agent_config>
Commands pushed through centralized configuration only run if the agent allows remote commands. Enable that on each agent, then restart it:
echo 'logcollector.remote_commands=1' | sudo tee -a /var/ossec/etc/local_internal_options.conf
sudo systemctl restart wazuh-agent
Group configuration moves the Wazuh settings, not the script. Copy php2shell-audit.sh to every host with your configuration management tool, and keep its permissions at root-only. If an attacker could edit the script, they could silence the very check that watches them.
Step 13: Build a dashboard view and rotate secrets
Open the Wazuh dashboard, go to the Threat Hunting module, and open the Events tab. Filter with this query to see every PHP2Shell finding in one place:
rule.groups:php2shell
To list only hosts that need action, narrow it to the three audit outcomes that demand work:
rule.id:(100102 OR 100103 OR 100104)
Save both searches and pin them to a dashboard, with a third for rule 100101 so the patched count sits next to the open count. A simple pair of numbers tells leadership how far the rollout has gone: hosts still flagged and hosts confirmed fixed.
For notifications, add an email block to the manager’s ossec.conf that fires on the rules that need a human. This assumes the global SMTP settings are already configured:
<email_alerts>
<email_to>[email protected]</email_to>
<rule_id>100102,100103,100112</rule_id>
<do_not_delay />
</email_alerts>
The secrets rotation drill
A patched server is not necessarily a clean server. Remote code execution means an attacker who succeeded ran code as your web user. If any internet-facing host ran a vulnerable build, add this checklist to your incident ticket and work through it:
- Review the FIM, crash, and 5xx alerts for the window between October 21, 2026 and your patch time.
- Search the web root for PHP files with recent modification times that no deploy created.
- Rotate database passwords,
.envvalues, API keys, signing secrets, and any tokens stored on the host. - Invalidate active sessions and force a password reset where the application stores user credentials.
- Check cron jobs, systemd units, and SSH
authorized_keysfor entries you did not add. - Record the result in the ticket so the audit trail ends with a decision, not a question.
None of this proves a breach. It proves you looked.
Rule map at a glance
Before the pitfalls, here is the full rule map. Keep it next to your runbook so the on-call engineer knows what each alert means and what to do first.
| Rule ID | Trigger | Level | First response |
|---|---|---|---|
| Not listed | Audit finds a PHP build below the fixed release | 12 | Patch, restart the service, re-run the audit |
| Not listed | Web process still runs a replaced binary | 10 | Restart PHP-FPM, Apache, or FrankenPHP |
| Not listed | Branch with no upstream fix and no backport proof | 9 | Check the vendor bulletin or upgrade the branch |
| 100112 | 3 or more worker crashes in 5 minutes | 12 | Check access logs and FIM alerts for the same window |
| 100113 | 10 or more PHP fatal or memory errors in 2 minutes | 10 | Look for a runaway request or a probing source |
| 100131 | Repeated 5xx responses from one IP in 60 seconds | 10 | Review the source IP, then block at the edge if hostile |
| Not listed | New PHP file under an upload, temp, or cache path | 10 | Inspect the file, then isolate the host if it is unknown |
| Not listed | PHP configuration file modified | 7 | Compare against your configuration management source |
Common Pitfalls That Break PHP2Shell Detection
Most failed rollouts trip on the same handful of mistakes. Avoid these six and you will skip a week of confusion.
Pitfall 1: Trusting the version string on Debian, Ubuntu, and Red Hat
Vendors backport security fixes into older upstream numbers. A package reporting 8.3.6 can contain the PHP2Shell fix while a plain comparison calls it vulnerable. The audit script handles this by searching changelogs for both CVE IDs, but that only works if the vendor lists them. If your vendor’s changelog is terse, confirm with the vendor’s security bulletin and set a manual override list on the manager.
Pitfall 2: Patching the package and forgetting the restart
PHP-FPM workers and Apache children load PHP into memory once. Replacing files on disk changes nothing for a process that has already started. The RESTART_NEEDED status exists for this reason. Teams that skip the restart report green dashboards while the old code keeps serving requests.
Pitfall 3: Auditing only the CLI binary
The php command on your path is often a different build from the one your web server uses. Plesk, cPanel-style stacks, Remi software collections, and Docker images each carry their own binaries. The script scans several well-known paths, but if you install PHP somewhere unusual, add that path to the bins array. Containers need their own treatment, because a Wazuh agent on the host does not see inside an image. Rebuild images from a patched base and audit the registry, not just the host.
Pitfall 4: Rule ID collisions and the missing parent
Custom rule IDs must be unique across every file in /var/ossec/etc/rules/. If another team already uses 100100, one definition wins and the other quietly misbehaves. Also remember that a child rule only fires when its if_sid parent fires. A typo in the parent match text silences all five audit rules without a single error message.
Pitfall 5: Counting a CDN as one angry visitor
Behind a CDN, reverse proxy, or load balancer, the access log shows the proxy’s address on every line. Rule 100131 then groups all your traffic under one IP and either fires constantly or hides the real source. Configure the web server to log the client address from the forwarded header before you rely on any per-IP correlation.
Pitfall 6: Treating end-of-life branches as someone else’s problem
PHP 8.1 and older no longer receive updates from the PHP project. The shared hosting provider or the Linux vendor may backport fixes, and the project itself provided minimal source backports for 7.4, 8.0, and 8.1, but none of that equals a supported branch. Every UNSUPPORTED_BRANCH alert deserves a ticket with an owner and a migration date, not an acknowledgment click.
Troubleshooting: 10 Problems and Their Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
No php2shell-audit events reach the manager | Agent not restarted, or script not executable | Run sudo chmod 750 on the script, restart the agent, then search /var/ossec/logs/ossec.log for php2shell |
| Rule 100100 fires but the follow-up rule never does | The status text in your match string differs from the script output | Compare the raw event in the dashboard with the match value character by character |
wazuh-analysisd -t reports an error | Malformed XML, an unescaped &, or a duplicate rule ID | Open the file named in the error, fix the line it reports, and rerun the test before restarting |
| Manager will not start after a rule edit | A custom file failed validation | Move the new file out of /var/ossec/etc/rules/, restart, then fix and re-add it |
Command from agent.conf is ignored | Remote commands are disabled on the agent | Set logcollector.remote_commands=1 in local_internal_options.conf and restart the agent |
| Audit marks a patched Ubuntu or Debian host as vulnerable | Changelog lacks the CVE IDs, or the doc folder has no changelog.Debian.gz | Run zgrep -c CVE-2026-109618 /usr/share/doc/php8.4-common/changelog.Debian.gz and compare with the vendor bulletin |
| Logtest shows no decoder for PHP log lines | Your log timestamp format differs from the decoder prematch | Check whether error_log goes to syslog or uses ISO timestamps, then adjust the prematch regex |
| FIM never alerts in realtime | inotify watch limit reached on a large web root | Raise fs.inotify.max_user_watches with sysctl, restart the agent, and check ossec.log for warnings |
| Access log rules never fire | Wrong log path, wrong format, or permission denied | Confirm the file path, use apache as the log format for combined logs, and make sure the agent can read the file |
| API calls are rejected as unauthorized | Wrong API user or expired token | Request a new token, and use the API credentials from the install files, not the dashboard login |
Advanced Tips for Production Fleets
Once the basics work, a few upgrades turn this from a lab project into fleet tooling.
Automate containment, carefully. Wazuh active response can block an address when rule 100131 fires. Add a block like the one below to the manager, but only after you confirm that your logs record real client addresses. A mistaken block on a CDN node would cut off every visitor.
<active-response>
<command>firewall-drop</command>
<location>local</location>
<rules_id>100131</rules_id>
<timeout>600</timeout>
</active-response>
Layer a WAF in front. AWS published guidance on using AWS WAF to apply mitigations for the two CVEs. A WAF rule buys time on hosts you cannot patch today, but it does not replace the fix, and Wazuh’s audit will keep flagging the host until the PHP build changes. That is the correct behavior.
Choose the right tool for the layer. Wazuh covers host telemetry well. If you are weighing it against heavier stacks for the log pipeline, the Wazuh vs Elastic vs Graylog comparison lays out the cost trade-offs. For network-level detection on the same traffic, the Wazuh vs Security Onion vs CrowdSec breakdown shows where each tool fits.
Cover Windows hosts separately. The audit script targets Linux. If you run PHP behind IIS, collect process creation events with Sysmon, as described in the guide to Sysmon setup for ransomware detection, and write a PowerShell equivalent of the version check. XAMPP on Windows is a common culprit, since its installers bundle a PHP build that ages quietly.
Tighten the cadence during an incident. Drop the frequency value from 3,600 to 900 seconds while you chase down stragglers, then raise it again. Hourly runs cost almost nothing, but a fifteen-minute loop gives your change tickets faster feedback.
Keep evidence. Wazuh retains alerts in the indexer according to your index lifecycle policy. Export the level 3 rule 100101 events to your ticketing system when a host reaches a fixed state. Auditors prefer a timestamped event to a screenshot.
Run a second validation pass. Pair the audit with an external scan. A scanner sees what an attacker sees, including hosts that no agent covers. Disagreements between the two are the interesting cases, because they point to shadow servers and forgotten containers.
The Complete Working Project
Everything above fits in a small repository you can version and redeploy. It also fits neatly into the rest of the cybersecurity coverage if you want to extend it with other detections. Use this layout:
php2shell-wazuh/
agent/
php2shell-audit.sh # Step 4
ossec-snippet.xml # Steps 5, 8, 11 as one ossec_config block
manager/
rules/
php2shell_audit_rules.xml # Step 6
php2shell_log_rules.xml # Step 9
php2shell_web_rules.xml # Step 10
php2shell_fim_rules.xml # Step 11
decoders/
php2shell_decoders.xml # Step 8
tests/
logtest-cases.txt
run-logtests.sh
deploy-manager.sh
deploy-agent.sh
The agent snippet wraps the localfile and syscheck blocks from earlier steps in their own root element, which Wazuh merges with the rest of ossec.conf:
<ossec_config>
<localfile>
<log_format>command</log_format>
<command>/usr/local/bin/php2shell-audit.sh</command>
<alias>php2shell-audit</alias>
<frequency>3600</frequency>
</localfile>
<localfile>
<log_format>syslog</log_format>
<location>/var/log/php8.4-fpm.log</location>
</localfile>
<localfile>
<log_format>apache</log_format>
<location>/var/log/apache2/access.log</location>
</localfile>
<syscheck>
<directories check_all="yes" realtime="yes">/var/www</directories>
<directories check_all="yes" realtime="yes">/etc/php</directories>
</syscheck>
</ossec_config>
Deploy the manager side with one script:
#!/usr/bin/env bash
# deploy-manager.sh
set -euo pipefail
SRC="$(cd "$(dirname "$0")" && pwd)/manager"
sudo install -m 660 -o root -g wazuh "$SRC"/rules/*.xml /var/ossec/etc/rules/
sudo install -m 660 -o root -g wazuh "$SRC"/decoders/*.xml /var/ossec/etc/decoders/
sudo /var/ossec/bin/wazuh-analysisd -t
sudo systemctl restart wazuh-manager
echo "manager deployed"
And the agent side, which stays safe to run twice:
#!/usr/bin/env bash
# deploy-agent.sh
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
sudo install -m 750 -o root -g root "$HERE/agent/php2shell-audit.sh" /usr/local/bin/php2shell-audit.sh
if ! grep -q 'php2shell-audit' /var/ossec/etc/ossec.conf; then
sudo cp /var/ossec/etc/ossec.conf /var/ossec/etc/ossec.conf.bak
sudo tee -a /var/ossec/etc/ossec.conf < "$HERE/agent/ossec-snippet.xml" > /dev/null
fi
sudo systemctl restart wazuh-agent
sleep 5
sudo /usr/local/bin/php2shell-audit.sh
sudo grep -iE 'error|critical' /var/ossec/logs/ossec.log | tail -5 || true
Finally, add a regression test so a later rule edit cannot silently break detection. Save the sample events in tests/logtest-cases.txt, one per line, with the expected rule ID first:
100102|ossec: output: 'php2shell-audit': php2shell-audit: binary=/usr/bin/php8.4 version=8.4.26 branch=8.4 status=VULNERABLE
100101|ossec: output: 'php2shell-audit': php2shell-audit: binary=/usr/bin/php8.4 version=8.4.27 branch=8.4 status=PATCHED
100103|ossec: output: 'php2shell-audit': php2shell-audit: runtime="/usr/sbin/php-fpm8.4" pid=912 status=RESTART_NEEDED
100104|ossec: output: 'php2shell-audit': php2shell-audit: binary=/usr/bin/php7.4 version=7.4.33 branch=7.4 status=UNSUPPORTED_BRANCH
100111|[25-Oct-2026 08:14:02] WARNING: [pool www] child 4187 exited on signal 11 (SIGSEGV) after 3.512 seconds from start
100110|[25-Oct-2026 08:14:05 UTC] PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 4096 bytes) in /var/www/html/index.php on line 12
#!/usr/bin/env bash
# tests/run-logtests.sh (run with sudo on the manager)
set -u
pass=0
fail=0
while IFS='|' read -r expected line; do
[ -z "$expected" ] && continue
out=$(printf '%s\n' "$line" | /var/ossec/bin/wazuh-logtest 2>&1)
if grep -q "id: '$expected'" <<<"$out"; then
echo "PASS $expected"; pass=$((pass+1))
else
echo "FAIL $expected :: $line"; fail=$((fail+1))
fi
done < "$(dirname "$0")/logtest-cases.txt"
echo "passed=$pass failed=$fail"
[ "$fail" -eq 0 ]
A healthy run ends like this:
PASS 100102
PASS 100101
PASS 100103
PASS 100104
PASS 100111
PASS 100110
passed=6 failed=0
Wire run-logtests.sh into your CI pipeline or run it after every rules change. A green run takes seconds, and it protects the detections you will need on the next critical PHP bulletin.
FAQ: Wazuh SIEM and PHP2Shell
What is PHP2Shell?
PHP2Shell is the nickname for CVE-2026-109618 and CVE-2026-109619, two critical vulnerabilities in PHP core. Both allow unauthenticated remote code execution against PHP applications that serve web requests, and both are rated CVSS v4.0 9.2. The PHP project has fixed them in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35.
Which PHP versions fix PHP2Shell?
The fixed releases are PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35. Builds below those numbers on the same branches are affected. PHP 8.1 and older no longer receive updates from the PHP project, so those systems need a vendor backport or an upgrade.
Does Wazuh have a built-in rule for PHP2Shell?
As of October 25, 2026, the Wazuh documentation we checked does not show an official rule or integration named for PHP2Shell or the two CVE IDs. Community-maintained rules exist on GitHub, but they are unofficial. The custom audit, log, web, and FIM rules in this guide fill the gap until official coverage appears.
Why does the audit flag a patched Debian or Ubuntu server?
Debian-family distributions usually keep the upstream version number and backport fixes into it. The script looks for both CVE IDs in the package changelog to catch this. If the changelog does not name them, the host shows as vulnerable until you confirm the fix with the vendor and add an override.
Do I need to restart services after updating PHP?
Yes. Running PHP-FPM workers, Apache children, and FrankenPHP processes keep the old code in memory until you restart them. The RESTART_NEEDED status in the audit script flags processes that still map a replaced binary.
Will these Wazuh rules stop an attack?
No. They detect and report. The version audit gives reliable answers about exposure, while the crash, memory, 5xx, and FIM rules are heuristics that can miss attacks and flag harmless faults. Patching is the only complete fix, and active response or a WAF can add short-term protection on top.
What should I do if I think a server was exploited?
Isolate the host if you can, preserve the logs and the web root, and review FIM and crash alerts from October 21, 2026 onward. Rotate every database password, API key, signing secret, and token stored on the machine. Then rebuild from a known-good image when the evidence points to a successful compromise.





