PHP2Shell puts a difficult question in front of defenders: when can an organization say its PHP estate has been remediated?
A package update is part of the answer. So are:
- the PHP workers that continue handling requests;
- the containers built before the update;
- the hosting panels that bundle their own runtime;
- the evidence of activity before remediation.
A WAF rule can reduce exposure to covered traffic. It does not prove that every request reaches that WAF, or that no earlier request exposed data.
Remediation is complete only when you can explain what was found, what fixed code is running, what the controls actually cover, and what the historical investigation concluded.
This PHP2Shell mitigation guide separates those questions. It builds on our analysis of the two PHP flaws and the first 60 hours of PHP2Shell, in data.
Alongside it, Brandefense is providing the PHP2Shell Defense Kit. It holds:
- read-only inventory tools;
- experimental Sigma detections;
- YARA triage heuristics;
- SIEM hunts;
- repeatable rule tests.
Its purpose is to make defensive assumptions inspectable. It does not claim a new vulnerability, a successful exploit reproduction or a production prevention rate.
Start With the Two Failure Modes
In one line: the original advisories cover two distinct memory-safety problems in PHP itself.
- CVE-2026-109618 concerns out-of-memory handling. A failure can interrupt PHP while internal state is only partially initialized. Later cleanup may operate on that invalid state.
- CVE-2026-109619 concerns stale global state. Memory belonging to a request can be released while a surviving worker retains references to it. A later operation may consume those references before initialization replaces them.
The exact path depends on the runtime, configuration and application. [1][2] These distinctions matter operationally:
- Restricting one input feature does not repair every error path.
- Terminating a worker changes the lifecycle differently from allowing it to serve another request.
- The advisories distinguish Apache mod_php, FPM and other environments. “We use FPM” is not a general exemption. [1][2]
The practical consequence is that a defensive claim needs scope: which build, which worker, which path, which control and which evidence?
1. Find the Runtime Behind the Application
In one line: an inventory of WordPress installations is not an inventory of PHP.
PHP may sit inside any of these:
- a control panel or webmail product;
- an appliance;
- an internal business application;
- a staging copy;
- a container with an unrelated image name.
Application dependency manifests can identify places to investigate. They do not establish the PHP version currently serving HTTP. Our note on which enterprise tools run on PHP lists common places to look.
Collect evidence from several layers:
- Installed software: exact package version, architecture and vendor release. Remaining question: does that release contain both fixes?
- Running processes: executable and mapped PHP module paths, process lifetime. Remaining question: is an old build still running?
- Containers: image references, image IDs and rollout records. Remaining question: what PHP is inside the running image?
- Source and build definitions: PHP image references and runtime installation hints. Remaining question: was that definition deployed?
- HTTP: an advertised PHP version, where present. Remaining question: is the banner accurate, complete and backported?
The kit’s Linux collector keeps these as evidence rather than collapsing them into a clean or vulnerable verdict.
- Its source review identifies candidate serializer changes and lifecycle operations without exporting source text.
- Optional Docker and Kubernetes metadata reads are explicitly enabled.
- No container commands, Secrets, ConfigMaps or environment values are collected.
Coverage gaps are part of the result. Permission failures, skipped symlinks, unsupported package managers and uninspected image layers remain visible. An empty inventory is not proof that PHP is absent.
2. Prove That the Patched Code Is Handling Requests
PHP initially fixed the two original CVEs in 8.2.35, 8.3.36, 8.4.27 and 8.5.12. [3]
Distribution and LTS packages may carry backported fixes under older upstream version strings. Verify the exact vendor package record rather than comparing one version number in isolation. [3] See also the PHP end-of-life dates for branches that get no fix at all.
Then verify the running deployment. Installing a new binary or module does not replace code already mapped into an existing process.
Restart or redeploy the relevant services and every replica through the operational change process. On hosts with suspected compromise, preserve appropriate incident evidence before restarting where feasible.
Use four separate closure records:
- ArtifactThe installed or built artifact contains the fixes.
- RuntimeThe HTTP-serving runtime uses that artifact.
- RestartRelevant workers and replicas have restarted or rolled out.
- ReviewThe effective configuration and related advisory status have been reviewed.
The CLI command php -v alone does not establish the version of a separately installed FPM instance or Apache module. Similarly, a changed response header is supporting evidence rather than a complete deployment proof.
3. Keep Temporary Mitigation Claims Narrow
In one line: temporary measures reduce specific paths; the runtime update is the complete remedy for the original issues.
The PHP project documents configuration measures that reduce specific paths. It recommends the runtime update as the complete remedy for the original issues. [1][2][3]
| Temporary measure | Intended effect | What still needs attention |
|---|---|---|
| Turn off upload progress at startup | Removes confirmed session-related paths | Other stale-state paths remain |
| Turn off fatal error backtraces (PHP 8.5) | Removes the confirmed backtrace producer | Other request-owned state remains |
| Enforce memory_limit=-1 where appropriate | Removes the predictable PHP-limit OOM trigger | Underlying allocation failures and resource exhaustion remain |
| Cap request size and multipart parts | Reduces input-driven work and memory pressure | Small wire inputs can still expand internally |
| Isolate unpatched services | Reduces reachable attack surface | Every alternate route must be included |
The PHP advisory also warns against replacing the memory limit with RLIMIT_AS or RLIMIT_DATA, because those can provoke underlying allocation failures.
It also says that a cgroup limit such as memory.max is not guaranteed to prevent allocation failure. It is not a complete mitigation. Treat container memory limits as blast-radius controls and test what a kill takes down in your own setup. [1]
For the described early session path, apply the setting in php.ini, an FPM pool or server configuration that applies before request parsing, and restart every worker:
; php.ini or FPM pool
session.upload_progress.enabled = 0
In our lab an ini_set() call inside the script did not stop the leak. The same setting in php.ini, the Apache config, .htaccess, an nginx-passed PHP_ADMIN_VALUE and an FPM .user.ini did (details in the lab results below).
- Apply the setting in php.ini, an FPM pool or server configuration, before request parsing.
- Restart every worker.
- Rely on
ini_set()or FPM.user.ini, as PHP’s own guidance says. - Replace the memory limit with
RLIMIT_ASorRLIMIT_DATA.
Treat our .user.ini and .htaccess results as one synthetic path on one build, not as a reason to use them. Hiding warning output is also different from removing an initialization diagnostic that accesses stale state. [2][3]
4. Read the October 23 Follow-Up Precisely
This article’s baseline is 24 October 2026. PHP’s October 23 informational advisory describes a related issue, CVE-2026-115560. It involves an unusual combination:
- the
session.serialize_handleris set tophp_binaryorphp_serialize; - it is switched to
phpwithini_set()beforesession_start(); - upload progress stays enabled.
PHP recommends keeping upload progress disabled pending the regular fix where applicable. Applications that never make that runtime switch do not meet the described condition. Our post on the session upload progress path has the details. [3]
Track that review separately from installation of the original patches. A broad “patched or unpatched” label can obscure the difference between an unfixed original build and a patched build with a separately described conditional path.
Cloudflare’s follow-up documents another important scope condition. Its relevant known-attempt WAF protection relies on the default upload-progress field name. Cloudflare states that customers who changed session.upload_progress.name are not covered by that WAF rule.
That limitation is already publicly documented by the vendor. It is not a bypass discovered by this kit. [4]
5. Verify the WAF’s Action and Observation Point
A useful WAF review answers four questions:
- Are the relevant managed rules deployed in block mode, with exclusions understood?
- Does the actual application configuration match the rule’s assumptions?
- Can requests reach an origin, alternate hostname or internal route outside that enforcement point?
- Does the control reject a request, suppress a response, or only create an alert?
Cloudflare describes PHP2Shell detection using both request and response context. That can provide useful protection, but it also illustrates why the outcome must be stated precisely.
Preventing a response from reaching a requester is not the same observation as proving that no origin-side corruption occurred. [4]
The kit’s HTTP rules consequently require explicit telemetry contracts:
- A default upload-progress parameter is a hunting signal, including legitimate use.
- A large default session cookie is another investigation lead.
- Neither is a universal exploit signature.
Ordinary access logs generally cannot supply all the required metadata. Extract parameter names and cookie lengths at a trusted inspection point. Do not create a new store of full request bodies and live session values just to support these detections.
6. Hunt Below the Application and Beyond the Shell
In one line: review several signals together; each one alone is a lead, and a missing webshell does not rule out exposure.
The Sigma rules cover:
- web runtimes launching unusual shells, interpreters and downloaders;
- commands referencing sensitive locations;
- selected script writes in web roots;
- PHP errors or worker crashes.
They are behavioral leads that require application context. A legitimate CMS update or application subprocess may match.
Existing work from Elastic also shows why web-process behavior is useful across multiple entry vulnerabilities. Its coverage examples distinguish post-exploitation emulation from reproducing the PHP CVEs.
The same distinction applies here: exercising a shell-child rule is a detection test, not a successful PHP2Shell reproduction. [5]
Memory disclosure is an important limit to process-centric detection.
Review these together:
- HTTP and WAF signals;
- PHP errors and worker lifecycle;
- host activity;
- downstream identity use.
Corelight’s open-source Zeek and Suricata work can add network visibility. The relevant HTTP content must be visible at the inspection point; encrypted traffic alone cannot supply those fields. [7]
The YARA rules serve a narrower purpose: offline triage of suspicious PHP text. They look for:
- direct request-to-command patterns;
- certain encoded eval chains;
- request-related writes;
- socket and process co-occurrence;
- research artifacts.
These heuristics can match benign code and miss obfuscation. They do not inspect the vulnerability inside the engine, attribute a file to PHP2Shell, or prove that a memory-only event occurred.
7. Investigate the Period Before Remediation
- 17 Oct 2026Earliest attempt AWS reportedAWS reports an exploitation attempt in MadPot telemetry.
- 21 Oct 2026Public releasePHP fixes the two original CVEs in 8.2.35, 8.3.36, 8.4.27 and 8.5.12.
- 23 Oct 2026Follow-up advisoryPHP describes the related CVE-2026-115560.
AWS reported a PHP2Shell exploitation attempt in MadPot telemetry dating to October 17, and more attempts on October 21 before the public release. Its report says it is unclear whether any of these attempts would have succeeded in real-world systems.
Use the earliest relevant observations to guide a historical review. Do not turn an attempt into a confirmed breach. [8]
On suspicious hosts:
- preserve evidence and restrict access;
- review files, processes, connections, persistence and identity activity;
- if a secret was accessed or credibly exposed, invalidate and rotate it with an understanding of downstream dependencies.
A newly patched runtime cannot revoke an already issued token or remove persistence.
Published IP observations are pivots for investigation. AWS notes that some represent shared anonymization infrastructure. An address match is not unique attribution or a permanent block recommendation. The kit keeps those observations dated and separate from active rules.
What the Defense Kit Validates
The package includes:
- synthetic positive and negative events;
- real YARA compilation and matching;
- Sigma parsing and SPL conversion;
- regression tests for conservative inventory behavior;
- missing-field checks and an explicit loopback HTTP test.
No exploit payload is executed, and no external target is contacted by the test suite.
Those checks establish that the packaged logic behaves as described on the supplied fixtures. They do not:
- establish a production false-positive rate;
- validate every SIEM mapping;
- prove that a WAF prevents every PHP2Shell path.
The lab results below add a second layer of evidence on real records from unpatched and patched builds, with their own limits. Windows execution, native osquery and MDE execution and any code-execution test remain marked as not tested in the validation documentation.
For an internal defensive experiment, record:
- the exact build and topology;
- a separately established reference outcome;
- the control added;
- the telemetry produced;
- the remaining unknowns.
Measure request rejection, worker termination, response suppression and alert generation separately. A failed PoC alone cannot establish protection.
Palo Alto customers can also check the PHP2Shell status and hotfix dates for Palo Alto products.
The companion kit provides a practical way to keep the evidence above together: read-only inventory tools, experimental Sigma detections, YARA triage heuristics, SIEM hunts and repeatable rule tests.
Lab Results
In one line: only the update fixed both problems; several temporary controls stopped part of the lab traffic, none stopped everything.
We built a small lab to test what the controls and detections really do. It used the official PHP containers (Apache with mod_php, and nginx in front of PHP-FPM), started with no network access.
Unpatched builds: 8.3.35, 8.4.26 and 8.5.11. Patched builds: 8.3.36, 8.4.27 and 8.5.12. We sent only our own synthetic requests: no public exploit and no outside target.
- 277Runs in totalSource: Our lab
- 120Memory-exhaustion runsSource: Our lab
- 31Leak runsSource: Our lab
- 42Benign runsSource: Our lab
Limits first. We used one Apple-silicon (arm64) machine, official release builds and short synthetic traffic.
We did not reproduce code execution or memory corruption. Everything below is about the memory-exhaustion error and the stale session data, not about a break-in. “Not observed here” does not mean “safe”.
What Reproduced
Memory exhaustion while PHP reads the request, before any script runs. It happened on all three unpatched lines, on both Apache and FPM.
- Trigger: a 3 MB multipart body with 600,000 part headers, which exhausts the default 128M limit.
- Triggers in a lab profile with the limit lowered to 8M: a 150,000-header multipart body, urlencoded bodies of 0.2 to 6.3 MB and a 4,000-part upload-progress body.
- On the patched builds the two header-flood bodies (600,000 and 150,000 part headers) no longer ran out of memory at all and were answered normally.
- The three smaller-limit triggers still hit the 8M limit on patched builds, but failed with a clean error and left nothing behind.
Stale session data: the upload-progress request received another session’s data in $_SESSION within the same request.
- This happened on all three unpatched lines and both servers, never on the patched ones.
- We used a synthetic marker; in our test the next request on the same worker showed nothing.
- The php-src test
rfc1867_no_user_codefails on 8.3.35 and 8.4.26 and passes on the fixed releases.
Also:
- Not seen: the “next request” tests, which look for damage left behind after the memory error, passed even on the unpatched release builds.
- On FPM the worker that hit the memory error exited with code 70 and was replaced. Apache workers kept running.
- Normal requests worked after every run.
What Each Temporary Control Really Stopped
We tested each control on unpatched 8.3.35 unless noted. Read each row for what it stopped and what it left open.
| Control | What it stopped | What it did not stop |
|---|---|---|
| Upload progress off (session.upload_progress.enabled=0) in php.ini, Apache config, .htaccess, nginx-passed value or FPM .user.ini | The session-data leak. A negative control (.user.ini with only memory_limit=64M) was read by the worker, reported 64M and still leaked, so the stop came from the setting. | Memory exhaustion. It removed only the 4,000-part upload-progress trigger; the other four triggers still ran out of memory. |
| The same setting through ini_set() inside the script | Nothing in our test of the leak. | The leak. |
| memory_limit=-1 | The memory-exhaustion trigger at these sizes. The 128M default is the behaviour that fails. | The leak was unaffected. |
| Front-end body limit of 1 MB | Every body over 1 MB (answered 413, never reached PHP). | Bodies under 1 MB still got through and still ran out of memory (195 KB urlencoded, 566 KB upload-progress). The 456-byte leak request was unaffected. |
| RLIMIT_AS 300M with memory_limit=-1 | No memory error in the other runs (memory_limit=-1 removed the trigger). | The 600,000-header body: Apache logged mmap() failed, then the child segfaulted. This matches the advisory’s warning. |
| Container memory limit 96M with memory_limit=-1 | No memory error in the other runs (memory_limit=-1 removed the trigger). | The 600,000-header body: the FPM worker was killed (SIGKILL) and the Apache worker died; both were replaced. We saw no corruption, but anyone who can send that request can now take down workers at will. |
| pm.max_requests=1 or MaxConnectionsPerChild 1 | Workers were recycled every request. | The leak: in our test the producer and the consumer ran inside one request. |
| fatal_error_backtraces=0 (8.5.11) | No observable effect with our triggers. | We have no evidence either way. |
The Four-Evidence Check
“Package installed” is not enough, so the kit’s Linux script (php2shell-check.sh) shows four separate pieces of evidence.
- PackageThe fixed version, or a distribution changelog that names both CVEs.
- RuntimeThe PHP binaries that serve HTTP, not just php -v.
- ProcessRunning workers whose file was replaced after start show as “(deleted)”.
- DeploymentRunning containers and local images.
Run it from the kit:
# full check, as root
sudo bash tools/php2shell-check.sh
# no container checks
bash tools/php2shell-check.sh \
--no-containers
Exit codes: 0 all PASS, 1 WARN, 2 FAIL, 3 nothing PHP found.
What it did in the lab:
- It reported FAIL for unpatched builds and PASS for patched ones.
- It caught a worker still running old code after the file on disk was replaced, in both FPM and Apache.
- A fake distribution package with an old version string passed only when its changelog named both CVEs.
It does not cover Kubernetes, ECS or CI images, was not tested on real Ubuntu, RHEL or Alpine hosts, and the Windows script is untested.
Which Detection Caught What
We replayed every rule we could run against the lab records. Counts are lab runs, not production rates.
| Signal | Memory-exhaustion runs (120) | Leak runs (31) | Benign runs with a false hit (42) |
|---|---|---|---|
| Startup memory fatal in the PHP error log (php_startup_oom_fatal) | 74 | 0 | 0 |
| Many multipart parts or part headers in the request (http_multipart_flood) | 72 | 0 | 0 |
| Upload-progress field and a session id in the body (http_upload_progress_with_body_session_id) | 24 | 31 | 0 |
| Upload-progress field alone (http_upload_progress_field) | 24 | 31 | 4 |
| FPM worker exit code 70, five in five minutes (php_fpm_child_exit_70_storm) | 7 | 0 | 0 |
| nginx or Apache 413 on a large body | 8 | 0 | 0 |
| Generic memory-exhaustion line, before we narrowed it | 74 | 0 | 24 (script-level errors) |
The 46 runs without a hit are not misses of the startup rule. In 42 of them PHP never ran out of memory:
- a control removed the trigger in 26 runs.
- the front end rejected the body in 6.
- the payload was too small for the 128M default in 8.
- upload progress was off for the 4,000-part trigger in 2.
The other 4 runs: the worker segfaulted, was killed by the kernel (FPM) or died and was replaced (Apache). That happened with the 600,000-header body under RLIMIT_AS or a 96M container limit.
PHP wrote no fatal line, so no log rule of ours caught them.
The request rule http_multipart_flood did flag the request. Every run that did log the fatal was caught. The rule only sees what PHP writes, so PHP error logging must be on and read by your collector.
- The leak leaves no error in any log. Only the request rules saw it, and only if your proxy or WAF records the decoded form-field names.
- The 31 includes six runs where a control had already stopped the leak: the rule sees the request, not whether it worked.
- Patched builds log the same startup line when they stop the request cleanly, so the log rule cannot tell patched from unpatched. Use the version check for that.
- Noise and its limit: the generic memory rule also fired on script-level memory errors, so we narrowed it to the startup signature (benign hits 24 to 0). That gives up detection of script-level memory errors.
- Only 8 of our 42 benign runs are ordinary traffic. The other 34 are one script-level memory error, so “0 of 42” does not predict a busy production log.
- The plain upload-progress field also matches legitimate upload-progress forms, so we only use it for hunting.
- Other engines, run offline on the lab captures: Suricata, Zeek and ModSecurity rules caught some request shapes and missed others. The Zeek body notices did not fire at all, and three rules hit benign traffic (all three are bundled community rules: one Suricata, two ModSecurity).
- YARA was run only on 8 benign PHP files.
- Not tested: all process, file, cookie and user-agent rules and the SIEM queries, because the lab produced no such telemetry.
Credits: Third-Party Rules in the Kit
The kit bundles rules from these public projects, unchanged and under their own licences. Each folder in third_party/ keeps the original licence and the exact commit we copied. Thank you to the authors.
- Corelight: corelight/php2shell-detect, Zeek and Suricata rules (BSD-3-Clause)
- ORBITRON: orbitron-ai/php2shell-response-kit, multi-SIEM, Sigma and YARA rules (MIT)
- Senturkselim: Senturkselim/Am-I-Infected, Sigma rules (MIT)
- break-the-build: break-the-build/php-multipart-waf-signals, Suricata and ModSecurity rules, log only (MIT)
- Aplite: aplite-de/php2shell-detection-rules, KQL, ES|QL and SPL queries (MIT)
- deathbydata: deathbydata/php2shell-exposurehunt, Microsoft Defender hunting queries (MIT)
- Kislley Rodrigues: mym0us3r/PHP2Shell-Detection-with-Wazuh, Wazuh rules (MIT)
- sorenjohanson: sorenjohanson/crowdsec-cve-2026-109618-rule, CrowdSec scenario (MIT)
- karlisl: karlisl/php2shell-rules, fixed-version data (CC0-1.0)
