Detecting PHP2Shell

What to look for in your logs after CVE-2026-109618 and CVE-2026-109619

Research by
Hugo Baccino

Key Takeaway

- Two critical PHP bugs, attacked before the fix. CVE-2026-109618 and CVE-2026-109619 (PHP2Shell) let an unauthenticated attacker
run code on a PHP web server. Both are rated 9.2 out of 10.

- Patch, then restart. Update to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35 and restart every PHP process, so no worker keeps running the old code. PHP 8.1 and older get no official fix.

- Watch behavior. The attack runs before your code does. Look for unusual multipart POSTs, memory errors "in Unknown on line 0" and crashing PHP workers.

- Ready-to-use queries. For Microsoft Sentinel, Elastic and Splunk, built to run as scheduled rules, plus the log format they need and the lab used to validate them. The queries are on GitHub at aplite-de/php2shell-detection-rules.

On 21 October 2026, PHP fixed two critical flaws that let an unauthenticated attacker run code on a PHP web server. This post shows what to look for in your logs and how to monitor for it in Microsoft Sentinel, Elastic and Splunk, with queries we tested in our lab.

What Happened

The PHP team released PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix two bugs in the PHP core. Both are rated 9.2 out of 10 and allow unauthenticated remote code execution.

  • CVE-2026-109618. When PHP runs out of memory in the middle of a request, the data it was building can be left half finished, and the cleanup step treats that half-finished data as valid.
  • CVE-2026-109619. Data from one request can survive after the memory holding it has been freed, and a later request handled by the same PHP worker can end up using it.

Every PHP setup that serves web requests is affected, including PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. PHP rates mod_php as the highest risk, because a single unauthenticated request can trigger the attack before any application code runs.

Patch First

Update PHP now. Fixed versions are on the official PHP downloads page, and Linux distributions received the patches in advance, so check your vendor's packages too. Restart every PHP process afterwards, or an old worker will keep serving.

If you cannot patch immediately, the official advisory lists temporary settings, such as turning off session upload progress (session.upload_progress.enabled=0) and a low post_max_size. These block some attack paths but not all, and they are a stopgap, not a fix. Note that one commonly suggested workaround, removing the memory limit entirely, also removes the very error this detection relies on, so prefer the advisory's settings over that.

What To Look For In Web Server Logs

The attack has a recognizable shape, and even a patched server logs the attempts. No single signal proves a compromise. The strength is in seeing several of them from one source in a short window, and in lining up a crash in the error log with the requests that came just before it.

Close The Logging Gap First

Default access logs leave out the three fields that make this attack visible: the content type of the request, whether it declared a size, and how big it was. Add them, and use the same format across all your servers so the queries work everywhere.

These are the formats the detection queries expect (also present in our GitHub repository under log-formats/). Apache needs mod_logio for the %I bytes-received field:

# Apache httpd. Adds Content-Type (ct), Transfer-Encoding (te) and bytes received (reqlen).
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" ct=\"%{Content-Type}i\" te=\"%{Transfer-Encoding}i\" reqlen=%I" php2shell

# nginx. Put this in the http block, then: nginx -t && systemctl reload nginx
log_format php2shell
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'ct="$content_type" te="$http_transfer_encoding" reqlen=$request_length';

access_log /var/log/nginx/access.log php2shell;

One optional addition pays off: also log the request duration (%D on Apache, $request_time on nginx). In our lab the attack requests took around 120 ms while a real upload of similar size took about 1 ms, so duration is one of the cleanest ways to separate them.

A word on safety: never log the request body, the Cookie or Authorization headers, or the session ID. Log only what these formats capture, and treat the resulting logs as personal data, since they contain IP addresses.

Access, proxy and WAF signals

- Upload-style POSTs to pages that never take uploads, such as /index.php or a login page. The request is multipart/form-data but the target has no reason to receive a file.

- POSTs with no declared length. A request sent without a size shows up as Transfer-Encoding: chunked. Note the malicious body is deliberately small, a few hundred kilobytes in our tests, since it is crafted to blow up in memory rather than on the wire. Do not expect a large body, expect a small, slow, chunked one.

- Bursts from one source to the same URL within minutes, because the attack retries to hit the right worker.

PHP and web server error signals

- An out-of-memory error that names no file. Look for Allowed memory size of or Out of memory together with in Unknown on line 0. "Unknown on line 0" means the failure happened before any script ran, which is the fingerprint of this attack. See the next section for why the wording matters.

- Worker exits and restarts. On PHP-FPM, a worker that exits and is replaced right after one of these requests is a strong signal. In our tests the memory failure produced a clean exit (code 70). A segmentation fault (signal 11) is a heavier signal that points to memory actually being corrupted, that is, the attack going beyond the initial trigger. Alert on both, but treat the segfault as higher severity.

- Timeouts (Maximum execution time of ...) near those POSTs, for variants that exhaust CPU time rather than memory.

WAF or load balancer signals

- Long or unusual PHPSESSID values in Set-Cookie responses.

- A multipart part named PHP_SESSION_UPLOAD_PROGRESS sent alongside a field named PHPSESSID, which matches the public proof of concept.

Do not match on tool names such as "php2shell" in the user agent. They are trivial to change and will give you a false sense of coverage.

We built a lab to confirm this

