PHP2Shell: What to Hunt on the Web Server

PHP2Shell: What to Hunt on the Web Server

Posted on October 21, 2026

Figured I wouldn’t pass up the chance to pile on the PHP2Shell Hype Train. Here’s the quick and dirty: PHP’s October 21 emergency releases fix two vulnerabilities that can allow unauthenticated remote code execution. For the responders, that creates two different jobs: get the fixed runtime into the processes serving requests, and investigate whether those processes have done anything that needs explaining. A successful package update answers neither question by itself.

The useful starting point is the web worker, not a search for anything named php.exe. With Apache’s mod_php, PHP runs inside the Apache worker. That changes which parent process or file writer belongs in a hunt.

I’ll start with the patch versions and the request-lifecycle differences, then work through two behavior families in Microsoft Defender XDR, Splunk, and Elastic Security: a PHP-serving worker starting a selected shell or interpreter, and a worker writing candidate script files beneath a configured webroot. Those are investigation leads. They aren’t signatures for either vulnerability, and neither behavior has to occur for exploitation to have happened.

PHP Response: Two evidence paths


Patch the Runtime That’s Serving Requests

These are the upstream fixed releases in the PHP emergency announcement:

PHP Branch Fixed Release
8.5 8.5.12
8.4 8.4.27
8.3 8.3.36
8.2 8.2.35

PHP describes all web-serving installations as at risk and assesses the risk as highest for mod_php. The announcement includes PHP-FPM, CGI, FrankenPHP, and LiteSpeed; it does not say every configuration has the same demonstrated exploit path.

If you’re using a distribution or LTS package, check that vendor’s remediation information for your installed build. The upstream announcement says vendors received patches in advance; that isn’t verification that your particular package is available or installed. PHP also supplied exceptional minimal source backports for 7.4, 8.0, and 8.1. Those are source backports, not restored support for the end-of-life branches.

For patch verification, I would record the affected site’s serving mode, the runtime or module it loads, the fixed build or documented vendor backport, and the worker replacement or restart that brings that build into service. A CLI php -v result can help inventory a binary, but it doesn’t establish what an Apache module or a separate FPM pool has loaded. The reason is architectural: the Apache module is embedded in the worker, not necessarily the command-line executable you just checked. Verify the serving configuration and running workers, including separately deployed pools or containers.

Don’t make this wait for a hunt result. The advisories establish a patching need; an endpoint query answers a different question.


Two Failures at Request Boundaries

The OOM and stale-state advisories both rate these flaws Critical, CVSS v4.0 9.2, but the lifecycle details matter more to the response than treating them as one interchangeable PHP RCE.

For CVE-2026-109618, PHP can make request memory reachable before initialization is complete. An allocation failure triggers an out-of-memory exit through longjmp(), leaving that memory in an invalid state. Request shutdown can then read or branch on it. With mod_php, shutdown still runs when request startup fails. The advisory’s confirmed HTTP exploitation happens before application-specific code executes, with default PHP input and memory limits and normal prefork configurations containing multiple workers. Application authentication doesn’t stop that path simply by existing later in the request flow.

CVE-2026-109619 is about state surviving too long. A producer leaves request-owned state reachable through a worker’s global state; the request heap is freed, but the reference survives. A later request on that same worker reaches a consumer before normal initialization resets the stale value. Both producer and consumer are necessary. A fatal error or enabled session feature, considered alone, is not a complete exploit chain.

Path What the Advisory Establishes Boundary to Keep
OOM, mod_php HTTP RCE before application code, including default limits and multiple prefork workers. Execution has the web worker’s privileges, not automatic root access.
OOM, FPM Failed startup exits the child; source analysis supports susceptibility to later application OOM. No complete FPM exploit demonstrated for this CVE.
Stale state, PHP 8.5 fatal backtrace Confirmed RCE against mod_php. This is one state family, not the definition of every stale-state path.
Stale state, session lifecycle RCE validated against mod_php and FPM. The FPM producer requires a suitable application operation after successful startup.

Different lifecycle failures, different demonstrated paths. These are the PHP project’s OOM and stale-state validation results, not observed intrusions or experiments performed for this article.

Exiting an FPM child after failed startup prevents consumption of invalid state on that OOM path. It doesn’t erase the separately validated session-lifecycle path. In the latter, the advisory describes an application operation publishing state after successful startup, a fatal error, and session upload progress consuming the stale state later. Upload progress is enabled by default and doesn’t require the application to call session_start().

