Skip to main content
Blog

PHP2Shell: The Parent Process Is The Whole Story

  • October 23, 2026
  • 0 replies
  • 14 views
  • Translate All

Aaron Beardslee
Forum|alt.badge.img

Two unauthenticated PHP core RCEs landed on October 21. We emulated the aftermath on a bounded range across three PHP deployment models, measured what the telemetry actually carries, and found that the parent process is not the same thing on any two of them.

Under IIS, PHP does not run inside the IIS worker. The FastCGI handler maps *.php to php-cgi.exe, and user code runs inside that process. So when PHP code spawns a shell, the ParentImage on the resulting Sysmon Event ID 1 is C:\PHP\php-cgi.exe, one process deeper than w3wp.exe. Every rule anchored on ParentImage|endswith '\w3wp.exe' is blind to php2shell post-exploitation on that host. Not noisy. Not low-fidelity. Blind.

That is the headline, and it held. What we did not expect was that the same question has three different answers depending on how PHP is deployed:

  • IIS FastCGI. The parent resolves every time, to php-cgi.exe, because CreateProcess exec'd it and it is a fully tracked process. Five of five.
  • PHP-FPM behind nginx. The parent is usually absent. 4,135 of 4,226 process-creation records shipped ParentImage as the literal string -. That is 97.85 percent.
  • Apache mod_php with mpm_prefork. The parent resolves every time, to /usr/sbin/apache2, including on a worker we built specifically to be cold.

That last one falsified our own prediction, and it is the most useful thing here. We went in expecting the Apache prefork worker to behave like the FPM worker, because both are forked children that never call execve(2). It did not. The reason turns out to have nothing to do with fork versus exec, and everything to do with which process accepts the TCP connection.

The obvious reading of php2shell is that you detect the request. The advisories describe beautiful, specific request geometry: repeated multipart part headers, empty file controls, nested field names, missing content length. It reads like a signature waiting to be written. It is not. Cloudflare deployed WAF protections about nine hours ahead of public disclosure, then watched attackers mutate the payload to get past them, and concluded that "request data alone does not always provide enough evidence to distinguish an exploit from legitimate traffic" (blog.cloudflare.com). Everything in the request is freely variable. The process tree is not.

What the two CVEs actually are

Both carry CVSS 4.0 base score 9.2, vector CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N, and both affect every version before 8.2.35, 8.3.36, 8.4.27 and 8.5.12.

CVE-2026-109618 is RCE through out-of-memory corruption. PHP uses longjmp() to exit the current context on OOM. In some contexts request memory is reachable before it is fully initialised, and if an allocation fails there PHP does not account for the early exit, leaving the request memory invalid. The failed allocation does not need to be related to that memory. Request shutdown then reads or branches on the invalid state. The advisory documents several remotely reachable ways to drive that allocation failure through multipart parsing: requests with no usable declared content length that pass the initial post_max_size check and keep supplying decoded body data, parts carrying an unrestricted number of headers whose in-memory form far exceeds their wire form, empty file controls that create file metadata arrays without counting toward max_file_uploads, and nested field names that multiply the array work. (GHSA-w8hw-w9fx-j79w)

CVE-2026-109619 is RCE through stale cross-request state. Request-owned error, session, object and extension state can remain in persistent worker globals after the request heap is freed. That happens when a fatal error interrupts an operation before its matching reset runs, or when a nonfatal diagnostic during shutdown repopulates error state after it was cleared. A later operation running before normal request initialisation then uses the dangling pointer, which is a use-after-free. The confirmed cases are fatal error backtrace state on PHP 8.5 with fatal_error_backtraces enabled, and session upload progress state. session.upload_progress matters because, in the advisory's own words, it is enabled by default and no application call to session_start() is required. (GHSA-6wmf-4rv5-p3rp)

Neither is an FPM-only or an Apache-only problem, and the SAPI you run changes how much is proven rather than whether you are exposed:

SAPI CVE-2026-109618 CVE-2026-109619
Apache mod_php Complete RCE validated over HTTP with default input and memory limits, under normal prefork with multiple workers. Shutdown runs unconditionally even when startup failed, so the condition sits ahead of application code. Highest risk Fatal backtrace RCE confirmed; session lifecycle RCE validated. .htaccess and directory-level php_value/php_flag apply before php_request_startup(), giving an early diagnostic path
PHP-FPM Source analysis supports susceptibility. FPM exits the child when startup fails, which prevents consumption of the invalid state. No complete exploit demonstrated Session lifecycle RCE validated. The INI diagnostic path is conditional on the frontend sending PHP_VALUE/PHP_ADMIN_VALUE
FrankenPHP Likely susceptible, calls shutdown unconditionally No early consumer established in the default configuration, which is not immunity
LSAPI / LiteSpeed Likely but unvalidated Conditional candidate

First fixed releases are 8.2.35, 8.3.36, 8.4.27 and 8.5.12 (php.net). Upstream shipped no release for 8.1 or earlier, because those branches are past end of life, and 8.2 itself reaches end of life at the close of 2026. If you are on an EOL branch, a distribution backport is the only patched build you will get.

CISA urged immediate patching and, in its own words, said to "Monitor for suspicious activity, including PHP worker processes launching shells or downloading tools" (CISA alert). Amazon reported an exploitation attempt in MadPot dating to October 17, four days before disclosure, while stating that analysis is ongoing and it is unclear whether any of those attempts would have succeeded against real-world systems (AWS Security Blog).

If you cannot patch today

Updating is the only complete fix for either CVE, and the advisories say so plainly. These are the documented stopgaps, with their documented limits:

  • CVE-2026-109618: setting and enforcing memory_limit=-1 is the primary configuration mitigation, and it is explicitly not complete. It also removes a guardrail most operators want for unrelated reasons, so treat it as a bridge to the patch and not a setting you leave behind.
  • CVE-2026-109619: there is no complete configuration mitigation. Set session.upload_progress.enabled=0 and restart every worker. On PHP 8.5 also set fatal_error_backtraces=0 administratively and restart every worker. Consider pm.max_requests=1 if you can afford it, since it retires workers before stale state can be consumed.
  • Either: enable log_errors, a working error_log and error_reporting=E_ALL in startup configuration, and reduce attacker-reachable fatal errors and cleanup-stage diagnostics.

A restart matters more than it looks. CVE-2026-109619 lives in persistent worker globals, so a config change that does not recycle workers has not taken effect for the processes that are already serving.

Patch first. Detection is the backstop. What follows is the backstop.

What we emulated, and what we did not

This line matters enough to draw it before any results.

