PHP

PHP2Shell: check your servers with a few shell scripts

8 min read

PHP2Shell: update PHP now

Hi folks,

Last week a video from Hood Informatik landed in my feed: PHP2Shell: Wie ein Speicherfehler zur Shell wird (German). The title made me stop scrolling. Not a bug in some plugin, not a bug in TYPO3 or WordPress, but two critical bugs in the PHP interpreter itself. Anybody who sends a crafted request can run code on the server, without a login, and on Apache with mod_php even before your application sees the request.

My first thought was the usual one: does this concern me? I look after TYPO3 sites, my own site and this blog run on PHP, so yes, it does. Here is what I understood from the advisory and the research write-ups, and the small shell scripts I now use to check servers. Everything below is defensive. No exploit code, only checks.

What PHP2Shell is

PHP2Shell is the name for CVE-2026-109618 and CVE-2026-109619, both rated CVSS 9.2. They were found by Calif together with researchers from OpenAI and Wiz, reported to the PHP security team in August, and fixed in the security release of 21 October 2026. watchTowr reports attacks since the day of the release, and CISA has added CVE-2026-109618 to its catalogue of exploited vulnerabilities. So this is not a “patch it next month” thing.

The fixed versions are:

BranchFixed in
8.58.5.12
8.48.4.27
8.38.3.36
8.28.2.35
8.1, 8.0, 7.4only vendor or distribution backports
7.3 and olderno fix, upgrade the branch

Affected are all PHP setups that serve web requests: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The PHP team sees the highest risk with mod_php.

Why a memory error becomes a shell

This part I found really interesting. When PHP hits memory_limit, it stops with a fatal error right where it is. Normally that is fine, the request dies and the next one starts clean. But some places in the engine do their work in two steps. For example, a hash table first counts a new element and only then writes it. If the memory runs out exactly between those two steps, the cleanup later trusts the counter and works with something that was never finished.

Think of a waiter who is called away while laying a table. The manager sees “table 4 is ready” on the list and seats the next guest. Now add that PHP workers serve many requests one after another. Some of this half-finished state survives into the next request on the same worker, pointing at memory that was already freed. An attacker who controls what lands in that memory has a write primitive, and from there it goes to code execution.

How do you make PHP run out of memory at exactly the right moment? With a multipart upload that has lots of empty parts and deeply nested field names, sent in a way that skips the post_max_size check. PHP parses all of that during request startup. Your index.php has not even started yet. That is why updating TYPO3, WordPress or any plugin does not help here. The runtime itself needs the update.

You can see the basic effect on your own machine. This script is harmless, it only shows that a fatal out-of-memory error stops PHP in the middle of a job while shutdown code still runs with the unfinished state:

<?php
// A fatal "Allowed memory size exhausted" stops PHP in the middle of whatever it is doing.
// Shutdown functions still run afterwards - with state that was never finished.
$state = ['step' => 'started'];

register_shutdown_function(static function () use (&$state): void {
    echo PHP_EOL, 'shutdown sees: ', json_encode($state), PHP_EOL;
});

$state['step'] = 'building a big array';
$rows = [];
while (true) {
    $rows[] = str_repeat('x', 1024);
}
$state['step'] = 'done'; // never reached
php -d memory_limit=8M oom-demo.php
Fatal error: Allowed memory size of 8388608 bytes exhausted (tried to allocate 20480 bytes) in oom-demo.php on line 13

shutdown sees: {"step":"building a big array"}

In userland this is just an annoying half-done job. Inside the engine, in C, the same pattern ends with a dangling pointer. That is the whole story in one sentence.

Step 1: Check every PHP binary on the machine

Most servers have more than one PHP. My shared hosting has PHP 8.0 to 8.5 installed side by side, a Debian box has php8.3 and php8.4, and so on. This script asks each binary for its version and compares it with the fixed release of its branch:

#!/usr/bin/env bash
# Check every PHP binary on this machine against the PHP2Shell fix versions
# (CVE-2026-109618 / CVE-2026-109619, PHP security release of 21 Oct 2026).
set -u

fixed_for() {
  case "$1" in
    8.5) echo 8.5.12 ;;
    8.4) echo 8.4.27 ;;
    8.3) echo 8.3.36 ;;
    8.2) echo 8.2.35 ;;
    *)   echo none ;;
  esac
}

for bin in $(ls /usr/bin/php[0-9]* /usr/local/bin/php /usr/bin/php /opt/cpanel/ea-php*/root/usr/bin/php 2>/dev/null | sort -u); do
  version=$("$bin" -r 'echo PHP_VERSION;' 2>/dev/null) || continue
  branch=${version%.*}
  fixed=$(fixed_for "$branch")
  if [ "$fixed" = none ]; then
    status="NO FIX - upgrade the branch (or use a vendor backport)"
  elif "$bin" -r "exit(version_compare(PHP_VERSION, '$fixed', '>=') ? 0 : 1);"; then
    status="ok (fixed in $fixed)"
  else
    status="VULNERABLE - update to $fixed"
  fi
  printf '%-40s %-8s %sn' "$bin" "$version" "$status"
done

I ran it inside my DDEV project first, and honestly I was a bit surprised:

/usr/bin/php                             8.4.17   VULNERABLE - update to 8.4.27
/usr/bin/php8.2                          8.2.30   VULNERABLE - update to 8.2.35
/usr/bin/php8.3                          8.3.30   VULNERABLE - update to 8.3.36
/usr/bin/php8.4                          8.4.17   VULNERABLE - update to 8.4.27
/usr/bin/php8.5                          8.5.2    VULNERABLE - update to 8.5.12

A local DDEV is not reachable from the internet, so this is not a fire. But it is a good reminder that container images have their own PHP and need their own update. On the hosting the command line binaries already showed 8.4.27 and 8.3.36, built one day before the release. The PHP team shared the patches with distributions and vendors in advance, and here it paid off.

Step 2: Check the PHP your web server really runs

Here is the trap: php -v on the command line tells you nothing about the PHP behind your website. FPM pools and mod_php can use another build, and on cPanel every domain can have its own version. So ask the web server itself. The script writes a tiny PHP file with a random name, calls it once and removes it again, even if curl fails:

#!/usr/bin/env bash
# Ask the web server which PHP it really runs: the CLI can be a different version than FPM/mod_php.
# Usage: ./check-web-php.sh <document root> <base URL>
set -eu
docroot="$1"; base="$2"
name="php-check-$(openssl rand -hex 8).php"
trap 'rm -f "$docroot/$name"' EXIT   # never leave the file behind
printf '<?php echo PHP_VERSION, " ", PHP_SAPI, " session.upload_progress=", ini_get("session.upload_progress.enabled") ?: "0", PHP_EOL;' > "$docroot/$name"
curl -fsS "$base/$name"
./check-web-php.sh /var/www/html/public https://myport.ddev.site
8.4.17 fpm-fcgi session.upload_progress=1

Version, SAPI and one setting we will need in a moment, in one line.

Step 3: Update and restart every worker

The update is the only real fix. After it, restart all PHP processes, because old workers keep running the old code:

# Debian/Ubuntu with packages from the distribution or deb.sury.org
sudo apt update && sudo apt install --only-upgrade 'php8.4*'
sudo systemctl restart php8.4-fpm      # PHP-FPM
sudo systemctl restart apache2         # mod_php: a full restart, not only a reload

# Check again
./check-php2shell.sh

On managed or shared hosting you cannot do this yourself. Open a ticket and ask for the exact version, then verify it with the script from step 2. Do not trust “we are on 8.4” as an answer.

Step 4: Reduce the risk if you cannot update today

The PHP advisory lists a few settings that close specific attack paths. They do not make a server safe, they only buy time. Put them where PHP reads them before parsing the request, so in the FPM pool or php.ini, not in .user.ini and not with ini_set():

; FPM pool, e.g. /etc/php/8.4/fpm/pool.d/www.conf
php_admin_value[memory_limit] = -1
php_admin_value[post_max_size] = 8M          ; 32M if you need uploads
php_admin_value[upload_max_filesize] = 6M    ; keep it below post_max_size
php_admin_flag[session.upload_progress.enabled] = off

Yes, memory_limit = -1 feels wrong. It removes the trigger of one bug, but a single heavy request can now eat the whole server memory, so keep an eye on it and go back to a normal limit after the update. Check the result with a second small script:

#!/usr/bin/env bash
# Show the settings the PHP advisory names as temporary mitigations
php="${1:-php}"
"$php" -r '
foreach (["memory_limit", "post_max_size", "upload_max_filesize", "session.upload_progress.enabled"] as $key) {
    printf("%-34s %sn", $key, var_export(ini_get($key), true));
}'

One more thing that I would do even on patched servers: switch off session.upload_progress.enabled. Two days after the release the PHP team published a note about CVE-2026-115560, another bug in session upload progress that also affects 8.5.12 and will only be fixed in the next regular release. Most applications never use this feature. TYPO3 and WordPress core do not even use PHP sessions.

Step 5: Look into your logs

The attack happens below the application, so TYPO3 or WordPress logs stay quiet. The researchers mention memory-limit fatals, many HTTP 500 answers to POST requests and unusual worker restarts. A quick first look:

#!/usr/bin/env bash
# Look for the traces the researchers describe: memory-limit fatals and bursts of POST 500s.
# Usage: ./hunt-oom.sh <php error log> <access log>
errors="$1"; access="$2"
echo "== memory-limit fatals per day"
grep -h "Allowed memory size of" "$errors" | sed -E 's/^[([^ ]+) .*/1/' | sort | uniq -c
echo "== IPs with 5+ POST requests answered with 500"
awk '$6 == ""POST" && $9 == 500 { print $1 }' "$access" | sort | uniq -c | sort -rn | awk '$1 >= 5'
./hunt-oom.sh /var/log/php8.4-fpm.log /var/log/apache2/access.log
== memory-limit fatals per day
      2 27-Oct-2026
== IPs with 5+ POST requests answered with 500
      6 203.0.113.7

A hit is not a proof of an attack, big imports also run out of memory. But a cluster of fatals with many 500s from one IP since 21 October is worth a closer look. If you find something on an unpatched server, treat it as compromised: rotate the passwords and keys from the configuration files, and rebuild instead of cleaning up.

My takeaway

Since years I keep TYPO3 and its extensions up to date, but the runtime under it was more like “the hosting does that”. PHP2Shell showed me that this layer needs the same attention. The scripts above take five minutes, so please run them on every server you are responsible for, today and not after the weekend.

If this post helped you, I will be really happy. And if your hosting answered with an interesting version number, drop a comment.

Further reading

Leave a comment

Your email address will not be published.