Sentrix

Analysis · Exposure

PHP2Shell, what the patch does not fix

Frozen images, hosting panels, appliances, Windows with no fixed build. Where PHP2Shell stays open after the update, and how to inventory your own PHP.

By Sentrix · Published 2026-10-22

On October 21, 2026, the PHP project released versions 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix CVE-2026-109618 and CVE-2026-109619, known as PHP2Shell. Both allow remote code execution without authentication, and PHP considers every installation that serves web requests at risk. Almost everything published since comes down to one instruction, update PHP. It is right and it is incomplete. A large share of the PHP in production does not belong to the organization that runs it, has no fixed version to install, or shows a number that says nothing about its real state. This article is about those blind spots.

What is established as of October 22

The versions. The PHP project no longer releases updates for 8.1 and earlier. Given the severity, it provided minimal source-code backports for 7.4, 8.0 and 8.1, to be compiled by the user. It states that Linux distributions and extended-support vendors received the fixes in advance.

Who is at risk. According to PHP, every installation that serves web requests, whether PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP or LiteSpeed. The risk is rated highest for mod_php.

Exploitation. The sources disagree, and the time of day matters. On October 21, CISA and the Canadian Centre for Cyber Security had observed no exploitation at the time of publication. That evening, Amazon reported a first attempt on October 17 in its honeypots, 4 days before disclosure, and broad exploitation since, while stating it did not know whether those attempts would have succeeded. On October 22, neither CVE was in the CISA KEV catalog yet. The same day, Sansec lowered its assessment. An attack needs precise knowledge of the target server, and the public demonstrations do not work against a default PHP installation. Sansec still tells everyone to update PHP.

What leaves through memory. Wiz showed that an attacker can read the memory of the PHP process without even running code, and find passwords, tokens and API keys in it. Sansec adds TLS private keys, coming from requests other than the attacker's.

The patch does not tell you what already left

Updating PHP closes the door. It says nothing about what may have left before. Wiz notes that these flaws act below the application layer and that application logs may contain little context. A server with no suspicious trace is therefore not a clean server.

For each server exposed to the internet and still unpatched after October 17, an explicit decision is needed on the secrets its PHP processes handle, meaning database passwords, API keys, tokens and open sessions. Changing them costs a few hours. Not changing them is an accepted risk, documented with its rationale and its date.

Where there is nothing to install

This is the heart of the problem. For part of the estate, the update instruction leads nowhere.

SituationWhat the source saysConsequence
WordPress on old PHPWordPress.org counts 35.8% of WordPress sites on PHP 8.1 or older, including 5.7% on 7.3 or olderWordPress has nothing to fix. Everything depends on the PHP underneath and on who runs it
PHP 7.4, 8.0 and 8.1 on WindowsThe last Windows releases date from November 2022, August 2023 and December 2025No fixed build exists. Migrate or retire
XAMPPThe Windows and Linux installers still offer PHP 8.0.30, 8.1.25 and 8.2.12A development stack that often stayed in production, with no fixed version
Official Docker imagesA tag that is no longer supported will "simply never get rebuilt". Sansec confirms no official 8.1 or older image will be fixedAn image pinned to php:7.4, php:8.0 or php:8.1 stays vulnerable, even when redeployed
Bitnami catalogSince August 28, 2025, versioned images live in bitnamilegacy, which receives no further updates. The remaining free images, meant for development, exist only under the "latest" tagA Bitnami image pinned to a precise version, good practice in itself, is frozen, unless you pay for Bitnami Secure Images
cPanelUpdates for ea-php74 and later, and fixed cPanel & WHM releases for the panel's own PHP. For versions older than 7.4, cPanel refers to TuxCare, a paid serviceSite PHP and panel PHP are fixed separately. An account still on PHP 5.6 to 7.3 depends on a subscription the host did or did not take
CloudLinuxThe fix is backported into the alt-php and ea-php branches, end-of-life ones included. alt-php44 and alt-php51 have no fixed buildOld PHP is fixed for CloudLinux customers, not for others
PleskOn Linux, 7.4, 8.0 and 8.1 are fixed. On Windows, only 8.2 to 8.5. For 5.6, 7.2 and 7.3, Plesk recommends TuxCare, paidThe same panel does not protect in the same way depending on the system
QNAP NASEvery fix is "To be announced". QNAP states the built-in PHP cannot be updated manually, only through the firmwareThe customer waits for the manufacturer, with no lever other than exposure
Palo Alto firewallsThe management web interface of PAN-OS 10.2 to 12.2 and Panorama is affected, with no fix date on October 21The PHP in your firewall's management interface is one more PHP to track
AWS Lightsail instances"AWS cannot update running Lightsail instances in place"A deployed instance is fixed by its operator, or rebuilt

