Your host emails you: PHP shipped a security release, and your WordPress site on Apache with mod_php runs a version older than the fixed one. Your application code is clean and your plugins are current, yet the advisory says that does not matter. This article explains what PHP2Shell is, what is and is not confirmed about it, and how to verify your version, test interim hardening and detect a suspicious worker in a local Docker lab. The lab contains no exploit code. Everything runs on 127.0.0.1 for authorized, educational use.

What is PHP2Shell?

PHP2Shell is the nickname for two critical flaws in PHP itself, published on 2026-10-21 by the PHP project (NVD and the php-src GitHub advisories). Both concern how PHP handles request memory, so they sit below your application.

CVE-2026-109618 CVE-2026-109619
PHP’s title RCE via OOM corruption RCE via stale global state
Root cause longjmp() out of an out-of-memory error leaves request memory uninitialised; request cleanup then reads it Global state keeps pointing at freed request memory; a later request on the same worker uses it
Trigger noted by PHP Several multipart parsing behaviours can cause OOM within apparent limits A "producer" request and a "consumer" request on the same worker
CVSS v4.0 9.2 Critical 9.2 Critical

Both carry the 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. Note AC:H and AT:P: the score is critical, but the vector says exploitation is not trivial.

NVD records for both CVEs: CVSS v4.0 9.2 and affected ranges

Impact

PHP’s advisories confirm unauthenticated remote code execution as the web worker account. The CVE-2026-109618 exploit was validated over HTTP against mod_php with default input and memory limits. The advisory for CVE-2026-109619 also lists heap use-after-free and memory disclosure as confirmed impact. On a typical WordPress or shared-PHP host, code running as the worker user can read the site’s files, including wp-config.php with its database credentials, and use the database. Clean application code does not help: the corruption happens in PHP’s request startup and shutdown. Varonis and Recorded Future go further and report that attackers can read secrets held in worker memory, such as session data, tokens and API keys. I did not verify that claim here, so treat it as reported, not confirmed. If a worker may have been hit, rotate those secrets anyway.

What I could and could not confirm

I checked the owner’s notes against NVD, the two php-src advisories and the vendor posts. Where sources disagree, I say so.

Claim Status
CVE IDs and the "memory corruption" / "stale state" split Confirmed (NVD, GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp)
CVSS v4 9.2 Critical Confirmed, with the vector above
Disclosure around 2026-10-21 Confirmed: NVD and GHSA publication date. Recorded Future says PHP pre-announced the release on 2026-10-20
Fixed in 8.5.12, 8.4.27, 8.3.36, 8.2.35 Confirmed
Affects PHP back to 5.4 Not confirmed. Varonis says so; NVD and PHP’s two advisories list only branches 8.2 to 8.5. I found no mention of older branches or backports in the advisories
Unauthenticated memory read of a worker Partly confirmed. GHSA-6wmf-4rv5-p3rp lists "memory disclosure" among the confirmed impacts of CVE-2026-109619. The specifics (passwords, tokens, API keys in a worker) come from Varonis and Recorded Future (citing Wiz), and I could not verify them myself
RCE "needs an ASLR bypass" Not found in any source I read. Varonis says one bug helps an attacker "defeat address uncertainty"
Public PoCs incomplete or version-specific Partly supported: Recorded Future says their effectiveness varies and some rest on a theory later invalidated. The wiki I checked does not match PHP’s root cause for CVE-2026-109618
Detection: worker spawns a child while parsing an upload Reported by Varonis as a high-confidence signal. It is not in PHP’s advisories

PHP names Apache mod_php and FrankenPHP as most exposed and PHP-FPM as susceptible based on source analysis. For CVE-2026-109619, the strongest demonstrated case is the PHP 8.5 fatal_error_backtraces feature against mod_php.

Lab setup: unpatched and fixed PHP side by side

The lab uses official php:<version>-apache images: 8.4.26 (affected) and 8.4.27 (fixed). Apache with mod_php is the configuration PHP calls most exposed. The tag is a build argument, so one Dockerfile builds both.

ARG PHP_TAG=8.4.26-apache
FROM php:${PHP_TAG}
COPY www/ /var/www/html/        # only harmless demo pages are served
COPY tools/ /opt/lab/tools/     # monitor and scripts: never served over HTTP