For stale state, the producer and consumer must reach the same worker. Multiple workers and recycling reduce success per request but don’t remove the condition. That is a reason to understand worker lifetime, not to infer safety from a busy pool. The public advisories provide the demonstrated and conditional cases; they don’t establish that either CVE has been used to deploy webshells in the wild.


Temporary Settings Have Separate Jobs

If an update is blocked, use the advisories’ temporary measures with their actual scope. For the confirmed session paths, disable session.upload_progress.enabled administratively and restart every worker. For the confirmed PHP 8.5 backtrace path, disable fatal_error_backtraces and restart every worker. Neither setting covers the other state families. Configuration must apply early enough: the emergency announcement specifically warns against relying on ini_set() or FPM .user.ini for the upload-progress mitigation.

The OOM advice needs more care than a copied one-line fix. Administratively enforcing memory_limit=-1 removes the controllable PHP memory-limit failure, but underlying allocation failure can still reach the vulnerable path. Removing that per-request boundary also requires an availability plan. The advisory pairs temporary mitigation with conservative input limits and independently enforced decoded-request limits at the frontend. It warns that RLIMIT_AS and RLIMIT_DATA can induce the allocation failure, while a cgroup limit is not a complete safeguard and can kill more than one worker.

I wouldn’t turn that into a blanket instruction to remove memory limits. Work through the published controls with the application and platform owners while getting the fixed runtime deployed. These are partial measures for distinct paths, not a configuration-only substitute for the patches.


Start With the Worker, Then Choose the Behavior

Two Separate Behavior Families

Inventory decides which processes belong here. For an inventoried mod_php deployment, that can mean apache2, httpd, or httpd.exe. For another serving mode, use its actual FPM, CGI, FrankenPHP, or LSAPI executable names. The lists below are starting points, not a discovery method. An unlisted version suffix or renamed executable won’t match. NGINX or IIS being present doesn’t, by itself, establish which process handled PHP.

A webshell is server-side backdoor code, not another name for an operating-system shell. A PHP backdoor may use PHP facilities without starting sh or cmd.exe. Conversely, a legitimate application can start an interpreter. Memory corruption in a worker doesn’t require either an on-disk backdoor or a selected child process. That’s why the following are two optional behavior families:

  • A: Direct child execution. An inventoried PHP-serving process directly starts one of the selected shells or interpreters.
  • B: Direct worker file activity. An inventoried worker creates, changes, or renames a candidate script beneath a configured webroot. The Sysmon version covers creation/overwrite only.

These are established hunting ideas. Microsoft discusses script-file creation and responsible process trees; Elastic publishes web-server child-process and PHP plugin-file detections. Here, I’ll apply those ideas to the PHP-serving process and spell out each tool’s data requirements.

Tool and Language Required Records Family B Scope
Defender XDR, Kusto DeviceProcessEvents and DeviceFileEvents on selected Windows/Linux hosts. Selected creation, modification, and rename actions, where collected.
Splunk, SPL Windows Sysmon Event 1 and Event 11 with native fields extracted. Creation/overwrite only.
Elastic Security, EQL Linux Elastic Defend process and file event streams. Creation/change or the Defend rename action.

The examples use MDE’s process and file schemas, Windows Sysmon event definitions, and the qualified Elastic Defend contracts described below. These are not equivalent collection guarantees.

Editorial Note: Release and advisory details reflect public sources retrieved on October 21, 2026. All six queries are documentation-reviewed sample hunts. They have not been parsed or executed in the native backends, tested against fixtures, or validated for production efficacy.

Before adapting a query, replace the host and any index/source placeholders. Replace the example webroots with the directories the selected service actually serves, in the path representation recorded by its sensor. Run separately for differing host configurations unless you’ve designed an inventory mapping. Symlinks, bind mounts, and container paths aren’t resolved by these string predicates.

Use the seven-day window only if it fits the investigation and retained data. Keep original events alongside the displayed columns; projections are reading aids, not complete evidence exports. Missing command lines or hashes shouldn’t discard an otherwise matching event, but missing required parent/writer fields are a coverage problem. None of these examples joins events by PID, computes all descendants, or identifies an HTTP request.


Microsoft Defender XDR: Parent and Writer Fields

Use Advanced Hunting with Defender for Endpoint onboarded and the relevant records retained for the selected devices. Linux supports Advanced Hunting, but that doesn’t establish that every file action is present on every host. Microsoft’s table documentation directs you to the portal schema for supported action types. The public community reference describes ProcessCreated and FileCreated, FileModified, and FileRenamed; confirm those actions in your own collection before interpreting missing results.