The version number lies

Checkers that read the number the server displays are wrong in both directions.

Fixed, but looks vulnerable. We checked the php8.3-common package of Ubuntu 24.04. Its version 8.3.6-0ubuntu0.24.04.13 contains the fix for CVE-2026-109618, yet it still shows PHP 8.3.6, far below the PHP project's 8.3.36. CloudLinux spells it out, the fix is backported into every alt-php and ea-php branch, "which is why the version numbers do not change". cPanel fixes ea-php74, ea-php80 and ea-php81 the same way, without changing their version number.

Vulnerable, but looks fixed. A server can have the right package on disk and run the old code in memory. A PHP-FPM or Apache process started before the update keeps the old version until it restarts. Sansec says so in its procedure, install the package, then restart PHP-FPM or Apache.

The only reliable proof is the package changelog or the vendor advisory, followed by the restart. On Debian and Ubuntu, zgrep CVE-2026-109618 /usr/share/doc/php8.3-common/changelog.Debian.gz. On Red Hat and its derivatives, rpm -q --changelog php-common | grep CVE-2026-109618.

What you do not control

The host. On shared hosting, the customer chooses neither the version nor the time of the update, and does not see the processes. The hosting panel decides which old versions stay available and on what terms. Here are 5 questions to ask in writing.

  1. Which PHP package serves my site, in exact version, and which advisory confirms the fix of both CVEs for that package?
  2. When were the PHP processes serving my site restarted after the update?
  3. If my site runs a version older than 7.4, which fix applies, and from whom?
  4. Does my site have its own PHP processes, under its own account, or does it share them with other customers?
  5. Have you seen attempts against my site since October 17, and can you hand me the logs?

The vendors. CISA puts it plainly. PHP is often a component of other software and its prevalence may not be directly observable. The Canadian Centre asks organizations to identify third-party products, appliances and containers that include vulnerable PHP components. It is the same scenario as Log4j in 2021. The CISA public list kept receiving entries for 4 months and passed 4,000 products. For PHP2Shell, the Dutch NCSC-NL has kept a similar list since October 21. On October 22 at 15:28 UTC, it held 724 entries from 456 suppliers, 440 of them still under investigation. Of the 216 entries marked vulnerable, 185 carry a generic tag, vulnerable if the PHP serving it is. The cascade has only started, and each vendor will publish at its own pace.

The method fits in 4 steps.

  1. List the vendors whose product embeds PHP, appliances included.
  2. Subscribe to their security advisories and check the NCSC-NL list every week.
  3. Record each answer in the register, affected, not affected or fixed in a given version, with the date.
  4. Follow up in writing with those that have published nothing, and set a deadline.

Looking for a compromise

The Canadian Centre recommends reviewing web server, application, authentication and endpoint logs, and investigating any unexpected process, command, file, account or outbound connection associated with PHP or web server services. Wiz points to one sign to watch, unusual multipart requests followed by memory-limit errors or repeated HTTP 500 responses. Since application logs may stay silent, the absence of a sign proves nothing. PHP files that appeared in web folders since October 17 deserve a separate review.

Inventory your estate

The checkers published since October 21 answer one precise question, is this address vulnerable. They do not say where PHP lives in your estate, what uses it or whose job the fix is. We publish 2 inventory scripts to fill that gap, one for Windows and one for Linux.

Both follow the same rules.

  • Read-only. They never run PHP, change nothing and send nothing over the internet.
  • They find PHP engines and modules on local disks, then what uses them, such as services, scheduled tasks, IIS, Apache, running processes and containers.
  • They report the state from the most reliable source available, the package changelog on Linux, and the file version on Windows, where official builds carry the real number of the released version.
  • For container images, they read the version the image declares, a lead to confirm. They deliberately ignore remote Docker or Podman engines.
  • They see neither inside appliances, nor sites hosted elsewhere, nor images that are not on the machine. Those go through the vendor list.