The compose file binds the port to loopback and adds a sidecar that shares the web container’s PID namespace, so it can watch worker processes.

services:
  web:
    build: { context: ., args: { PHP_TAG: "${PHP_TAG:-8.4.26-apache}" } }
    image: php2shell-lab:${PHP_TAG:-8.4.26-apache}
    ports: ["127.0.0.1:8080:80"]
  monitor:
    image: php2shell-lab:${PHP_TAG:-8.4.26-apache}
    pid: "service:web"
    depends_on: [web]
    command: ["php", "/opt/lab/tools/monitor.php"]

Start the vulnerable build:

./run.sh vulnerable      # PHP 8.4.26

Verifying exposure (and why there is no exploit here)

I did not reproduce the memory corruption, and the lab does not try. PHP says exploit reliability depends on the PHP build, extensions and allocator layout, and I had no safe, reliable way to demonstrate it. What the lab can show is the version, the configuration and the detection behaviour.

tools/check-version.sh compares a version with the fixed releases from the advisories:

# abridged
branch="${v%.*}"
case "$branch" in
  8.5) fixed=8.5.12 ;;  8.4) fixed=8.4.27 ;;
  8.3) fixed=8.3.36 ;;  8.2) fixed=8.2.35 ;;
  *)   echo "UNKNOWN: branch not in the advisory's fixed list"; exit 2 ;;
esac
lowest=$(printf '%s\n%s\n' "$v" "$fixed" | sort -V | head -1)
[ "$lowest" = "$fixed" ] && echo "PATCHED" || echo "VULNERABLE"

Running ./tools/verify.sh queries version.php, applies that check, and then tests the detection signal:

verify.sh against PHP 8.4.26: VULNERABLE, then a monitor alert

Two details matter. session.upload_progress.enabled=1 is PHP’s default, so a stock install has a feature the advisory names as an early consumer of stale state. And a version string is only a starting point: distributions backport fixes without changing it, so check your vendor’s advisory too.

Fixing it

Upgrade to a fixed release

The advisories say updating is the only complete fix. For CVE-2026-109618, the patch removes longjmp() for OOM. It uses a delayed soft OOM at the next VM safe point and an immediate hard OOM with _exit() at a higher threshold. Switch the lab to the fixed image:

./run.sh fixed           # PHP 8.4.27
./tools/verify.sh

Interim hardening if you cannot patch yet

PHP’s advisories list settings that reduce exposure to specific paths. None is a complete fix. They must be set in php.ini, an FPM pool or Apache config, because ini_set() and .user.ini apply too late.

session.upload_progress.enabled = 0   ; blocks the confirmed session path for CVE-2026-109619
fatal_error_backtraces = 0            ; PHP 8.5 only: removes the confirmed RCE producer
log_errors = On                       ; a clean log is not proof of safety (the advisory says so)
error_reporting = E_ALL
post_max_size = 8M
max_input_nesting_level = 16

The first four settings come from the CVE-2026-109619 advisory; post_max_size and max_input_nesting_level come from the CVE-2026-109618 advisory. The values 8M and 16 are my lab choices, not PHP’s.

Why these help: CVE-2026-109619 needs a producer request and a consumer request on one worker. Disabling upload progress removes the demonstrated session path, and disabling backtraces removes the producer for the confirmed PHP 8.5 case. The advisory says the multipart limits reduce hard OOM and denial-of-service risk but do not replace updating. Neither setting touches the INI-diagnostics consumer, and PHP says there is no complete generic configuration mitigation for CVE-2026-109619.

More from the advisories:

General hardening, not from the advisory: rate-limit and size-limit requests at your proxy or WAF, and run PHP containers as non-root on a read-only filesystem. These limit damage after a compromise.

Re-testing the fixes

Apply the hardening file to the vulnerable image and re-run the checks:

./run.sh hardened        # 8.4.26 + conf/hardening.ini
./tools/verify.sh

verify.sh on 8.4.26 with hardening: upload progress is 0, version still VULNERABLE

The setting changed, but the verdict is still VULNERABLE, as it should be. Now the fixed image:

verify.sh on 8.4.27: PATCHED

Note that session.upload_progress.enabled is still 1 on the patched image. The advisory suggests keeping it disabled after patching if you do not need it.

Vulnerable vs. fixed

8.4.26 (affected) 8.4.26 + hardening 8.4.27 (fixed)
Version check VULNERABLE VULNERABLE PATCHED
session.upload_progress.enabled 1 0 1 (default)
Root causes addressed No Some paths only Yes, per PHP’s advisories
Worker child alert in the lab Yes Yes Yes

The last row is a lab artefact: the child is a simulation started by upload.php, so it fires on every version. It shows the monitor works, not that a patch changes it.

Detecting this class of bug

The signal Varonis calls high-confidence is a PHP worker creating a child process while it is still parsing an upload. A web worker normally runs PHP in-process, so a shell or other binary under a worker is rare, and on mod_php the corruption happens before application code runs. That is why application logs may show little.

The lab’s monitor reads /proc and alerts when a worker has a child that is not another worker:

foreach ($all as $cpid => $c) {
    if ($c['ppid'] !== $pid) continue;                       // child of this worker
    if (in_array($c['comm'], WORKER_COMMS, true)) continue;  // the pool spawning workers is normal
    fwrite(STDOUT, sprintf("%s ALERT worker pid=%d spawned child pid=%d comm=%s cmd=\"%s\"\n",
        date('c'), $pid, $cpid, $c['comm'], $c['cmd']));
}

On the lab, the alert looks like this:

2026-11-04T10:25:13+00:00 ALERT worker pid=27 (apache2) spawned child pid=29 comm=sleep cmd="sleep 3"

On a real host, ps -eo pid,ppid,comm --forest gives a quick manual look at process trees. A 100 ms /proc poll can miss short-lived children, so for continuous coverage use the kernel audit log. This auditd rule records every execve made by the web user (Debian and Ubuntu run Apache as www-data; check yours with id www-data):

# /etc/audit/rules.d/php-worker-exec.rules  (load with: augenrules --load)
-a always,exit -F arch=b64 -S execve -F uid=www-data -k php_worker_exec

Then search with ausearch -k php_worker_exec -i. Each record carries the process and its parent PID, so you can alert when the parent is an Apache or PHP-FPM worker. The rule is a starting point: legitimate code that calls mail() or exec() will match too, so baseline your hosts first. It works because a web worker normally runs PHP in-process and rarely calls execve, so a short, quiet list is easy to alert on. The rule syntax follows the auditctl man page; I did not run auditd in this lab. eBPF tools and EDR products can express the same "parent is the web worker" condition. Correlating with in-flight multipart requests is something this lab cannot do.

Other signals from the vendor write-ups:

These guide investigation and do not replace patching.

Key takeaways

Lab: download and run it

Download the lab (zip)

php2shell/
  Dockerfile  docker-compose.yml  docker-compose.hardened.yml  run.sh  README.md
  conf/hardening.ini
  www/      index.php  version.php  upload.php     (the only served directory)
  tools/    monitor.php  verify.sh  check-version.sh   (not served)

Prerequisites: Docker with Compose, bash and curl (Linux, macOS or WSL), free port 8080 on loopback.

unzip php2shell-lab.zip && cd php2shell
./run.sh vulnerable && ./tools/verify.sh   # VULNERABLE PHP 8.4.26, then one ALERT
./run.sh hardened   && ./tools/verify.sh   # still VULNERABLE, upload_progress=0
./run.sh fixed      && ./tools/verify.sh   # PATCHED PHP 8.4.27
./run.sh down                              # stop and remove the containers

If your unzip tool dropped the executable bit (common on Windows and WSL), run the scripts through bash instead: bash run.sh vulnerable and bash tools/verify.sh. Do not expose port 8080 beyond loopback: upload.php starts a harmless sleep 3 for anyone who can reach it.

Watch alerts live with docker compose logs -f monitor. Remove the images afterwards with docker image rm php2shell-lab:8.4.26-apache php2shell-lab:8.4.27-apache. The lab does not trigger the vulnerabilities and has no RCE code; the "child process" is a harmless sleep 3.

References