For MDE-A, the row describes the created child. FileName and ProcessCommandLine belong to that child; InitiatingProcessFileName and its related fields describe the initiating parent in the process-event schema. Don’t substitute InitiatingProcessParent*, which steps back another generation.

DeviceProcessEvents
| where Timestamp >= ago(7d) and Timestamp < now()
| where DeviceName in~ ("REPLACE_WITH_PHP_HOST")
| where ActionType == "ProcessCreated"
| where InitiatingProcessFileName in~ (
    "apache2", "httpd", "httpd.exe", "php-fpm",
    "php-fpm8.2", "php-fpm8.3", "php-fpm8.4", "php-fpm8.5",
    "php-cgi", "php-cgi.exe", "frankenphp", "lsphp")
| where FileName in~ (
    "sh", "bash", "dash", "zsh", "ksh", "csh", "tcsh", "fish",
    "cmd.exe", "powershell.exe", "pwsh", "pwsh.exe",
    "python", "python3", "python.exe", "python3.exe",
    "perl", "perl.exe", "ruby", "ruby.exe",
    "node", "node.exe", "php", "php.exe")
| project Timestamp, DeviceId, DeviceName, ReportId, ActionType,
    AccountDomain, AccountName,
    InitiatingProcessAccountDomain, InitiatingProcessAccountName,
    InitiatingProcessId, InitiatingProcessCreationTime,
    InitiatingProcessFileName, InitiatingProcessFolderPath,
    InitiatingProcessCommandLine,
    ProcessId, ProcessCreationTime, FileName, FolderPath, ProcessCommandLine

A result asks why this worker started this program. Read the executable paths and command lines against the expected application function. A helper used for rendering or administration is a competing explanation, not an exception to add before checking it.

MDE-B uses the file event’s initiating process as the writer. Here, FolderPath is the containing folder and FileName is the affected filename. That distinction explains the equality check for files directly in the root, followed by the separator-bounded prefix for nested folders.

DeviceFileEvents
| where Timestamp >= ago(7d) and Timestamp < now()
| where DeviceName in~ ("REPLACE_WITH_PHP_HOST")
| where ActionType in ("FileCreated", "FileModified", "FileRenamed")
| where InitiatingProcessFileName in~ (
    "apache2", "httpd", "httpd.exe", "php-fpm",
    "php-fpm8.2", "php-fpm8.3", "php-fpm8.4", "php-fpm8.5",
    "php-cgi", "php-cgi.exe", "frankenphp", "lsphp")
| where FolderPath == "/var/www/html"
    or FolderPath startswith_cs "/var/www/html/"
    or FolderPath =~ "C:\\inetpub\\wwwroot"
    or FolderPath startswith "C:\\inetpub\\wwwroot\\"
| where FileName endswith ".php" or FileName endswith ".phtml"
    or FileName endswith ".php5" or FileName endswith ".phar"
| project Timestamp, DeviceId, DeviceName, ReportId, ActionType,
    FileName, FolderPath, PreviousFileName, PreviousFolderPath, SHA1,
    InitiatingProcessAccountDomain, InitiatingProcessAccountName,
    InitiatingProcessId, InitiatingProcessCreationTime,
    InitiatingProcessFileName, InitiatingProcessFolderPath,
    InitiatingProcessCommandLine

Kusto’s in~ operator compares whole names without case sensitivity; the Linux root comparisons remain case-sensitive. Windows root and script-suffix comparisons are case-insensitive. The separator prevents /var/www/html-backup from qualifying as a child of /var/www/html; the filename suffix won’t select a text file merely because its directory contains .php. Don’t replace those checks with term-based has searches.

Retain rename history and available hashes for investigation. Neither query requires a unique process key to be populated on Linux. Microsoft’s process-identity guidance retains device, PID, and creation time for non-Windows systems; its Windows unique-ID guidance isn’t evidence of universal Linux population. Preserve that composite context, and keep ReportId with device and timestamp rather than treating it as a globally unique event ID.


Splunk: An Explicit Windows Sysmon Contract

These SPL examples are Windows-only. They assume a known Sysmon index and source, the official add-on’s documented XmlWinEventLog sourcetype, and native fields extracted without changing their meaning. Map differences explicitly if your ingestion differs. The add-on uses EventCode for its event-description lookup.