Read a script before you run it, this one like any other.

Windows (PowerShell 5.1 or 7, as administrator)

Paste the script into a PowerShell window opened as administrator. Expect a few minutes per disk.

# PHP inventory for Windows (Sentrix). Read-only: it lists files, services,
# scheduled tasks, IIS settings and processes. It never runs php, never
# changes anything and sends nothing over the network.
# Run in an elevated PowerShell 5.1 or 7 window. A full scan can take minutes.
# Folders to scan: every local fixed disk by default, or a list such as @('D:\Apps').
$Roots = (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3').DeviceID

$fixed = @{ '8.5' = 12; '8.4' = 27; '8.3' = 36; '8.2' = 35 }
function Get-Status([string]$v) {
  if ("$v".Trim() -notmatch '^(\d+)\.(\d+)\.(\d+)$') { return 'Unknown version: ask the vendor' }
  $branch = "$($Matches[1]).$($Matches[2])"; $patch = [int]$Matches[3]
  if ($fixed.ContainsKey($branch)) {
    if ($patch -ge $fixed[$branch]) { return 'Fixed version' } else { return 'Update required' }
  }
  return 'No fixed Windows build: upgrade or retire'
}
function Get-Origin([string]$p) {
  switch -Regex ($p) {
    'xampp'     { return 'XAMPP' }
    'wamp'      { return 'WampServer' }
    'laragon'   { return 'Laragon' }
    'filemaker' { return 'FileMaker Server' }
    'plesk'     { return 'Plesk' }
    default     { return 'Other: check the parent folder' }
  }
}

# Walk each disk folder by folder: a folder we may not read is skipped, never
# the whole disk, and junctions are not followed (no loops, no duplicates).
$pattern = '^(php|php-cgi|php-win)\.exe$|^php\d?(ts)?\.dll$|^php\d+apache[\d_]*\.dll$'
function Find-PhpFile([string]$root) {
  $stack = New-Object System.Collections.Generic.Stack[string]
  $stack.Push($root)
  while ($stack.Count -gt 0) {
    $dir = $stack.Pop()
    try { foreach ($f in [IO.Directory]::GetFiles($dir)) { if ([IO.Path]::GetFileName($f) -match $pattern) { $f } } } catch { }
    try { $subs = [IO.Directory]::GetDirectories($dir) } catch { continue }
    foreach ($s in $subs) {
      try { if (-not ([IO.File]::GetAttributes($s) -band [IO.FileAttributes]::ReparsePoint)) { $stack.Push($s) } } catch { }
    }
  }
}
$report = foreach ($root in $Roots) {
  Write-Progress -Activity 'Searching for PHP' -Status $root
  Find-PhpFile "$root\" | ForEach-Object {
    $file = Get-Item -LiteralPath $_ -Force
    $v = $file.VersionInfo.ProductVersion
    [pscustomobject]@{
      Kind    = 'File'
      Path    = $file.FullName
      Version = $v
      Status  = Get-Status $v
      Origin  = Get-Origin $file.FullName
      Detail  = "Modified $($file.LastWriteTime.ToString('yyyy-MM-dd'))"
    }
  }
}

# Who uses PHP: services, scheduled tasks, IIS handlers and running processes.
$uses = @()
$uses += Get-CimInstance Win32_Service | Where-Object { $_.PathName -match 'php|httpd|apache' } |
  ForEach-Object { [pscustomobject]@{ Kind = 'Service'; Path = $_.PathName; Version = ''; Status = $_.State; Origin = Get-Origin $_.PathName; Detail = $_.Name } }
