Threat Intelligence Researches

How to Defend Against PHP2Shell

A practical PHP2Shell mitigation guide: prove the patch is running, see what each temporary control stopped in our lab and which detections caught what.

19 Min Read
Dark glass shield split by a glowing red crack, cover art for: How to Defend Against PHP2Shell

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:

Closure checklist
  • 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 measures, what stays open
Temporary measureIntended effectWhat still needs attention
Turn off upload progress at startupRemoves confirmed session-related pathsOther stale-state paths remain
Turn off fatal error backtraces (PHP 8.5)Removes the confirmed backtrace producerOther request-owned state remains
Enforce memory_limit=-1 where appropriateRemoves the predictable PHP-limit OOM triggerUnderlying allocation failures and resource exhaustion remain
Cap request size and multipart partsReduces input-driven work and memory pressureSmall wire inputs can still expand internally
Isolate unpatched servicesReduces reachable attack surfaceEvery alternate route must be included
Warning: Do not remove the memory limit everywhereRemoving the PHP limit can create an availability problem. It is not a default hardening setting to copy onto every patched server.

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).

Do

  • Apply the setting in php.ini, an FPM pool or server configuration, before request parsing.
  • Restart every worker.
Do not

  • Rely on ini_set() or FPM .user.ini, as PHP’s own guidance says.
  • Replace the memory limit with RLIMIT_AS or RLIMIT_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_handler is set to php_binary or php_serialize;
  • it is switched to php with ini_set() before session_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.

Warning: Finding nothing is not the same as safeWiz demonstrated sensitive process-memory exposure without first obtaining remote code execution. An investigation that stops because there is no webshell or suspicious child process has not addressed that possibility. [6]

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

Dates that matter
  1. 17 Oct 2026Earliest attempt AWS reportedAWS reports an exploitation attempt in MadPot telemetry.
  2. 21 Oct 2026Public releasePHP fixes the two original CVEs in 8.2.35, 8.3.36, 8.4.27 and 8.5.12.
  3. 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.

PHP2Shell Defense Kit

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.

Open the kit on GitHub

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.

The lab in numbers
  • 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_code fails 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.

Temporary controls in the lab
ControlWhat it stoppedWhat it did not stop
Upload progress off (session.upload_progress.enabled=0) in php.ini, Apache config, .htaccess, nginx-passed value or FPM .user.iniThe 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 scriptNothing in our test of the leak.The leak.
memory_limit=-1The memory-exhaustion trigger at these sizes. The 128M default is the behaviour that fails.The leak was unaffected.
Front-end body limit of 1 MBEvery 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=-1No 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=-1No 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 1Workers 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.
Tip: The practical readingOnly the update fixes both problems. Several of these controls stopped part of the lab traffic, none stopped everything. Upload progress off is a good stop-gap for the leak, set in the right place. Nothing on this list stopped the memory exhaustion without causing a different problem or only covering part of the inputs.

The Four-Evidence Check

“Package installed” is not enough, so the kit’s Linux script (php2shell-check.sh) shows four separate pieces of evidence.

Is my server fixed? Four separate checks
  1. PackageThe fixed version, or a distribution changelog that names both CVEs.
  2. RuntimeThe PHP binaries that serve HTTP, not just php -v.
  3. ProcessRunning workers whose file was replaced after start show as “(deleted)”.
  4. 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.

Which rule caught which lab runs
SignalMemory-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)7400
Many multipart parts or part headers in the request (http_multipart_flood)7200
Upload-progress field and a session id in the body (http_upload_progress_with_body_session_id)24310
Upload-progress field alone (http_upload_progress_field)24314
FPM worker exit code 70, five in five minutes (php_fpm_child_exit_70_storm)700
nginx or Apache 413 on a large body800
Generic memory-exhaustion line, before we narrowed it74024 (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.

Frequently Asked Questions.

How do I know PHP2Shell is fixed on my servers?

Check four things separately: the installed package, the PHP that serves HTTP, the workers and replicas that restarted, and the deployed containers. Installing a new package does not replace code that an existing process already loaded.

Where must upload progress be turned off for it to work?

In our lab it stopped the session-data leak when set in php.ini or server configuration, before request parsing, but not through ini_set() inside the script. It did not stop the memory exhaustion. Only the update fixed both problems.

Which detections caught the PHP2Shell triggers in your lab?

The PHP startup memory fatal in the error log and the multipart flood request rule caught most memory-exhaustion runs. Only request rules on the upload-progress field saw the session leak. These are lab counts, not production rates.

Sources

  1. 1 PHP security advisory: RCE via OOM corruption (CVE-2026-109618)
  2. 2 PHP security advisory: RCE via stale global state (CVE-2026-109619)
  3. 3 PHP release announcements and October 23 follow-up
  4. 4 Cloudflare: PHP2Shell protections and scope updates
  5. 5 Elastic: Assessing relevant PHP2Shell detection coverage
  6. 6 Wiz: PHP2Shell technical research
  7. 7 Corelight: Zeek and Suricata detections
  8. 8 AWS: Pre-disclosure exploitation attempts in MadPot
Share