↓ Skip to main content
php2shell: an investigation playbook
  1. MindSecSet/

php2shell: an investigation playbook

·2193 words·11 mins·
Author
Prakash Ayyanagoudar
Table of Contents

Patching php2shell is the easy part. The harder question is the one your manager asks the next morning: were we hit?

This is the playbook I’d work from. It is built only from what PHP, Amazon, Imperva, Cloudflare, Huntress and Wiz have published. Where they are silent, I say so. No IOCs have been published that I could verify, so there are none here. What follows is where the bugs should leave marks, given how they work.

The commands below were run against a live Apache + mod_php, PHP-FPM and nginx stack (forced OOM and a worker SIGSEGV) to confirm which log each signal actually lands in. Paths and SAPI names still vary by distro; adjust them.

Background on the bugs is in the first post. This one is for the person holding the ticket.

0. set the clock
#

Investigation window: from 2026-10-17 00:00 UTC to the moment each host was patched and its workers restarted.

Amazon’s MadPot honeypots logged the first exploit attempt on October 17, four days before disclosure. Amazon’s own advice: “Start investigations from the earliest known exploitation, not from the disclosure date.”

What we know:

  • Exploit attempts were seen from Oct 17 (Amazon), with more from several actors on Oct 21 (Amazon). watchTowr reports exploitation in the wild from Oct 21.
  • Public proofs of concept appeared around 02:00 UTC on Oct 21 (Amazon).
  • Huntress had not seen exploitation across its partner base as of Oct 21, 4:30pm ET.

What we don’t know:

  • How many attempts succeeded anywhere. Amazon says it is unclear whether the Oct 21 attempts “would have succeeded in real-world systems.”
  • Any reliable network indicators. None published.

So the job is not “match an IOC.” It is “look for the side effects these bugs cannot avoid.”

1. the five-minute triage
#

Answer these before touching anything. They decide the priority.

QuestionWhy it matters
Do we run PHP that serves web requests?CLI-only PHP (cron, scripts) is not the exposure. PHP lists FPM, mod_php, php-cgi, FrankenPHP and LiteSpeed.
Is any of it Apache with mod_php?PHP rates the risk highest there, with a demonstrated pre-application exploit. Go there first.
Is it reachable from the internet?An internet-facing, unpatched host inside the window is your top suspect.
When was it patched, and were workers restarted?That timestamp closes the window for that host.
Is a WAF in front, and which rules?Cloudflare deployed php2shell rules from Oct 21, 03:02 UTC, on all plans. Your WAF logs may hold the only record of request bodies.

2. inventory: find every php
#

The hosts that hurt you are the ones nobody remembers. Huntress says it well: inventory “every PHP install you own (including the ones hiding inside vendor software).”

Running processes (what is actually serving):

ps -eo pid,user,cmd | grep -E 'php-fpm|httpd|apache2|frankenphp|lsphp' | grep -v grep
apachectl -M 2>/dev/null | grep -i php        # mod_php shows as php_module / php7_module

Installed packages:

dpkg -l | grep -E '^ii\s+php'                  # Debian / Ubuntu
rpm -qa | grep -i php                          # RHEL / Rocky / Alma / Fedora
apk info 2>/dev/null | grep -i php             # Alpine

Containers (the most common blind spot):

docker ps --format '{{.ID}} {{.Image}}'
docker exec <id> php -v

Is it patched? Fixed upstream: 8.5.12, 8.4.27, 8.3.36, 8.2.35. Two traps:

  • Distro packages often backport fixes without changing the upstream version number. php -v can show an “old” version that is patched, or the reverse. Check the package changelog for the CVE IDs. apt changelog needs network and fails on third-party repos (sury.org, ondrej PPA), so read the on-disk changelog instead:
# Debian / Ubuntu — the installed changelog, no network:
zgrep -E 'CVE-2026-10961[89]' /usr/share/doc/php*/changelog.Debian.gz
# RHEL / Rocky / Alma:
rpm -q --changelog php | grep -E 'CVE-2026-10961[89]'

A match confirms the fix is in the installed package even when php -v shows an unchanged version.

  • 8.1 and older get no upstream release. PHP published minimal source backports for 7.4, 8.0 and 8.1; otherwise you depend on your vendor. PHP also warned, as Rapid7 and Huntress relay, that rpms.remirepo.net would not have same-day updates: switch to distribution packages or build from source.

Record per host: version, SAPI, internet-facing yes/no, patch time, restart time. That table is your scope.

3. check which stopgaps were in place
#

Stopgaps don’t make a host safe, PHP is clear on that, but they change which attack paths were open during the window.