$uses += Get-ScheduledTask -ErrorAction SilentlyContinue | ForEach-Object {
  $task = $_
  $task.Actions | Where-Object { "$($_.Execute) $($_.Arguments)" -match 'php' } |
    ForEach-Object { [pscustomobject]@{ Kind = 'Scheduled task'; Path = "$($_.Execute) $($_.Arguments)"; Version = ''; Status = "$($task.State)"; Origin = Get-Origin $_.Execute; Detail = "$($task.TaskPath)$($task.TaskName)" } }
}
$iis = Join-Path $env:windir 'System32\inetsrv\config\applicationHost.config'
if (Test-Path $iis) {
  $uses += Select-String -Path $iis -Pattern '(fullPath|scriptProcessor)="([^"|]*php[^"|]*)' -AllMatches |
    ForEach-Object { $_.Matches } | ForEach-Object { $_.Groups[2].Value } | Sort-Object -Unique |
    ForEach-Object { [pscustomobject]@{ Kind = 'IIS FastCGI'; Path = $_; Version = ''; Status = 'Configured'; Origin = Get-Origin $_; Detail = 'applicationHost.config' } }
}
$listen = @{}
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ForEach-Object { $listen[[int]$_.OwningProcess] += @($_.LocalPort) }
$uses += Get-CimInstance Win32_Process |
  Where-Object { $_.ProcessId -ne $PID -and ($_.Name -match '^(php|httpd|w3wp)' -or $_.CommandLine -match 'php(-cgi|-win)?\.exe|php\.ini') } |
  ForEach-Object {
    $ports = ($listen[[int]$_.ProcessId] | Sort-Object -Unique) -join ','
    # The PHP build this process really runs: its php*.exe or loaded php*.dll.
    $mod = $null
    try { $mod = (Get-Process -Id $_.ProcessId -ErrorAction Stop).Modules | Where-Object { $_.ModuleName -match $pattern } | Select-Object -First 1 } catch { }
    $ver = if ($mod) { $mod.FileVersionInfo.ProductVersion } else { '' }
    $state = if ($mod) { Get-Status $ver } else { 'Running' }
    $runs = if ($mod) { $mod.FileName } else { $_.ExecutablePath }
    [pscustomobject]@{ Kind = 'Process'; Path = $runs; Version = $ver; Status = $state; Origin = Get-Origin "$runs"; Detail = "$($_.Name) PID $($_.ProcessId), ports $ports" }
  }

$all = @($report) + @($uses)
if (-not $all) { Write-Output 'No PHP found in the scanned folders.' }
$all | Sort-Object Kind, Path | Format-List Kind, Status, Version, Origin, Path, Detail
# To keep a copy: $all | Export-Csv "$env:USERPROFILE\Desktop\php-inventory.csv" -NoTypeInformation

Linux (bash, with sudo)

Save the script as find-php.sh, then run sudo bash find-php.sh. Without sudo, the processes of other accounts stay invisible.