To be sure the signals above are real and not just plausible, we built a small lab: two web stacks, each in a vulnerable and a patched version, side by side. You can find all the files and detailed results in our GitHub repository, in /lab.

- Apache with mod_php, where PHP runs inside the web server.

- Nginx with PHP-FPM, where PHP runs as a separate service the web server talks to.

- Vulnerable build PHP 8.4.26, patched build PHP 8.4.27. Same release branch, so the only meaningful difference is the fix. Default configuration, no tuning.

Everything runs on an isolated network with nothing exposed. We send the attack's request shape, an upload-style POST crafted to drive PHP out of memory while it reads the request, before any page code runs, and we compare what each server writes to its logs.

We stop at the memory failure the attack depends on; we do not weaponize it. The lab is published next to the detection queries so you can reproduce every line below.

One detail explains the shape of the malicious request. Modern PHP caps the number of parts in an upload at 1020 by default, so a body of "thousands of empty parts" is simply ignored past the limit.

This cap is not the fix, and it is present on both the vulnerable and the patched build.

The working trigger uses deeply nested field names, which makes PHP allocate far more memory per part and run out of memory within that cap. The October fix does not change the cap; it makes PHP handle the out-of-memory failure safely instead of leaving the corrupt state the attack needs.

On Apache (mod_php), the vulnerable server records the failure like this:

[php:error] [pid 19] [client 198.51.100.23:54321] PHP Fatal error:
Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) in Unknown on line 0

"Unknown on line 0" means PHP ran out of memory before any script started. The worker survives and serves the next request, which is exactly what the second stage of the attack relies on.

The patched server, given the identical request, shrugs it off:

[php:warn] [pid 18] [client 198.51.100.23:54321] PHP Warning:
PHP Request Startup: Multipart body parts limit exceeded 1020.
To increase the limit change max_multipart_body_parts in php.ini. in Unknown on line 0

A harmless warning, and a normal 200 response. We confirmed this across a wide range of request sizes: in default configuration the vulnerable build ran out of memory every time, the patched build never does.

This is the key to low-noise detection. Both lines end in "Unknown on line 0", so match on the out-of-memory wording (Allowed memory size of ... exhausted), not on "Unknown on line 0" by itself. Otherwise every patched PHP 8.4 or newer server under internet scanning will light up your alerts.

On Nginx with PHP-FPM, the failure is louder. When PHP runs out of memory at the very start, the PHP worker dies mid-answer. Nginx sees a cut-off reply and logs a 502:

[error] upstream sent unexpected FastCGI record: 3 while reading response header from upstream, client: 198.51.100.23, request: "POST /index.php HTTP/1.1",

upstream: "fastcgi://...:9000"

and PHP-FPM's own log records the error and the worker dying and being restarted, an unusual event worth alerting on by itself:

NOTICE: PHP message: PHP Fatal error: Allowed memory size of 134217728 bytes exhausted ... in Unknown on line 0
WARNING: [pool www] child 11 exited with code 70 after ... seconds from start
NOTICE: [pool www] child 12 started

Note code 70, PHP's clean out-of-memory exit, not a segfault. Treat code-70 exits as "the attack is biting" and segfaults as "possible corruption, investigate now."

Telling the attack apart from a normal crash. An ordinary application that runs out of memory produces a nearly identical line, except it names a real file:

... Allowed memory size of 134217728 bytes exhausted ... in /var/www/html/report.php on line 42

The attack names no file ("in Unknown on line 0"). That one difference is the cleanest way to keep everyday application errors out of your alerts.

What the logs can and cannot tell you. Everything above proves the attack was attempted and, on an unpatched server, that it reached the dangerous state it needs. It does not prove the attacker ran their own code. For that you need the behavioral signal in the next section, a PHP or web server process launching a shell, which is why we treat that one as the real "success" indicator and treat everything in the logs as a strong lead to go and look at the host.

Detection Queries

The queries are on GitHub at aplite-de/php2shell-detection-rules. There are three for each platform (Microsoft Sentinel, Elastic and Splunk), plus the log formats they need. The README explains how to run and tune them.

  • Query 1, suspicious form POSTs, from access logs. Finds multipart POSTs to URLs that never take uploads, chunked multipart bodies, very large form bodies and bursts of multipart POSTs from one source, in web server access logs.
  • Query 2, memory errors and worker crashes, from PHP and web server error logs. Finds out-of-memory errors raised before any script ran and crashing PHP workers, in PHP and web server error logs.
  • Query 3, PHP spawning a shell, from EDR (endpoint detection and response) data. Finds a PHP or web server process starting a shell, script interpreter or download tool, in EDR data. This is the clearest sign that an attack worked, so treat it as high severity.

Schedule them to run every hour. They match on behavior, not on tool names, so they keep working when attackers change their tools.

Tune the thresholds and test the queries on your own data before you turn them into alert.

Conclusion

PHP2Shell is a reminder that a bug can be exploited in the window between disclosure and patching, and that scanning continues long after.

Patch to a fixed version and restart your workers. Then make sure your logs actually capture the request details this attack reveals itself through, and lean on behavior, the memory failures, the worker crashes, and above all a web process spawning a shell, rather than on anything the attacker can trivially change.

The lab and the queries are on GitHub so you can confirm the signals on your own stack before you rely on them.