Use Computer for the originating event’s computer, not an assumed forwarder identity. The published Event 1 XML distinguishes the child Image, CommandLine, ProcessGuid, and ProcessId from their parent equivalents. Preserve the original event along with the table output, especially when Windows Event Collection sits between the endpoint and Splunk.

SPL-A selects an inventoried Windows Apache or PHP-CGI worker directly starting one of the listed programs:

index="REPLACE_WITH_SYSMON_INDEX" sourcetype="XmlWinEventLog"
source="REPLACE_WITH_SYSMON_SOURCE" Computer="REPLACE_WITH_PHP_HOST"
earliest=-7d latest=now EventCode=1
| eval parent_image=lower(replace(ParentImage, "\\\\", "/")),
       child_image=lower(replace(Image, "\\\\", "/"))
| where match(parent_image, "/(httpd|php-cgi)[.]exe$")
| where match(child_image, "/(cmd|powershell|pwsh|python|python3|perl|ruby|node|php|bash|sh)[.]exe$")
| table _time UtcTime Computer host source EventCode EventRecordID User
    ParentImage ParentCommandLine ParentProcessGuid ParentProcessId
    Image CommandLine ProcessGuid ProcessId

The comparison fields are lowercased and normalize backslashes to forward slashes; the original fields stay in the output. SPL requires two layers of backslash escaping inside eval replace. The final-component separator and $ anchor matter because match otherwise finds a matching substring. A directory containing httpd.exe isn’t the same thing as an executable with that final component.

SPL-B uses Event 11. Sysmon documents it as file creation or overwrite, not comprehensive modification or rename telemetry:

index="REPLACE_WITH_SYSMON_INDEX" sourcetype="XmlWinEventLog"
source="REPLACE_WITH_SYSMON_SOURCE" Computer="REPLACE_WITH_PHP_HOST"
earliest=-7d latest=now EventCode=11
| eval writer_image=lower(replace(Image, "\\\\", "/")),
       target_path=lower(replace(TargetFilename, "\\\\", "/"))
| where match(writer_image, "/(httpd|php-cgi)[.]exe$")
| where like(target_path, "c:/inetpub/wwwroot/%")
| where match(target_path, "[.](php|phtml|php5|phar)$")
| table _time UtcTime Computer host source EventCode EventRecordID
    Image ProcessGuid ProcessId TargetFilename CreationUtcTime

Here Image is the writer and TargetFilename is the full affected path. The root ends in a separator so a sibling such as wwwroot-backup isn’t selected. In like, % is the multi-character wildcard, not *. Account for wildcard or regex characters if your replacement paths contain them.

The inspected Event 11 example supplies Image, ProcessGuid, ProcessId, TargetFilename, UtcTime, and CreationUtcTime. It doesn’t supply a writer command line or an EventData User, so the query doesn’t invent either. Don’t substitute provider security identity for the writer. CreationUtcTime is file creation metadata; use event time when reconstructing when this observation occurred. Further process context requires actual corresponding evidence, not a PID-only join.


Elastic Security: EQL Over Linux Defend Events

Use Elasticsearch EQL for these examples, not Kusto or the Kibana Query Language search bar. Select logs-endpoint.events.process* for ELA-A and logs-endpoint.events.file* for ELA-B, with Elastic Defend data from the selected Linux endpoints. Set a bounded seven-day or incident-specific @timestamp range outside the query text, and check result limits or truncation.

The process selector follows the documented Defend shape in Elastic’s web-server command-execution rule: a Linux process start with event.action == "exec". In the ECS process fields, process.parent.* describes the parent; process.* describes the child on this process event.

process where host.os.type == "linux"
  and host.name == "REPLACE_WITH_PHP_HOST"
  and event.type == "start" and event.action == "exec"
  and process.parent.name in (
    "apache2", "httpd", "php-fpm", "php-fpm8.2", "php-fpm8.3",
    "php-fpm8.4", "php-fpm8.5", "php-cgi", "frankenphp", "lsphp"
  )
  and process.name in (
    "sh", "bash", "dash", "zsh", "ksh", "csh", "tcsh", "fish",
    "python", "python3", "perl", "ruby", "node", "php", "pwsh"
  )

Keep the full matching event: timestamp, event ID, host identity, available user and container identity, process entity IDs, PID/start information, executable paths, command lines, and parent context. Don’t confuse a process name with the PHP script being handled. The query selects a relationship between recorded processes, not a route or request.

For ELA-B, process.* on the qualified Defend file event is the responsible process. Elastic’s PHP plugin-file rule supplies the creation/change pattern; its unusual web-server file rule also selects the rename action. The latter is a kuery/new_terms rule. This sample is an EQL event filter, not a reproduction of that rule’s baseline behavior.

file where host.os.type == "linux"
  and host.name == "REPLACE_WITH_PHP_HOST"
  and (event.type in ("creation", "change") or event.action == "rename")
  and process.name in (
    "apache2", "httpd", "php-fpm", "php-fpm8.2", "php-fpm8.3",
    "php-fpm8.4", "php-fpm8.5", "php-cgi", "frankenphp", "lsphp"
  )
  and file.path like "/var/www/html/*"
  and file.name : ("*.php", "*.phtml", "*.php5", "*.phar")

ECS separates the full file.path from file.name. The root uses case-sensitive like; the filename’s colon patterns are case-insensitive under EQL’s documented operators. Thus the intended suffix selection includes .PHP, but not .php.jpg, while a differently cased Linux root is different. These are candidate suffixes, not proof the server will execute the file. PHP handler mappings can associate other extensions with PHP, so adapt them to the actual configuration.

Don’t quietly extend this writer contract to every ECS integration. event.action is implementer-defined, and the required mappings must exist. An unmapped query field can make EQL fail; hiding it with optional-field syntax doesn’t repair missing collection. Resolve that adoption problem before treating the query as a hunt you can interpret.


What a Match Actually Buys You

Editorial Note: The following update scenario and path are fictional, not an observed incident.

Suppose an inventoried Linux php-fpm8.3 worker launches sh for an approved rendering helper. During the same application update, the worker itself writes /var/www/html/wp-content/plugins/report-helper/export.php. Both observations fall within the selected hosts and time window. The process and file examples for MDE or Elastic can select them independently.

Now add the context: the application owner authorized the update, the helper’s actual command matches its documented function, and the written file matches the approved plugin package. That supplies a coherent legitimate explanation for both observations. It isn’t merely a maintenance-window excuse. Elastic’s public guidance explicitly recognizes legitimate administration and conversion workflows and plugin updates as explanations to investigate.

Two matching families don’t become independent proof of compromise when one authorized workflow explains both. Nor should that workflow become a blanket exclusion for every file written by the same service account. The relevant evidence is the particular command, content, authorization, and application function.

Keep the endpoint observations unchanged, but remove the authorization record and the trusted package comparison. The correct result is unresolved provenance. Find out what the file contains, where it came from, whether the application should write it, and whether the helper invocation belongs to an authorized operation. A .php extension establishes none of those things. Even a file that turns out to be a backdoor doesn’t, on its own, identify the vulnerability that placed it there.

For a child-process result, I’d start with the executable path, command line, parent instance, and application feature that could legitimately invoke it. For a file result, I’d preserve the content and original event, compare it with the expected deployment artifact, and inspect the recorded writer and rename history where available. Microsoft’s process-tree guidance is useful here because a filename alone is thin evidence.

Then bring in whatever web and application records actually connect the activity to a route, account, or request. Process identity is not request identity. Timestamp proximity is a lead, not proof that a nearby access-log entry caused the process or file event. Where that connection isn’t recorded, leave it unresolved rather than filling it in from the worker’s name.

Family B has a blind spot: if the worker starts sh and sh writes the file, the direct-worker writer filter won’t match that write. Family A may still find the shell child. Follow its actual recorded ancestry and file activity separately; adding every shell as a writer would discard the PHP provenance the filter was meant to preserve. Similarly, A doesn’t follow worker -> helper -> interpreter through an unlisted intermediate process.


Keep Patch Evidence and Hunt Evidence Separate

A no-match result is only interpretable with its scope attached: which hosts, which retained interval, which populated parent/writer fields, which executable names, and which roots. Missing collection, excluded events, different container paths, unlisted programs, existing files, or activity outside that interval can all leave these predicates with nothing to select. In-memory execution and PHP-only backdoor activity may produce neither selected behavior.

The same restraint applies to error logs. The stale-state advisory says logs supplement a static configuration and application-flow audit; absent errors aren’t proof of safety, including when historical logging excluded relevant diagnostics. Use logs to develop the investigation, not as a replacement for verifying the loaded fix.

What I want from the response is a defensible answer to each original question. For remediation, show that the affected service is running the fixed PHP runtime, with the workers replaced or restarted. For investigation, show what behavior was found, what explains it, and what remains outside the evidence. Close the patch task on serving-runtime evidence. Don’t close the compromise question merely because a generic hunt returned no rows.

Back to Blog