#!/usr/bin/env bash
# PHP inventory for Linux (Sentrix). Read-only: it lists files, packages,
# processes, web server settings and local container images. It never runs
# php, never changes anything and sends nothing over the network. Run with sudo.
ROOTS=${ROOTS:-$(findmnt -ln -o TARGET -t ext2,ext3,ext4,xfs,btrfs,zfs,squashfs)}
row() { printf '%-10s %-40s %-30s %s\n' "$1" "$2" "$3" "$4"; }
upstream() { # status of an upstream version number, without vendor backports
  local b=${1%.*} p=${1##*.}
  case "$b" in
    8.5) [ "$p" -ge 12 ] && echo "Fixed version" || echo "Update required" ;;
    8.4) [ "$p" -ge 27 ] && echo "Fixed version" || echo "Update required" ;;
    8.3) [ "$p" -ge 36 ] && echo "Fixed version" || echo "Update required" ;;
    8.2) [ "$p" -ge 35 ] && echo "Fixed version" || echo "Update required" ;;
    "") echo "Version unknown: ask the vendor" ;;
    *) echo "End of life: vendor fix or upgrade" ;;
  esac
}
origin() {
  case "$1" in
    /opt/cpanel/ea-php*) echo "cPanel EA4" ;;
    /opt/alt/php*) echo "CloudLinux alt-php" ;;
    /opt/plesk/php/*) echo "Plesk" ;;
    /usr/local/php*) echo "DirectAdmin CustomBuild?" ;;
    /usr/local/lsws/*) echo "LiteSpeed lsphp" ;;
    /var/lib/docker/*|/var/lib/containers/*) echo "Container layer" ;;
    /snap/*) echo "Snap" ;;
    *) echo "Unknown origin" ;;
  esac
}
fixproof() { # $1 package, $2 file: is the fix documented, and is the file the packaged one?
  local log
  if command -v rpm >/dev/null && rpm -q "$1" >/dev/null 2>&1; then
    log=$(rpm -q --changelog "$1"); rpm -Vf "$2" 2>/dev/null | grep -q " $2\$" && { echo "File differs from its package"; return; }
  elif [ -f "/usr/share/doc/$1/changelog.Debian.gz" ]; then
    log=$(zcat "/usr/share/doc/$1/changelog.Debian.gz"); dpkg --verify "$1" 2>/dev/null | grep -q " $2\$" && { echo "File differs from its package"; return; }
  else echo "No changelog: check the vendor"; return; fi
  if grep -q 109618 <<<"$log" && grep -q 109619 <<<"$log"; then echo "Fixed: both CVEs in changelog"
  else echo "Not in changelog: see vendor advisory"; fi
}

row KIND STATUS DETAIL PATH
# 1. PHP engines and modules on local disks. The version is read from the
#    file itself (the X-Powered-By string compiled into PHP), never by running it.
while IFS= read -r root; do
  [ -n "$root" ] || continue
  find "$root" -xdev -type f \( -name php -o -name 'php[0-9]*' -o -name 'php-fpm*' -o -name 'php-cgi*' \
    -o -name 'lsphp*' -o -name 'libphp*.so' -o -name 'mod_php*.so' -o -name frankenphp \) 2>/dev/null
done <<<"$ROOTS" | sort -u | while IFS= read -r f; do
  [ "$(head -c4 "$f" 2>/dev/null | od -An -c | tr -d ' ')" = '177ELF' ] || continue
  ver=$(grep -aom1 -E 'X-Powered-By: PHP/[0-9]+\.[0-9]+\.[0-9]+' "$f" | sed 's#.*/##')
  pkg=$(dpkg -S "$f" 2>/dev/null | head -1 | cut -d: -f1)
  [ -z "$pkg" ] && command -v rpm >/dev/null && pkg=$(rpm -qf --qf '%{NAME}' "$f" 2>/dev/null | grep -v 'not owned')
  if [ -n "$pkg" ]; then row File "$(fixproof "$pkg" "$f")" "PHP ${ver:-?}, package $pkg" "$f"
  else row File "$(upstream "$ver")" "PHP ${ver:-?}, $(origin "$f")" "$f"; fi
done

# 2. Processes still running PHP code that was replaced on disk since they started.
ps -eo pid=,comm= | awk '$2 ~ /^(php|lsphp|frankenphp|apache2|httpd)/ {print $1, $2}' | while read -r p comm; do
  exe=$(readlink "/proc/$p/exe" 2>/dev/null) || continue
  stale=no
  grep -qE 'php[^ /]*\.so \(deleted\)' "/proc/$p/maps" 2>/dev/null && stale=yes
  [[ "$comm" =~ ^(php|lsphp|frankenphp) && "$exe" == *" (deleted)" ]] && stale=yes
  [ "$stale" = yes ] && echo "Restart needed: old PHP in memory|$exe" || echo "Running|$exe"
done | sort | uniq -c | while read -r n line; do row Process "${line%%|*}" "$n process(es)" "${line#*|}"; done

# 3. mod_php, the setup PHP rates highest risk, and sockets held by PHP.
grep -rshE '^\s*LoadModule\s+php' /etc/apache2 /etc/httpd /usr/local/apache/conf 2>/dev/null | sort -u |
  while read -r l; do row Apache "mod_php loaded" "-" "$l"; done
ss -ltnpH 2>/dev/null | grep -E '"(php|lsphp|frankenphp)' | while read -r l; do row Listener "Listening" "-" "$l"; done