No CVE was exploited. Neither CVE was triggered, in any run, on any host. No memory was corrupted. No public proof of concept was fetched, read or executed. Nothing here demonstrates detection of actual exploitation of either CVE.

What was executed instead was the consequence, under laboratory control: a PHP worker spawning a shell, an interpreter or a transfer tool, through a token-gated endpoint that accepted exactly one fixed token and one fixed action keyword and interpolated no caller input into any command, path, host, port or filename. Every command in every run was a hardcoded argv array or a fixed string. The request-layer work was a bounded structural proxy: elevated but capped part and header counts with a correct Content-Length on every request, deliberately inside every PHP input and memory ceiling. The one failure-path probe was a handled PHP memory_limit fatal with the limit lowered for a single request. The one abnormal termination was a SIGABRT delivered to a single Apache worker from privileged guest control, not from a request.

In ATT&CK terms the emulated behaviour is T1190 as the initial-access precursor, with T1059.004 Unix Shell on the Linux hosts, T1059.003 Windows Command Shell on the IIS host, and T1105 Ingress Tool Transfer for the curl.exe and wget children.

Why behaviour-level and not exploit reproduction. A faithful run needs a confirmed-vulnerable build, a reviewed exploit profile matched to that exact build, and a bounded proof action. "Below the fixed version" is not an exploit profile. More to the point, the detection being shipped does not depend on the exploit: attackers were already observed mutating the request to evade request-layer rules, and the monitoring advice everyone converged on is about the worker spawning shells and downloading tools, which is the post-exploitation shape. Emulating the consequence tests the rule that will actually fire.

An exploit-reproduction claim is a different claim from a telemetry-visibility claim. This is the second one.

The range

AURORA01, domain AURORA.CITY, seven guests, reverted to clean between cycles. Three PHP deployment models, deliberately, because that turned out to be the whole variable.

wp01 is the IIS FastCGI half: Windows Server 2022, IIS 10, PHP 8.3.35 NTS VC2019 x64 at C:\PHP, SAPI cgi-fcgi, stock WordPress bound to *:80. The handler mapping was read in-guest and confirmed as PHP_via_FastCGI *.php -> C:\PHP\php-cgi.exe. Telemetry is Sysmon64 v15.15 on the FT config, verified in-guest to carry no ProcessCreate exclusion for cmd.exe, powershell.exe, curl.exe, certutil.exe, php-cgi.exe or w3wp.exe as either Image or ParentImage. That matters: a detection measured under a config that quietly drops the event is not a measurement.

web02 carries both Linux models. nginx 1.24.0 on :80 with php8.3-fpm version 8.3.6-0ubuntu0.24.04.13 over a unix socket, pool www at pm=dynamic, pm.max_children=5. Then Apache 2.4.58 on :8081 with mpm_prefork_module and php_module both shared, package libapache2-mod-php8.3 at the same version, a single vhost with DocumentRoot /var/www/aurora-prefork, and a one-worker override setting StartServers 1, MinSpareServers 1, MaxSpareServers 1, MaxRequestWorkers 1. One worker is the whole point of that design: with exactly one, "the same worker before and after" and "the replacement worker" become measured facts rather than inferences. Telemetry is Sysmon for Linux v1.5.2, whose ProcessCreate exclude list contains only /usr/bin/ps.

That package version is worth holding onto. 8.3.6-0ubuntu0.24.04.13 carries an upstream-looking 8.3.6, far below the upstream fixed 8.3.36, while being the exact build Ubuntu shipped as the fix for both CVEs on 24.04 LTS (USN-8948-1). It comes back later.

waf01 is nginx 1.24.0 with ModSecurity in DetectionOnly and CRS v4 including REQUEST-922-MULTIPART-ATTACK. Port 80 proxies to wp01, port 8088 to web02 nginx. web01 is the range-internal client: python3 3.12.3 and wget 1.21.4, no curl. Direct access to wp01:80 from the client is closed, so the WAF path is enforced rather than merely preferred.

The safety envelope. No persistence of any kind on any guest. No outbound callback; the only network action by any worker was a TCP connect to a listener on the same host's own LAN address, chosen deliberately because the Sysmon for Linux config excludes NetworkConnect to 127.0.0.1 and a loopback connect would have emitted nothing. Exactly one configuration mutation across all runs, a temporary FPM pool change to pm=static, pm.max_children=1, restored byte-identical and md5-verified. Exactly one worker termination, once, against a single named PID, behind a guard that aborted unless the target was the sole www-data child of the Apache master and was not the master. Stop conditions declared in advance; none met. Cleanup read back by probe rather than assumed: all endpoints removed, zero files matching *p2s* under any web root or /tmp, and all endpoints returning 404 through the WAF while both stock index.php pages returned 200, which distinguishes "endpoint gone" from "site broken".

The emulation approach

Four things were worth measuring, and only one of them is a rule.

The request layer, bounded. The precursor sent the advisory's structural features at elevated but capped counts. T1 went to the PHP route on wp01 through the WAF: 4,621 body bytes, 51 parts, 8 empty-filename file controls, 41 plain fields, nesting depth 2, a maximum of 24 part headers on one part, and a PHP_SESSION_UPLOAD_PROGRESS field. Against post_max_size 8M, memory_limit 128M, max_file_uploads 20 and max_input_vars 1000 that is comfortably inside every ceiling. Four negative controls followed.

The endpoint layer, three SAPIs. Token-gated, non-parameterised PHP endpoints accepting one fixed token and one fixed action keyword. On Windows, children were spawned with proc_open and bypass_shell so each is a direct child of php-cgi.exe via CreateProcess with no intermediate cmd.exe. On both Linux models, the PHP 7.4+ proc_open array form execs without /bin/sh, so children are direct children of the worker with no intervening shell. Every platform got absence controls: a normal request, an invalid token with a valid action keyword present, and a valid token with no action keyword. Windows and Apache also got specificity controls, the same child spawned from a non-web parent.

The parent-cache question, twice. On FPM, a temporary one-worker static pool so worker identity is a measured fact, a restart to obtain one fresh worker, then three requests eight seconds apart with a deliberate warming action in the middle. On Apache, the same design using the freshly forked replacement worker from the termination test as the cold subject.

The failure signal, two ways. On IIS, a bounded memory_limit fatal to see whether php-cgi.exe dies and produces an Application Error. On Apache, one SIGABRT to the sole worker, to see whether abnormal termination is visible anywhere in shipped telemetry at all.

Proof: the records that matter

Everything below is from the archived capture members. Two timestamp fields appear in these records and they differ by a couple of milliseconds: UtcTime is Sysmon's own event time, EventTime is the collector's. Every timestamp quoted here is UtcTime.

Under IIS FastCGI, the parent is php-cgi.exe

ParentImage: C:\PHP\php-cgi.exe appears exactly 5 times in the collected Windows Sysmon member, and those five are precisely the five intended child spawns. The transfer-tool child, with values preserved exactly:

Process Create:
UtcTime: 2026-10-22 22:16:35.746
ProcessId: 6016
Image: C:\Windows\System32\curl.exe
CommandLine: curl.exe --version
User: IIS APPPOOL\DefaultAppPool
ParentImage: C:\PHP\php-cgi.exe
Hostname: AURORA01-WP01

Three things in there carry the argument.

ParentImage is C:\PHP\php-cgi.exe, fully qualified, not w3wp.exe. The IIS worker is the grandparent, not the parent. A rule anchored one level up sees nothing.

User is IIS APPPOOL\DefaultAppPool. The child inherits the application pool identity, so the account tells you this came through the web stack even when the parent chain does not.

CommandLine is populated and selectable alongside the parent anchor. That matters because the Windows rule gates shell children on arguments, and the gate needs the field.

The full set, and the controls:

Child image Count UtcTime PIDs
C:\Windows\System32\cmd.exe 3 22:16:26.389, 22:17:59.237, 22:19:07.765 5096, 4428, 3736
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe 1 22:16:30.483 5816
C:\Windows\System32\curl.exe 1 22:16:35.746 6016

The count of exactly 5 is itself the absence proof. The invalid-token request carried the action keyword a=cmd and still spawned nothing, the valid-token-no-action request spawned nothing, and the stock WordPress page spawned nothing. Had any fired, the count would exceed five.

One disclosure: the cmd.exe action ran three times. proc_open's bypass_shell array form quotes each argv element separately and cmd.exe cannot resolve a quoted builtin, so the first two attempts spawned cmd.exe but the inner echo failed. All three are genuine php-cgi.exe to cmd.exe lineage events and all three are in the evidence. Only the payload differed.

The same children, from a different parent

The specificity control, spawned locally from powershell.exe PID 2308:

  Image:        C:\Windows\System32\curl.exe
ParentImage: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe

Identical children, different parent, no match. That is the exact property a parent-anchored selector depends on, and it is why the Windows rule needs no account branch: the parent field alone discriminates.

Under PHP-FPM, the parent is usually absent

Same instrumentation, same host, different SAPI. The cold child on static pool worker 9351:

{
"EventID": 1,
"EventData.UtcTime": "2026-10-22 22:22:42.636",
"EventData.ProcessId": "9362",
"EventData.Image": "/usr/bin/echo",
"EventData.CommandLine": "/bin/echo P2SNIXPARENT-CHILD1-UNWARMED",
"EventData.CurrentDirectory": "/var/www/petalstem",
"EventData.User": "www-data",
"EventData.ParentProcessId": "9351",
"EventData.ParentImage": "-",
"EventData.ParentCommandLine": "-",
"Hostname": "aurora01-web02"
}

ParentImage is the literal string -. So is ParentCommandLine. Across the first capture window that was true of 4,135 of 4,226 process-creation records carrying the element, 97.85 percent.

Be precise about what that number is, because it is easy to over-read. It spans all three Linux hosts in the range, not just the PHP host, and it is remarkably consistent across them: 98.77 percent on the WAF, 98.17 percent on the web server, 96.61 percent on the client. So it is a property of Sysmon for Linux on this fleet rather than a measurement of PHP-FPM workers specifically. The FPM-specific finding is the narrower one next to it: the pool workers had no process-creation record at all, because PHP-FPM pool workers and nginx workers are created by fork(2) and never call execve(2), so there was nothing to put them in the cache. On the PHP host in that window only 2 process creations ran as www-data at all, the emulated child and the sudo control, which is also the absence proof for the Linux side.

Eight seconds later, after the same worker performed one network connect and one file create and delete of its own, the same fixed child:

{
"EventData.UtcTime": "2026-10-22 22:22:50.670",
"EventData.ProcessId": "9363",
"EventData.CommandLine": "/bin/echo P2SNIXPARENT-CHILD2-WARMED",
"EventData.User": "www-data",
"EventData.ParentProcessId": "9351",
"EventData.ParentImage": "/usr/sbin/php-fpm8.3"
}

Same worker, same pool, nothing intervening except instrumented activity by that worker itself. So the parent branches are conditional, not impossible, and an earlier version of the Linux rule that called them "unsatisfiable" was wrong and has been corrected. The 97.85 percent stands. The inference drawn from it does not.

Under Apache prefork, the parent always resolves

Here is where the prediction broke. The subject was worker PID 3610, forked 7.2 seconds after we terminated its predecessor, chosen precisely because a freshly forked worker should be cold.

Field C1, intended cold C4, warm D1, control
UtcTime 2026-10-23 01:01:01.468 2026-10-23 01:01:18.118 2026-10-23 01:01:39.446
ProcessId 3738 3739 3745
Image /usr/bin/dash /usr/bin/dash /usr/bin/dash
CommandLine /bin/sh -c -- true P2SAPACHE20261023 identical identical
CurrentDirectory /var/www/aurora-prefork /var/www/aurora-prefork /home/svc-h4xagent
User www-data www-data www-data
ParentProcessId 3610 3610 3744
ParentImage /usr/sbin/apache2 /usr/sbin/apache2 /usr/bin/sudo
ParentProcessCommandLine /usr/sbin/apache2 -k start /usr/sbin/apache2 -k start sudo

C1 and C4 are identical except for the timestamp and the child PID. The cold case was not reachable on that SAPI.

The mechanism is the transport. An mpm_prefork worker accepts the inbound TCP connection itself, and Sysmon for Linux instruments that accept as Event ID 3 with the worker as the subject process and Initiated of false. So the worker produces an instrumented event of its own on every request it serves, including its first, and its per-PID cache is warm before mod_php runs a line of user code. A PHP-FPM pool worker accepts on a unix socket, because nginx terminated the TCP, which produces no NetworkConnect event at all.

The instrumented-event sequence for worker 3610 reads cleanly. Event ID 3 with Image of /usr/sbin/apache2 and Initiated of false returns 8 records for that PID: five inbound accepts beginning 1.0 seconds after the worker forked, then the C1 accept at 01:01:01.461, the C3 accept at 01:01:09.799 and the C4 accept at 01:01:18.116. Initiated of true returns exactly 1, the single deliberate outbound warm connect to this host's own LAN address on port 80 at 01:01:09.800. The C1 child followed its own accept by 7 milliseconds.

Fork versus exec explains why neither worker has a process-creation record. It does not explain the difference between them. The transport does.

Two consequences. First, if you run PHP under Apache prefork, your parent-image detections work, and they work on the worker's very first request. Second, the honest residual: worker 3610 had already accepted five earlier connections, so this run cannot separate "warmed by its own accept in the same request" from "warmed by an earlier accept." Settling that needs a worker whose first-ever accepted request is the child-spawning one, which needs a second controlled termination and was out of scope under the one-termination constraint.

The control, D1, is the account-branch exposure reproduced on Apache: identical Image and CommandLine, identical User of www-data, and ParentImage of /usr/bin/sudo. The parent-image branch does not match it. The account branch does. Any administrative sudo -u www-data sh -c ... and any cron job running as the web service account behaves the same way.

The crash that nothing recorded

We sent one SIGABRT to the sole Apache worker. The master survived, a replacement forked 7.2 seconds later, and the first client request was satisfied 12.1 seconds after the signal. The TCP connect succeeded immediately because the master holds the listening socket, so that 12.1 seconds is the client's own retry behaviour waiting for a worker to accept, not connection refusal.

Guest-local records exist. Apache's global error log carries exactly one exit-signal record:

[Thu Oct 22 18:57:52.938547 2026] [core:notice] [pid 1874] AH00051:
child pid 1901 exit signal Abort (6), possible coredump in /etc/apache2

Apport produced a 2.3 MB crash file with Signal: 6 and ExecutablePath /usr/sbin/apache2. The vhost error log carries no termination record at all, because the MPM writes child-exit notices only to the global ErrorLog.

Nothing about the termination reached the collector. Sysmon EID 5 is disabled on that host by config, onmatch="include" with zero rules, and include-with-no-rules logs nothing. The AH00051 notice lives in a file nxlog does not read. Non-Sysmon syslog lines are dropped at the input. The apport report is on disk only.

The one shipped witness is the authorisation for the signal, not its effect:

<13>Oct 22 18:57:45 aurora01-web02 sudo: svc-h4xagent :
PWD=/home/svc-h4xagent ; USER=root ; COMMAND=/usr/bin/kill -ABRT 1901

That is the whole of it. One sudo record saying a signal was authorised. It says nothing about Apache. Abnormal Apache worker termination is not a detection signal on this telemetry path, and the negative result is committed as evidence so the dead end is not re-investigated.

This matters for php2shell specifically. Public PoC write-ups describe worker crashes as a stage of the chain, and advise defenders to watch for SIGSEGV/SIGABRT children. On this pipeline there is nothing to watch with.

Finding it: Securonix Spotter

One note on provenance. The collector ships these sources to a cloud-native Securonix tenant, and the attribute mappings the queries use are validated rather than assumed. The counts quoted throughout this piece, though, were taken from the raw collected telemetry rather than from a SIEM query, because the archive is where the per-record field checks were done. Read them as archive counts.

Each query is scoped by rg_functionality rather than by a resource group or a datasource name, so it runs anywhere the functionality exists. Which functionality depends on which telemetry you are asking about, and the three runs shipped seven distinct streams across six collector files:

What we collected Event types captured Hosts rg_functionality
Windows Sysmon Process Create, File created, File Delete, Image loaded, Registry value set, Dns query WP01, DC01, WKS01, WKS02 Endpoint Management Systems
Sysmon for Linux EID 1 ProcessCreate, 3 NetworkConnect, 11 FileCreate, 23 FileDelete, 16 config change web01, web02, waf01 Endpoint Management Systems
Windows event log Security 4625, Application 1000 from provider VGAuth WP01, DC01, WKS01, WKS02 Microsoft Windows
IIS W3C method, URI stem and query, status, substatus, bytes, time-taken, UA, host WP01 Web Server
nginx access, JSON client address, host, method, URI, args, status, bytes, upstream, timings, request id WAF01 Web Proxy
ModSecurity audit, JSON full request headers and body, part headers, rule messages WAF01 none exists
Linux auth log sudo authorisation, VGAuth validation web02 Unix / Linux / AIX

Only the first two carry process lineage, so the lineage queries below are all Endpoint Management Systems. The last row is the one that matters most and it has no functionality at all: the only log that saw the multipart structure is the one with nowhere to land.

The IIS gap, in one line. Everything whose parent is the PHP CGI process rather than the IIS worker:

index=activity and rg_functionality="Endpoint Management Systems"
and ParentImage ENDS WITH "\php-cgi.exe"

The same question asked the way most rule sets ask it, which is the point:

index=activity and rg_functionality="Endpoint Management Systems"
and ParentImage ENDS WITH "\w3wp.exe"

On our capture the first returns the five child spawns and the second returns none of them. If you host PHP under IIS FastCGI, run both against your own estate and compare the counts. That is the ten-minute check.

The Windows detection is three child tiers OR'd against one parent anchor. Run them as three queries rather than one. Every engine handles a flat and (...) and (...) more predictably than a nested mix of AND and OR, and an analyst triaging a hit wants to know which tier fired rather than only that something did.

Tier 1, a shell asked to execute something.