# use the versioned FPM binary (php-fpm8.4, php-fpm8.3, ...), not plain php:
php-fpm8.4 -i | grep -E 'session.upload_progress.enabled|memory_limit|post_max_size|fatal_error_backtraces'
grep -RnE 'session.upload_progress|memory_limit|post_max_size|fatal_error_backtraces' /etc/php* 2>/dev/null

php -i shows the CLI config, which is a different SAPI. On a stock install the CLI runs memory_limit = -1 while the FPM pool runs 128M, so the CLI reading is meaningless here. Read the config the web SAPI actually loads.

Why session.upload_progress matters: per Imperva, three of the five demonstrated exploitation techniques use PHP’s session upload-progress feature as the remote trigger. It runs while PHP parses a multipart body, before your application code. If it was off for the whole window, those three paths were closed. The other two (an OPcache path and a fatal-error backtrace path) still need assessing.

4. hunt: what the bugs leave behind
#

Both bugs work the same way: PHP aborts a request (out of memory, or a fatal error), leaves state half-cleaned, and a later request on the same worker trips over it. Imperva notes that turning this into code execution is the hard part. When timing or memory placement is off, “the worker may crash or respond normally instead of executing a command.”

That is good news for the hunter. Failed attempts crash workers. Crashes get logged.

Imperva’s own list of investigation leads: “unusually large multipart payloads, excessive field counts, anomalous session-related parameters, and unexpectedly large PHPSESSID response values,” correlated with memory-exhaustion errors and worker crashes. Here is where each one lives.

4a. memory exhaustion errors
#

The trap here: with the default catch_workers_output off, PHP-FPM sends PHP’s own errors to the web server’s error log, not to a dedicated PHP log. On an nginx+FPM box the Allowed memory size line lands in nginx’s error log; under mod_php it lands in Apache’s. So grep all three:

grep -rh 'Allowed memory size of' \
  /var/log/nginx/ /var/log/apache2/ /var/log/httpd/ \
  /var/log/php*fpm*.log 2>/dev/null

Look for clusters after Oct 17, especially on endpoints that never ran out of memory before. A baseline from last month beats any fixed threshold.

4b. worker crashes
#