# 4. Local container images that declare PHP (PHP_VERSION of the official image).
#    Remote Docker or Podman endpoints are ignored on purpose.
for c in docker podman; do
  command -v "$c" >/dev/null || continue
  if [ "$c" = docker ]; then cli=(env -u DOCKER_HOST docker --context default); else cli=(env -u CONTAINER_HOST -u CONTAINER_CONNECTION podman); fi
  for id in $("${cli[@]}" image ls -q 2>/dev/null | sort -u); do
    ver=$("${cli[@]}" image inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$id" 2>/dev/null | sed -n 's/^PHP_VERSION=//p')
    [ -n "$ver" ] || continue
    row Image "$(upstream "$ver")" "PHP $ver declared by the image" "$("${cli[@]}" image inspect -f '{{index .RepoTags 0}}' "$id" 2>/dev/null) $id"
  done
done

Reading the result. A line "Update required" or "No fixed Windows build" calls for action. A "Restart needed" line on Linux flags a process still running the old code. A "Not in changelog" line points to the vendor advisory, since some fixed packages do not mention the CVEs in their changelog. A "File differs from its package" line flags a file changed after installation, to be examined. The origin is inferred from the folder and remains a lead to confirm.

What we think at Sentrix

PHP2Shell is not mainly a patching matter. It is a matter of inventory and responsibility, as Log4j was. Here is our order of work.

  1. Inventory all PHP, including the PHP that is not yours. Servers, workstations, containers, appliances and hosted sites, each with the party responsible for the fix. It is the discovery stage of the CTEM module.
  2. Prove the fix by the package and the restart, never by the number.
  3. Decide on secrets for each server exposed before its fix, and record the decision.
  4. Take versions with no fix out of direct exposure. Migrate, isolate or retire, and record the decision in the risk register.
  5. Keep the register of vendor answers, with the same rigour as third-party risk. The SBOM is the right question for the next renewal.
  6. Do not wait for the KEV catalog. PHP already asks for an immediate update and Amazon reports attempts. The method in prioritizing patches with the KEV catalog applies now, without waiting for a listing.

Our vulnerability and patch management service applies this method, from inventory to proof of the fix.

The next step

Run the inventory on your servers and workstations, send the 5 questions to your host and open the register of vendors that embed PHP. Every line with no proof of the fix and no follow-up date is a blind spot. If you want to do the exercise with us, contact us.

Sources

Frequently asked questions

Q01
Is my WordPress site affected by PHP2Shell?
Yes, if the PHP that runs it is not fixed. WordPress.org states that no WordPress update is required, because the flaw is in PHP. According to WordPress.org statistics, 35.8% of WordPress sites run PHP 8.1 or older, versions the PHP project no longer releases. Ask your host which version serves your site and what proof of the fix it can give you.
Q02
My server shows PHP 8.3.6. Am I vulnerable?
The number alone does not tell. Ubuntu 24.04 fixes PHP2Shell in package 8.3.6-0ubuntu0.24.04.13, which keeps the 8.3.6 number while the PHP project's fixed release is 8.3.36. CloudLinux, cPanel and Plesk do the same for older versions. The proof is in the package changelog or the vendor advisory, then in the restart of the PHP processes.
Q03
I run PHP 7.4, 8.0 or 8.1 on Windows. What should I do?
No official fixed Windows build exists for these versions. The last Windows releases are 7.4.33 in November 2022, 8.0.30 in August 2023 and 8.1.34 in December 2025. The PHP project only provided source-code fixes. What remains is to move the application to 8.2 or later, or to take the server off the internet until the move, and to record the decision in the risk register.
Q04
My host says PHP is fixed. How do I check?
Ask for the exact package version that serves your site and the advisory confirming the fix of both CVEs for that package. Ask for the date the PHP processes were restarted after the update, since a process started before it keeps the old code in memory. If you are on a version older than 7.4, ask which fix applies, since cPanel and Plesk refer those versions to a paid service.
Q05
Should we change our passwords and keys?
It is a server-by-server decision. Wiz and Sansec showed that one of the flaws can read the memory of the PHP process, which can hold passwords, session tokens and API keys. For a server exposed to the internet and still unpatched after October 17, the date of the first attempt Amazon recorded, changing those secrets is the prudent position.
Q06
Is a web application firewall enough?
No. It reduces exposure for the traffic that goes through it, but it fixes nothing. Sansec notes that the flaws trigger inside PHP itself, before the application code, and advises not to wait for a filtering rule before updating PHP. A server that can be reached directly, without going through the firewall, stays exposed.

Let's talk about your compliance program.

Last updated: