Sitemap
Bayu Sangkaya

Bayu Sangkaya

Seorang SysAdmin yang berpengalaman gelar server dari scratch (merakit dan memasang hardware) sampai monitoring dan analysis.

Detecting PHP2Shell from Real Attack Telemetry

6 min readOct 23, 2026

--

How CVE-2026–109618 / CVE-2026–109619 look in your Apache and PHP logs, and how to catch them with Wazuh

This article is purely defensive. It reconstructs the log trail of a PHP2Shell attack to build detection. It contains no exploit code, offsets, or weaponization steps. All testing was done on an isolated lab inside Cybershield.id’s environment.

Press enter or click to view image in full size
The POC of the CVE

When Wiz, OpenAI, and Calif disclosed PHP2Shell — two memory-safety vulnerabilities in the PHP runtime (CVE-2026–109618 and CVE-2026–109619, CWE-824, CVSS 9.2), the scary part wasn’t just that they reach pre-authentication remote code execution. It was that the flaw lives in PHP itself, not in application code, so even an endpoint that merely echoes “OK” is exposed.

The good news for defenders: a successful exploit is noisy. It leaves a rich, layered trail in standard logs. You never need to “see” the RCE to catch the attempt. This post dissects one real run of a public PoC against my lab and maps every log artifact to a Wazuh detection rule.

Why you detect the trail, not the exploit

PHP2Shell weaponization is probabilistic. Triggering the bug is easy; turning it into code execution requires landing attacker-shaped data into freed memory on the same worker at the right moment. Most attempts just crash a worker; occasionally one lands. Because the final step has no fixed byte signature, detection targets the preconditions (triggers) and the effects (crashes, memory disclosure) instead.

The lab

  • Image: php:8.4-apache (Debian Bookworm) via Docker
  • PHP 8.4.26, Apache 2.4.69 (prefork), memory_limit = 8M (image default)
  • Target: http://10.10.1.6:8080/index.php
  • Attacker: 172.18.151.246 (Docker network)
  • Result: PRE-AUTH RCE CONFIRMED, uid=33(www-data)

The exploit ran id, wrote the output to a file, then retrieved it via GET /p1, returning:

uid=33(www-data) gid=33(www-data) groups=33(www-data)

The attack, as the logs saw it

The entire chain unfolded in about 37 seconds.

15:22:13  POST /index.php 200 4268   -> initial burst
15:22:13 [php:warn] Cannot modify header information - headers already sent
in /var/www/html/index.php on line 112 (and 113)
15:22:13 POST /index.php 200 693
15:22:14 [php:warn] PHP Request Startup: Input variables exceeded 1000.
To increase the limit change max_input_vars in php.ini
15:22:14 [php:warn] PHP Request Startup: Session cookie cannot be sent after
headers have already been sent
15:22:14 [php:error] PHP Fatal error: Allowed memory size of 8388608 bytes
exhausted (tried to allocate 1589248 bytes) in Unknown on line 0
15:22:14 zend_mm_heap corrupted
15:22:18 [core:notice] AH00051: child pid 100 exit signal Abort (6),
possible coredump in /etc/apache2
15:22:21 [php:error] [client 172.18.151.246:60739] Allowed memory size of 8388608
bytes exhausted (tried to allocate 2098791 bytes) in Unknown on line 0
15:22:26 [core:notice] AH00051: child pid 101 exit signal Segmentation fault (11),
possible coredump in /etc/apache2
15:22:27 GET /p1 200 275 -> command output retrieval (RCE proof)
15:22:42 GET /p1 200 256 "curl/8.7.1"
15:22:50 GET /p1 200 312 "Mozilla/5.0 ... Firefox/158.0"

Read it in order and the shape becomes obvious:

  1. A burst of POST /index.php within seconds, the attacker rotates workers to hit the same process with a crafted body.
  2. “Input variables exceeded 1000”, the body carries more than 1000 POST fields, a side effect of memory grooming, and a precursor that rarely appears in normal traffic.
  3. “Cannot modify header information” and “Session cookie cannot be sent”, output forced out before headers, consistent with session/output manipulation.
  4. An OOM at exactly 8388608 bytes (8M), the attacker fingerprinted memory_limit=8M and deliberately exhausted it to leave a stale pointer behind.
  5. zend_mm_heap corrupted, the single most specific indicator. The Zend heap manager detected corruption. You almost never see this in a healthy app.
  6. The worker dies twice, two different ways: exit signal Abort (6), SIGABRT from the heap-corruption abort, then Segmentation fault (11). A rule that only looks for segfaults will miss the SIGABRT path entirely.
  7. GET /p1, a short, unfamiliar path that returns command output. This is the post-exploitation tell: the attacker retrieving the result.

None of these lines is the RCE. Together, in a tight window on one host, they are unmistakable.

The three signals most rules miss

If you already wrote PHP2Shell detection from the advisory alone, real telemetry adds three artifacts you probably do not have yet:

zend_mm_heap corrupted
AH00051: child pid <N> exit signal Abort (6)
PHP Request Startup: Input variables exceeded 1000
  • zend_mm_heap corrupted is the strongest standalone marker, raise it to a high severity with almost no false positives.
  • exit signal Abort (6) extends worker-crash detection beyond segfault(11). This PoC killed its first worker with SIGABRT, not a segfault.
  • Input variables exceeded 1000 is a cheap precursor that sharpens confidence when correlated with the OOM and the crash.

One more detail worth noting: the fatal error reads “in Unknown on line 0”, not a real file path. That is because it fires in an internal runtime phase, not in application code , exactly what you expect from a flaw in the PHP runtime itself.

Wazuh decoders and rules

The idea is to detect from error logs. Anchor the decoders on body tokens so they work regardless of the log header (native PHP format, Apache mod_php, or journald).

Get Bayu Sangkaya’s stories in your inbox

Join Medium for free to get updates from this writer.

Decoders (excerpt):

<!-- OOM, with srcip pulled from the mod_php "[client IP:port]" field -->
<decoder name="php2shell-oom">
<prematch>Allowed memory size of </prematch>
</decoder>
<decoder name="php2shell-oom">
<parent>php2shell-oom</parent>
<regex>Allowed memory size of (\d+) bytes exhausted</regex>
<order>php_mem_limit</order>
</decoder>
<decoder name="php2shell-oom">
<parent>php2shell-oom</parent>
<regex>[client (\d+.\d+.\d+.\d+):\d+]</regex>
<order>srcip</order>
</decoder>
<!-- Strongest single signal -->
<decoder name="php2shell-heap">
<prematch>zend_mm_heap corrupted</prematch>
</decoder>
<!-- Catch BOTH Segmentation fault (11) and Abort (6) -->
<decoder name="php2shell-worker-exit">
<prematch>exit signal </prematch>
</decoder>
<decoder name="php2shell-worker-exit">
<parent>php2shell-worker-exit</parent>
<regex>child pid (\d+) exit signal</regex>
<order>proc_pid</order>
</decoder>

Rules (excerpt):

<!-- zend_mm_heap corrupted: very strong -->
<rule id="100222" level="12">
<decoded_as>php2shell-heap</decoded_as>
<description>PHP2Shell: zend_mm_heap corrupted - heap corruption (very strong indicator)</description>
<mitre><id>T1190</id></mitre>
<group>php2shell,exploit_attempt,</group>
</rule>
<!-- Worker killed by SIGABRT (the path a segfault-only rule misses) -->
<rule id="100212" level="6">
<if_sid>100210</if_sid>
<match>Abort</match>
<description>Apache worker killed by SIGABRT - heap corruption abort (possible PHP2Shell)</description>
<mitre><id>T1190</id></mitre>
<group>php2shell,segfault,php2shell_segv,</group>
</rule>
<!-- The key correlation: OOM followed by a worker crash on the same host -->
<rule id="100230" level="10" timeframe="120">
<if_sid>100218</if_sid>
<if_matched_sid>100201</if_matched_sid>
<description>PHP2Shell: memory exhaustion followed by worker crash on same host - likely exploitation attempt</description>
<mitre><id>T1190</id></mitre>
<group>php2shell,exploit_attempt,</group>
</rule>

The detection that matters is not any single rule, it is the correlation: OOM, then a worker crash (Abort or Segfault), on the same host, within a short window. In this telemetry the gap was 4 to 12 seconds.

Docker vs bare-metal: are the logs the same?

Yes, the log lines are identical in content, because PHP and Apache generate them inside the container. Only collection and context differ:

  • Log destination: the official php:apache image sends logs to the container’s stdout/stderr (/proc/self/fd/1 and /dev/stderr), not to /var/log/apache2/. So collect them with docker logs, a logging driver, or by reading the host json log at /var/lib/docker/containers//*-json.log.
  • Client IP: inside Docker it is the network IP (172.18.x). Behind a real reverse proxy or CDN, the “[client …]” field is the proxy/CDN IP unless you configure mod_remoteip (RemoteIPHeader X-Forwarded-For). Source-based correlation must use the X-Forwarded-For client, not the raw remote IP.
  • PID 1 is the Apache master in the container (vs systemd-managed on bare metal). Cosmetic, the signals are the same.

A word on auto-blocking

The attacker IP does appear in the mod_php error lines ([client 172.18.151.246:60739]), which makes active response tempting. Do it carefully.

  • Behind a CDN, that “[client …]” IP is the CDN edge, not the attacker. Dropping it at the host firewall takes the whole site offline. Either configure mod_remoteip so the real client IP is logged, or push the block to the CDN/WAF via your SOAR instead.
  • OOM and segfaults can be benign. Trigger active response only on a strong, IP-bearing signal (for example, repeated OOM from the same source in a short window), never on a single event.
  • Use a timed block, keep a safelist of internal and CDN ranges, and run alert-only first while you tune.

Mitigation

Patching PHP is the real fix. When framework compatibility locks the version, the temporary php.ini measures from the advisory help:

session.upload_progress.enabled = 0 #closes three of the five demonstrated techniques 
opcache.enable = 0 #test for performance impact first

but they do not close every path, so active detection stays mandatory as compensation.

Takeaway

PHP2Shell is a runtime memory-corruption bug, and runtime memory corruption is loud. OOM, heap-corruption aborts, segfaults, an input-vars precursor, and result retrieval all land in logs you already collect. Build the correlation, add the three signals most people miss, and you will see the attempt long before you ever see a shell.

If this helped, the full Wazuh decoders, rules, and Suricata signatures are things I’m happy to share, still work in progress by the way. And if you need professional Cybersecurity Services, connect with us at Cybershield.id. Stay defensive.

--

--

Written by Bayu Sangkaya

Seorang SysAdmin yang berpengalaman gelar server dari scratch (merakit dan memasang hardware) sampai monitoring dan analysis.