# PHP-FPM
grep -hE 'exited on signal (11|6|7)' /var/log/php*-fpm.log /var/log/php-fpm/*.log 2>/dev/null
# Apache (mod_php)
grep -h 'exit signal Segmentation fault' /var/log/apache2/error*.log /var/log/httpd/error*log 2>/dev/null
# kernel
journalctl -k --since '2026-10-17' | grep -iE 'php|httpd|apache2' | grep -i segfault
# nginx in front of FPM: a crashed worker drops the FastCGI socket mid-response
grep -hE 'recv\(\) failed .*while reading response header from upstream|upstream prematurely closed|Connection reset by peer' /var/log/nginx/error*.log 2>/dev/null

A segfault in PHP is rare on a healthy production box. A run of them in the window, timed with 4a, is the strongest signal you will get from local logs.

4c. the access log, and its blind spot
#

Default access logs record the response size, not the request size. The bugs are triggered by large or many-part request bodies. So check first what your logs can actually see:

  • Apache logs request bytes with %I (needs mod_logio). nginx has $request_length. If neither is in your format, you cannot measure body size from these logs. Write that down as a gap.
  • What you can still do: find POST requests with multipart content to endpoints that normally take no uploads, and bursts of requests from one source to one endpoint. The exploit needs follow-up requests to reach the same worker, so it is not a single shot.
  • Line up 500/502/503 spikes with the crash timestamps from 4b.

4d. what only the WAF or proxy saw
#

Request bodies and response headers rarely reach local logs. Your WAF, CDN or reverse proxy may have them:

  • multipart bodies carrying the upload-progress field (its default name is PHP_SESSION_UPLOAD_PROGRESS) on endpoints that don’t use it
  • excessive field or part counts
  • Set-Cookie: PHPSESSID= values far longer than normal. Imperva describes a memory-disclosure path that returns roughly 67 KB of process memory through that header.

On Cloudflare: Security > Security Analytics > Events, filtered by the php2shell rule IDs in Cloudflare’s post. Blocked events show what was tried; they don’t show what reached an unprotected host.

4e. signs of code execution
#

If an attempt worked, the attacker has code execution as the web worker’s account (www-data, apache, nginx). Not root, but everything that account can reach. Look for what a web worker should never do:

# shells and tools spawned by the web worker
ps -ef --forest | grep -B2 -A2 -E '(sh|bash|dash|curl|wget|python3?|perl|nc|ncat|socat)( |$)'
# PHP files changed in the web root during the window
find /var/www -newermt '2026-10-17' -type f \( -name '*.php' -o -name '.htaccess' \) -ls
# files dropped where the worker can write
find /tmp /var/tmp /dev/shm -user www-data -newermt '2026-10-17' -ls 2>/dev/null
# persistence (crontab is often absent in containers, so read the spool too)
crontab -u www-data -l 2>/dev/null; ls -la /var/spool/cron/crontabs/ /var/spool/cron/ 2>/dev/null
# live outbound connections from the worker (needs root)
ss -tpn | grep -E 'php-fpm|apache2|httpd'

Swap www-data and /var/www for your distro and layout. The single highest-signal query, if you have EDR or auditd: any child of php-fpm, httpd or apache2 that is a shell or network tool since Oct 17. On a healthy box the web worker forks nothing. A quick live check:

for p in $(pgrep -f 'php-fpm: pool|apache2'); do pgrep -aP "$p"; done

4f. the leak you can’t see
#

The memory-disclosure paths are the quiet half. Wiz says the leaked process memory “may contain passwords, authentication tokens, API keys and certificates belonging to other requests or the server itself.” A leak leaves no file and no process behind.

So list what that worker held in memory: database credentials, API keys in env or config, session tokens, TLS keys if PHP terminates TLS, anything the app decrypts. That list drives rotation if you can’t rule exploitation out.

5. read the evidence: call it
#

What you foundCallDo
Patched and restarted before Oct 17, or no web-facing PHPNot exposedDocument and close.
Unpatched in the window, nothing in 4a to 4e, good loggingExposed, no evidencePatch, keep the WAF rules on, close with the evidence noted.
Unpatched in the window, gaps in logging (no request size, no body or response visibility)Exposed, unknownPatch. Rotate the high-value secrets from 4f. Say plainly that absence of evidence is limited here.
Memory errors and crashes clustering in the window, odd multipart trafficSuspected exploitationEscalate to IR. Preserve logs now, before rotation overwrites them. Rotate secrets. Look harder at 4e.
Shells under the worker, new PHP files, unknown cron, outbound connectionsCompromisedIsolate, image or snapshot, full IR. Rotate everything the worker could reach. Rebuild rather than clean.

The third row is where most teams will land, and the one most tempted to round down to “no evidence.” Don’t. Write down what you could not see.

6. close it out
#

  • Patch and restart every worker. A patched package with old workers still running is not patched.
  • Can’t patch today: PHP’s stopgaps (session.upload_progress.enabled=0 in php.ini or the pool, then restart; on 8.5, fatal_error_backtraces=0; a low post_max_size; memory_limit=-1 with a container or cgroup limit as the real boundary). Imperva adds opcache.enable=0, after testing, for the OPcache path. None of these are a fix.
  • End-of-life PHP: isolate it from the internet until it is upgraded or carries a verified vendor backport.
  • Keep the WAF rules on after patching, as a second layer.

7. the gaps this exposed
#

Most of this playbook is limited by logs we chose not to keep. Worth fixing before the next one:

  • request size in access logs (%I or $request_length)
  • WAF or proxy retention long enough to look back a week
  • process telemetry on web servers: who spawned what
  • a living inventory of where PHP runs, including containers and vendor appliances

the part that stays
#

Every advisory this week ends with “patch now.” Fair. But patching answers “are we safe from tomorrow?” It says nothing about last Thursday.

Honestly? The hardest line to write in an incident report is “we could not see.” It is also the most honest one, and the one that gets the logging budget approved.

What’s the one log you wish you had kept this week?

the patch closes the door. it doesn’t tell you who walked in.

sources
#

  • PHP, security advisory for 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 (who is at risk, mitigations): php.net
  • PHP 8 ChangeLog: php.net/ChangeLog-8.php
  • Amazon Threat Intelligence, PHP2Shell exploitation prior to public disclosure in MadPot: aws.amazon.com
  • Imperva Threat Research, “PHP2Shell: When PHP Runs Out of Memory at the Wrong Moment” (techniques, investigation leads): imperva.com
  • Cloudflare, “Cloudflare deploys protections against php2shell vulnerability” (WAF rules and timing): blog.cloudflare.com
  • Huntress, rapid response on php2shell (inventory advice, observations): huntress.com
  • watchTowr, CVE-2026-109618 FAQ (in-the-wild status): watchtowr.com
  • Wiz Research, PHP2Shell (memory disclosure impact): wiz.io
  • Rapid7, ETR on CVE-2026-109618 and CVE-2026-109619 (remi repository note): rapid7.com

Related