index=activity and rg_functionality="Endpoint Management Systems"
and (ParentImage ENDS WITH "\php-cgi.exe" or ParentImage ENDS WITH "\php.exe"
or ParentImage ENDS WITH "\w3wp.exe" or ParentImage ENDS WITH "\httpd.exe"
or ParentImage ENDS WITH "\nginx.exe" or ParentImage ENDS WITH "\tomcat.exe")
and (Image ENDS WITH "\cmd.exe" or Image ENDS WITH "\powershell.exe"
or Image ENDS WITH "\pwsh.exe" or Image ENDS WITH "\powershell_ise.exe")
and (ChildProcessCommandLine CONTAINS " /c "
or ChildProcessCommandLine CONTAINS " /k "
or ChildProcessCommandLine CONTAINS " -Command "
or ChildProcessCommandLine CONTAINS " -enc"
or ChildProcessCommandLine CONTAINS " -nop"
or ChildProcessCommandLine CONTAINS " -w hidden"
or ChildProcessCommandLine CONTAINS "DownloadString"
or ChildProcessCommandLine CONTAINS "DownloadFile"
or ChildProcessCommandLine CONTAINS "Invoke-WebRequest"
or ChildProcessCommandLine CONTAINS "Invoke-Expression"
or ChildProcessCommandLine CONTAINS "FromBase64String"
or ChildProcessCommandLine CONTAINS "\Temp\")

Tier 2, an interpreter handed inline code, a remote resource or a temp path.

index=activity and rg_functionality="Endpoint Management Systems"
and (ParentImage ENDS WITH "\php-cgi.exe" or ParentImage ENDS WITH "\php.exe"
or ParentImage ENDS WITH "\w3wp.exe" or ParentImage ENDS WITH "\httpd.exe"
or ParentImage ENDS WITH "\nginx.exe" or ParentImage ENDS WITH "\tomcat.exe")
and (Image ENDS WITH "\python.exe" or Image ENDS WITH "\perl.exe"
or Image ENDS WITH "\ruby.exe" or Image ENDS WITH "\wscript.exe"
or Image ENDS WITH "\cscript.exe" or Image ENDS WITH "\mshta.exe")
and (ChildProcessCommandLine CONTAINS " -c "
or ChildProcessCommandLine CONTAINS " -e "
or ChildProcessCommandLine CONTAINS "base64"
or ChildProcessCommandLine CONTAINS "exec("
or ChildProcessCommandLine CONTAINS "http://"
or ChildProcessCommandLine CONTAINS "https://"
or ChildProcessCommandLine CONTAINS "\Temp\")

Tier 3, a transfer or tunnelling tool. No argument condition, deliberately.

index=activity and rg_functionality="Endpoint Management Systems"
and (ParentImage ENDS WITH "\php-cgi.exe" or ParentImage ENDS WITH "\php.exe"
or ParentImage ENDS WITH "\w3wp.exe" or ParentImage ENDS WITH "\httpd.exe"
or ParentImage ENDS WITH "\nginx.exe" or ParentImage ENDS WITH "\tomcat.exe")
and (Image ENDS WITH "\curl.exe" or Image ENDS WITH "\wget.exe"
or Image ENDS WITH "\certutil.exe" or Image ENDS WITH "\bitsadmin.exe"
or Image ENDS WITH "\ftp.exe" or Image ENDS WITH "\nc.exe"
or Image ENDS WITH "\ncat.exe" or Image ENDS WITH "\socat.exe")

Tier 3 is the one to wire up first, and the one not to "improve" by adding an argument condition to match the other two. A web worker spawning curl.exe needs no argument to be interesting, and this is the tier that survives the attacker changing everything about the request. It is also the behaviour CISA names.

The Linux detection has the same three tiers and a wider parent anchor, because PHP-FPM behind nginx often ships no resolvable parent at all.

Tier 1, a shell asked to execute something.

index=activity and rg_functionality="Endpoint Management Systems"
and (ParentImage ENDS WITH "/apache2" or ParentImage ENDS WITH "/httpd"
or ParentImage ENDS WITH "/nginx" or ParentImage ENDS WITH "/tomcat"
or ParentImage CONTAINS "/php-fpm"
or ParentProcessCommandLine CONTAINS "apache2"
or ParentProcessCommandLine CONTAINS "httpd"
or ParentProcessCommandLine CONTAINS "nginx"
or ParentProcessCommandLine CONTAINS "php-fpm"
or AccountName IN ("www-data","apache","apache2","httpd","nginx","http"))
and (Image ENDS WITH "/sh" or Image ENDS WITH "/bash"
or Image ENDS WITH "/dash" or Image ENDS WITH "/zsh"
or Image ENDS WITH "/ksh")
and (ChildProcessCommandLine CONTAINS " -c "
or ChildProcessCommandLine CONTAINS " -i"
or ChildProcessCommandLine CONTAINS "/dev/tcp/"
or ChildProcessCommandLine CONTAINS "/dev/udp/"
or ChildProcessCommandLine CONTAINS ">&"
or ChildProcessCommandLine CONTAINS "curl "
or ChildProcessCommandLine CONTAINS "wget "
or ChildProcessCommandLine CONTAINS "base64"
or ChildProcessCommandLine CONTAINS "/tmp/"
or ChildProcessCommandLine CONTAINS "/dev/shm/")

Tier 2, an interpreter.

index=activity and rg_functionality="Endpoint Management Systems"
and (ParentImage ENDS WITH "/apache2" or ParentImage ENDS WITH "/httpd"
or ParentImage ENDS WITH "/nginx" or ParentImage ENDS WITH "/tomcat"
or ParentImage CONTAINS "/php-fpm"
or ParentProcessCommandLine CONTAINS "apache2"
or ParentProcessCommandLine CONTAINS "httpd"
or ParentProcessCommandLine CONTAINS "nginx"
or ParentProcessCommandLine CONTAINS "php-fpm"
or AccountName IN ("www-data","apache","apache2","httpd","nginx","http"))
and (Image ENDS WITH "/python" or Image ENDS WITH "/python3"
or Image ENDS WITH "/perl" or Image ENDS WITH "/ruby"
or Image ENDS WITH "/php")
and (ChildProcessCommandLine CONTAINS " -c "
or ChildProcessCommandLine CONTAINS " -e "
or ChildProcessCommandLine CONTAINS "base64"
or ChildProcessCommandLine CONTAINS "exec("
or ChildProcessCommandLine CONTAINS "http://"
or ChildProcessCommandLine CONTAINS "https://"
or ChildProcessCommandLine CONTAINS "/tmp/"
or ChildProcessCommandLine CONTAINS "/dev/shm/")

Tier 3, a transfer or tunnelling tool. Again no argument condition.

index=activity and rg_functionality="Endpoint Management Systems"
and (ParentImage ENDS WITH "/apache2" or ParentImage ENDS WITH "/httpd"
or ParentImage ENDS WITH "/nginx" or ParentImage ENDS WITH "/tomcat"
or ParentImage CONTAINS "/php-fpm"
or ParentProcessCommandLine CONTAINS "apache2"
or ParentProcessCommandLine CONTAINS "httpd"
or ParentProcessCommandLine CONTAINS "nginx"
or ParentProcessCommandLine CONTAINS "php-fpm"
or AccountName IN ("www-data","apache","apache2","httpd","nginx","http"))
and (Image ENDS WITH "/curl" or Image ENDS WITH "/wget"
or Image ENDS WITH "/nc" or Image ENDS WITH "/ncat"
or Image ENDS WITH "/netcat" or Image ENDS WITH "/socat")

Our own wget --version child is the worked example for why tier 3 stands alone. Its command line contains none of the tier 1 substrings, so the moment you fold the transfer tools in with the shells and let one argument condition cover both, you lose it. That event also shipped with ParentImage and ParentProcessCommandLine both as -, so the only parent branch that reaches it is AccountName. Tier 3 plus the account branch is what catches a transfer-tool child on a cold PHP-FPM worker, and nothing else in the rule does.

Note ParentImage CONTAINS "/php-fpm" rather than a suffix match. Distribution packages version-suffix the binary, as in /usr/sbin/php-fpm8.3, so a suffix match silently never fires on Debian or Ubuntu.

Run this one first, before you enable the account branch. It is the false-positive exposure, measured:

index=activity and rg_functionality="Endpoint Management Systems"
and AccountName IN ("www-data","apache","apache2","httpd","nginx","http")
and (Image ENDS WITH "/sh" or Image ENDS WITH "/bash")
and ChildProcessCommandLine CONTAINS " -c "
and (ParentImage ENDS WITH "/sudo" or ParentImage ENDS WITH "/cron"
or ParentImage ENDS WITH "/systemd")

Every row that returns is a legitimate maintenance action that satisfies the account branch and the shell branch together. Our own control, a sudo -u www-data /bin/sh -c true with no inbound HTTP at all, matches by design. Baseline and filter those command lines before the account branch goes live, because on PHP-FPM it is the branch most likely to be the only one that fires.

The IIS W3C corroboration, under the Web Server functionality, where RequestUrl is the URI stem, StatusCode is sc-status, TimeTaken is time-taken and BytesReceived is cs-bytes:

index=activity and rg_functionality="Web Server"
and RequestUrl CONTAINS ".php"
and StatusCode IN ("403","500")

That traces the token gate and the handled memory fatal. What it cannot do is the subject of the next section.

Two more from the same runs, because both are traps rather than detections.

The Event ID 1000 trap, under Microsoft Windows. If you reach for Application-log 1000 as a crash signal, this is what you actually get:

index=activity and rg_functionality="Microsoft Windows"
and BaseEventId=1000
and ProviderName="VGAuth"

On any VMware guest that returns VMware Tools authentication, not crashes. Our pre-capture count was 263 records and every one was VGAuth. Split on ProviderName before you anchor on the event id, and if you want real crashes constrain to ProviderName="Application Error" instead.

The SIGABRT witness, under Unix / Linux / AIX, which is the whole of what a terminated Apache worker produced in shipped telemetry:

index=activity and rg_functionality="Unix / Linux / AIX"
and AccountName="svc-h4xagent"
and Command CONTAINS "kill -ABRT"

One row. It records that a signal was authorised and says nothing about Apache. That is the negative result, expressed as the query that finds all of it.

What the telemetry carries, and what it does not

Windows Sysmon Event ID 1 is the load-bearing source. ParentImage, Image, CommandLine, User and hostname are all populated and all selectable together. This is the one layer where php2shell post-exploitation is cleanly visible.

Sysmon for Linux Event ID 1 carries Image, CommandLine, User and ParentProcessId reliably, with ParentImage conditional per the SAPI. CurrentDirectory would discriminate a web-application parent context beautifully, and it is right there in the raw record, but the Endpoint Management Systems functionality publishes no CurrentDirectory attribute, so it cannot go in a selector. Event ID 3 carries the accept that explains the Apache behaviour, but a forked worker's own Image is unresolved for the same reason, so "PHP initiated this connection" is not demonstrable on FPM.

IIS W3C carries method, URI stem and query, status, substatus, sc-bytes, cs-bytes, time-taken, user agent, host and X-Forwarded-For. It does not carry request Content-Type, and there is no request content-type attribute in the Web Server functionality either. That single omission is why the request layer failed: T1, with 51 parts, 24 repeated part headers, 8 empty-filename controls and an upload-progress field, and NC1, a single ordinary file part, both logged cs-bytes 4927 and status 200, differing only in time-taken at 745 versus 741 milliseconds. They are indistinguishable.

The WAF nginx JSON access log carries the true client address, host, method, URI, args, status, bytes sent, upstream address and status, timings and request ID. No content type. No body. No cookie. No part counts. The Web Proxy functionality does publish ContentType and HTTPDetails attributes, so here the log key is the thing that does not exist, not the attribute. Routing it through the Web Application Firewall functionality instead does not rescue it: that one publishes RuleMessages, RequestUrl, Method and Status, and has no content-type or HTTP-details attribute at all. Neither path gets you the request structure.

The ModSecurity JSON audit record is the only place the discriminating structure survives. It held the 24 repeated part headers and the PHP_SESSION_UPLOAD_PROGRESS field. And the rule result is worth stating plainly: CRS v4 at default paranoia raised no 922xx multipart rule on the anomalous request. The only rule that fired across the whole archived member was 920350, Host header is a numeric IP address, an artifact of addressing the lab by IP. There is no ModSecurity datasource in any of the 76 DataDictionary functionalities, so the one log that can see this is the one the SIEM cannot consume.

Windows Application logs produced the most portable incidental finding. A pre-capture count of Application-log events with Id -eq 1000 returned 263, which looked like a rich baseline for a crash rule. Every one was provider VGAuth emitting vmtoolsd: Username and password successfully validated, and 80 more were generated inside the capture window by the session's own guest-exec calls. Application-log Event ID 1000 is shared between VGAuth and Application Error on any VMware guest. Anchor a detection on event ID 1000 alone and you will alert on VMware Tools authentication on every virtualised host in the estate.

Version banners are unsafe here, and web02 is the live counter-example. php8.3-fpm 8.3.6-0ubuntu0.24.04.13 is the build Ubuntu shipped as the fix for both CVEs on 24.04 LTS. An upstream version-string rule comparing against 8.3.36 would mark that host as unpatched. It is patched. Distributors backport without changing the upstream-looking version. That is not a tuning problem, it is a rule that is wrong by construction. Verify by package provenance against the vendor's security tracker, not by phpinfo() and not by X-Powered-By, which is a response header absent from both the IIS field set and the nginx access log and present only in the ModSecurity audit record.

One more hazard, because it would have produced a confidently wrong field claim if we had ignored it. The collector's Linux path un-escapes XML entities, so a probe process whose own command line contained a search string appears in the archive with &gt; decoded and therefore matches an element-pinned archive pattern it does not match in the guest's raw log. Measured case: an element-pinned pattern for ParentProcessId 3610 returned 4 archive matches where the guest-authoritative count was 2, and the two extras were our own search processes. The discipline that follows: pick a unique action marker no probe ever carries, require the deciding fields to co-occur in one record, and cross-check counts against the guest.

The detections

Web Server or PHP Worker Spawning Suspicious Command Process Analytic. The Linux rule. Coverage L1, criticality High, logsource category: process_creation, product: linux. Four parent branches as shown in the queries above. Across the three runs the branch coverage now reads: shell exercised, interpreter exercised, transfer and tunnel exercised with wget, web-daemon ParentImage exercised and satisfied via /apache2, ParentProcessCommandLine exercised and satisfied, AccountName exercised and satisfied, /php-fpm ParentImage exercised and satisfied only under the warm-cache condition. The /httpd entry remains unexercised: Ubuntu names the binary apache2, and /httpd would match an RHEL-family path we do not have.

The Apache measurement is recorded in the merged rule, which also retracted the cold-case prediction that the run falsified. Every change reached master as its own focused pull request: one adding the account branch, one retracting the absolute parent-resolution claim, one recording the Apache result, and one correcting a premise about parsing that had crept into both files. On each of those a structural diff confirmed the change touched prose and the single version line only, with no change to any selector, the condition, the violation summary, references, labels, criticality, logsource or coverage level. The date did not move on either, because the detection logic did not.

L1 rests on the account mapping. The capture proves the raw Sysmon for Linux element User equals www-data, and that element is confirmed to parse into the published attribute AccountName, which is what the account branch selects on.

Windows IIS Worker or PHP CGI Process Spawning Suspicious Command Process Analytic. The Windows rule, and the new one. Coverage L1, criticality High, logsource category: process_creation, product: windows. The parent anchor covers php-cgi.exe, php.exe, w3wp.exe and Windows builds of httpd, nginx and tomcat.

It deliberately carries no account branch, and that asymmetry is a measurement rather than a style choice. Under IIS FastCGI php-cgi.exe is created by CreateProcess, so it is exec'd rather than forked, it is a fully tracked process, and ParentImage resolved correctly on all five captured children plus the specificity control. The parent anchor alone is sufficient, and an IIS APPPOOL\... account branch would import the Linux rule's false-positive exposure while buying nothing.

It spent its first two versions at L0, and the question that held it there turned out not to be a question. The argument gates select on the published attribute ChildProcessCommandLine, and the capture proves the raw Sysmon CommandLine element populated on every child, co-occurring in one record with User and ParentImage. ChildProcessCommandLine and ParentProcessCommandLine are simply what the Endpoint Management Systems functionality calls the child and parent command line, where the Microsoft Windows functionality calls the same field CommandLine. That is a naming convention per functionality, not a missing mapping, so there was nothing to prove and the rule went to L1 with no selector change.

Worth knowing if you go looking for the Security-log equivalent: WP01 had no Security-channel process-creation auditing during the capture. The Windows event log we collected carries 4624, 4648, 4672, 4776 and 4634 but no 4688, and no process command line at all. Sysmon Event ID 1 was the only command-line-bearing process-creation source on that host.

Suspicious WSUS Spawning Command Shell for Reconnaissance Analytic. The one other rule in the corpus anchored on an IIS worker parent, at L0. It requires w3wp.exe or wsusservice.exe with a flat list of shell and recon children and no command-line gating. Complementarity was measured, not assumed. It does not fire on the captured evidence, because the measured parent on all five children is C:\PHP\php-cgi.exe and it has no curl.exe or interpreter children either. There is one overlapping firing path, w3wp.exe spawning cmd.exe or powershell.exe, and there the new rule is the stricter of the two. The WSUS rule was left untouched: broadening it would leave a WSUS-named, WSUS-described file carrying general web-worker detection, and narrowing or retiring it is a content judgement, not a unilateral edit.

What we did not write, and why

No multipart request rule. No attribute in any of the 76 DataDictionary functionalities represents a per-part MIME header count, a multipart part count, a field-name length, a nesting depth, or a decoded-to-wire size ratio. That was checked against Web Proxy, Web Application Firewall, Web Server, Next Generation Firewall and Network Traffic Analytics. Corelight's published hunting thresholds, a 65,536-byte form-field name and 128 repeated multipart headers, are not encodable as selectors here (corelight/php2shell-detect). That is Zeek and Suricata content. A narrower rule on the PHP_SESSION_UPLOAD_PROGRESS token alone is schema-expressible against Web Proxy, and the token was proven to survive into a body-bearing WAF record. But the nginx access log carries no field holding it, so the log format has to be extended before any attribute could be populated at all. Building the rule first would produce YAML, not a detection.

No crash rule, on either platform. On Windows the worker did not die: a handled memory_limit fatal returns 500 and php-cgi.exe carries on with the same StartTime, and there are zero Application Error provider events in the log, ever. On Linux the worker did die, on a deliberate SIGABRT, and nothing about it reached the collector. Two platforms, two different reasons, same outcome. This is the biggest remaining gap and the honest statement of it is that the probe on Windows tested a graceful abort, which is not what either CVE does.

No second Linux PHP-worker rule. The existing rule owns that telemetry layer, and the update generalised it rather than adding a parallel rule for the same behaviour. Four narrower rules stay useful for their own cases and were not touched:

  • Base64 Encoded Payload Decode and Execute from Web Server Analytic
  • cPanel cpsrvd or cpdavd Spawning Unexpected Child Process Analytic
  • SLAPSHOT Python Tunneler Launch from Web Daemon Analytic
  • SUID Bit Grant on System Shell Analytic

No version or banner rule. Version-range comparison is not expressible in the schema, so the rule would have to enumerate every vulnerable patch string across four branches as literal substrings, and the verdict would be wrong by construction anyway because of the backport problem web02 demonstrates. Version exposure is vulnerability management work.

Operational findings and what to do about them

Patch, and verify by package provenance rather than by banner. The fixed upstream releases are 8.2.35, 8.3.36, 8.4.27 and 8.5.12. If you run distribution packages, check the vendor's security tracker, not phpinfo(). web02 would fail a banner audit and pass a real one.

Audit your IIS parent anchors today. If you host PHP under IIS FastCGI and your process-lineage detections anchor on w3wp.exe, you have a gap one process deep. Run the two one-line queries above against your own estate and compare the counts. Highest-value item here.

Know which SAPI you are detecting on, because the parent field behaves differently on each. Under IIS FastCGI it always resolves. Under Apache prefork it always resolves, from the worker's first request, because the worker accepts its own connections. Under PHP-FPM behind nginx it usually does not, because nginx terminated the TCP and the worker never emits an event of its own. Carry a parent-image-independent path for the FPM case, then baseline the maintenance command lines that path exposes.

Fix the edge, and do not expect CRS to carry this for you. A one-line nginx log_format change adding $content_type, $content_length and $request_length converts "no field visibility" into a measurable answer, and confirm the attribute is populated, not just that the log key exists. CRS v4 at default paranoia raised no multipart rule on a request carrying 51 parts, 24 repeated part headers and 8 empty file controls. Tune the 922xx family deliberately and test it rather than assuming it is on because the ruleset is installed.

Treat a web worker spawning a transfer tool as high signal and act on it. That is the behaviour CISA names, and it is the one that survives the attacker mutating everything about the request.

Limitations

Neither CVE was exploited, so nothing here demonstrates detection of actual exploitation. What was measured is the post-exploitation process shape and the request-layer field visibility. Those are different claims.

Every count here was taken from the raw collected telemetry rather than from a SIEM query. The Spotter queries express the same logic against the published attributes for the functionality each stream lands in, but the numbers quoted are archive counts and should be read as such.

The 97.85 percent is a fleet-wide property of Sysmon for Linux across three hosts, not a measurement of PHP-FPM workers. The PHP-FPM claim rests on the narrower observation beside it, that pool workers produce no process-creation record at all.

The Apache cold case was not reachable, and the reason is interesting rather than disappointing, but the residual stands: worker 3610 had accepted five earlier connections, so same-request self-warming is shown to be fast enough in principle and is not isolated as the cause.

ParentImage sits beyond the line-truncation window of the evidence search tooling in both record formats, so field claims were proven by writing the deciding field name and value into the search pattern itself, making a non-zero match count the proof. That is at least as rigorous as reading records, but it is a different method.

 

Appendix: the test matrix

Nine filtered traces across the three runs, each committed as an attack telemetry demonstration before any detection referenced it, so no detection ever cited a trace that did not resolve:

  1. The PHP-FPM shell child, 1 record.
  2. The ModSecurity audit records for the multipart precursor and its controls, 13 records, where the 24 repeated part headers and the upload-progress token survive and where all 8 rule messages are 920350.
  3. The IIS W3C view of those same requests, 5 records, where T1 and NC1 are indistinguishable.
  4. The five php-cgi.exe child-lineage records plus the non-web-parent specificity control, 10 records.
  5. IIS W3C corroboration of the same window, 9 records: token gate at 200 and 403, handled memory fatal as 500.
  6. The interpreter and transfer-tool branches plus the complete controlled parent-resolution sequence for FPM worker 9351, 8 records.
  7. The Apache mod_php prefork child lineage, 3 records: the intended-cold child, the warm child, and the specificity control.
  8. Every Sysmon record whose subject is /usr/sbin/apache2, 15 records: the inbound accepts that self-warm the per-PID cache, the single outbound warm connect, and the marker create and delete pair.
  9. The SIGABRT authorisation record, 1 record. The only shipped witness of the controlled termination anywhere in the collector, committed as a standalone negative result and cited by no detection.

Harness tokens are redacted everywhere they appeared in a captured record. The invalid-token control value is retained, because it is a deliberately non-secret control string and its presence is part of the control's proof.

Test Host and SAPI Action Result
T1 multipart precursor wp01 IIS 51 parts, 24 part headers, 8 empty file controls, upload-progress field, 4,621 body bytes 200, cs-bytes 4927, time-taken 745, no 922xx rule
NC1 size control wp01 IIS same body size, one ordinary file part 200, cs-bytes 4927, time-taken 741, indistinguishable from T1
NC2 encoding control wp01 IIS same field count, urlencoded 200, cs-bytes 637
NC3 static-route control wp01 IIS same multipart body to /license.txt 405
NC4 normal-upload control wp01 IIS unauthenticated async-upload.php shape 302, nothing created
A1c cmd child wp01 IIS cmd.exe /c echo <marker> EID 1, PID 3736, parent php-cgi.exe
A2 PowerShell child wp01 IIS powershell.exe -NoProfile -NonInteractive -Command ... EID 1, PID 5816, parent php-cgi.exe
A3 transfer child wp01 IIS curl.exe --version EID 1, PID 6016, parent php-cgi.exe
Windows absence controls wp01 IIS stock page; invalid token with action keyword; valid token no action zero children, parent count stays exactly 5
Windows specificity control wp01 IIS same three children from powershell.exe PID 2308 parent is PowerShell, no match
B1 memory probe wp01 IIS memory_limit 16M, bounded 64 KiB allocation loop 500; php-cgi.exe PID 1264 and w3wp.exe PID 4472 survive; zero Application Error events ever
LIN shell child web02 FPM sh -c -- true EID 1, 1 of 4,296, ParentImage -
A4 interpreter child web02 FPM python3 -c pass from worker 2039 EID 1, direct child, ParentImage -
A5 transfer child web02 FPM wget --version from worker 2042 EID 1, direct child, ParentImage -
A7 unwarmed child web02 FPM /bin/echo, static pool worker 9351 cold ParentImage -, 22:22:42.636
A8 warm action web02 FPM host-local connect plus /tmp marker create and delete EID 3, 11, 23 under PID 9351
A9 warmed child web02 FPM /bin/echo, same worker 9351 ParentImage /usr/sbin/php-fpm8.3, 22:22:50.670
Linux absence controls web02 FPM stock page; invalid token; valid token no action zero children
Linux account exposure control web02 FPM sudo -u www-data /bin/sh -c true, no HTTP request matches the account branch by design
Apache build web02:8081 prefork Apache 2.4.58, mpm_prefork, one worker, mod_php 8.3 master 1874, sole worker 1901, 200 at 246 bytes
Apache absence controls web02:8081 prefork static page; invalid token with action keyword; valid token no action 200/403/200, zero children
B1 SIGABRT web02 local guarded sudo kill -ABRT 1901, once master survives; replacement 3610 at +7.2s; HTTP at +12.1s; 1 AH00051; 1 apport file; nothing shipped
C1 intended-cold child web02:8081 prefork fixed shell child, first child action on replacement worker 3610 ParentImage /usr/sbin/apache2, NOT cold
C3 warm action web02:8081 prefork host-local connect plus /tmp marker create and delete EID 3 Initiated=true, EID 11, EID 23, all PID 3610
C4 warmed child web02:8081 prefork the same fixed shell child ParentImage /usr/sbin/apache2, identical to C1
D1 Apache specificity control web02 local same child as www-data from sudo, no HTTP ParentImage /usr/bin/sudo; account branch matches, parent branch does not

There is no A6. It was to be curl on Linux, and curl is not installed on web02, so A5 used wget instead and the label was retired rather than reused.

One incidental finding for anyone writing a /tmp selector against mod_php. apache2.service runs with PrivateTmp=yes, so the Event ID 11 FileCreate TargetFilename carries a host-namespace systemd-private-... prefix while the Event ID 23 FileDelete on the same file shows the plain in-namespace /tmp/p2s-apache-warm-3610.marker. The same file, two different paths, in two events one millisecond apart. Expect the prefix on create.