#!/bin/sh
# php2shell-check.sh — Zero Hunt Research, free self-check (MIT licence)
# https://zerohunt.ai/self-check/
#
# Lists every PHP build on this host and tells you whether it is older than the
# PHP2Shell fixes (CVE-2026-109618, CVE-2026-109619): 8.2.35, 8.3.36, 8.4.27,
# 8.5.12. Also shows which PHP processes are running, the stopgap settings in
# PHP-FPM pools, and PHP processes that have spawned a shell or a download tool
# (the behaviour a successful RCE usually leaves).
#
# Read-only and offline: it runs "<php> -v", ps, find and grep. It changes
# nothing and sends nothing anywhere. Run as root to see every pool and process.
#
#   sh php2shell-check.sh            # this host
#   sh php2shell-check.sh --docker   # also run "php -v" inside running containers
#
# Exit code: 0 nothing to fix, 1 something to fix or verify.

VERSION=0.1.1
DOCKER=0
[ "$1" = "--docker" ] && DOCKER=1
[ "$1" = "--version" ] && { echo "php2shell-check $VERSION"; exit 0; }

FIX=0
say() { printf '%s\n' "$*"; }

# fixed_for <major.minor> -> fixed patch level, or "eol"
fixed_for() {
  case "$1" in
    8.2) echo 35 ;; 8.3) echo 36 ;; 8.4) echo 27 ;; 8.5) echo 12 ;;
    *) echo eol ;;
  esac
}

# verdict <binary> <version string>
verdict() {
  bin="$1"; ver="$2"
  mm=$(printf '%s' "$ver" | cut -d. -f1-2)
  patch=$(printf '%s' "$ver" | cut -d. -f3 | sed 's/[^0-9].*//')
  fixed=$(fixed_for "$mm")
  pkg=""
  if command -v dpkg >/dev/null 2>&1; then
    pkg=$(dpkg -S "$(readlink -f "$bin")" 2>/dev/null | head -1 | cut -d: -f1)
    [ -n "$pkg" ] && pkg="$pkg $(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null)"
  elif command -v rpm >/dev/null 2>&1; then
    pkg=$(rpm -qf "$(readlink -f "$bin")" 2>/dev/null | grep -v 'not owned' | head -1)
  fi
  case "$mm" in 7.4|8.0|8.1) backport=1 ;; *) backport=0 ;; esac
  if [ "$fixed" = eol ] && [ "$backport" = 0 ]; then
    say "[FIX ] $bin  PHP $ver — branch $mm is end-of-life and PHP published no PHP2Shell fix for it (backports exist only for 7.4, 8.0, 8.1)."
    say "        Upgrade to a supported release (8.2.35+), or take this PHP off the internet until you can."
    [ -n "$pkg" ] && say "        Package: $pkg"
    FIX=1
  elif [ "$fixed" = eol ] && [ -n "$pkg" ]; then
    say "[CHECK] $bin  PHP $ver — branch $mm is end-of-life upstream, but this is a distribution package ($pkg)."
    say "        Check your vendor's security notice for a PHP2Shell backport; otherwise upgrade to 8.2+."
    FIX=1
  elif [ "$fixed" = eol ]; then
    say "[FIX ] $bin  PHP $ver — branch $mm is end-of-life upstream; use your vendor's backport or upgrade to 8.2+"
    FIX=1
  elif [ -n "$patch" ] && [ "$patch" -ge "$fixed" ]; then
    say "[ok  ] $bin  PHP $ver (fixed from $mm.$fixed)"
  elif [ -n "$pkg" ]; then
    say "[CHECK] $bin  PHP $ver < $mm.$fixed, but it is a distribution package ($pkg)."
    say "        Distributions often backport the fix without changing the version: check your vendor's security notice."
    FIX=1
  else
    say "[FIX ] $bin  PHP $ver < $mm.$fixed — upgrade to $mm.$fixed or later, then restart every worker"
    FIX=1
  fi
}

say "php2shell-check $VERSION — PHP2Shell (CVE-2026-109618 / CVE-2026-109619) self-check"
say "Fixed releases: 8.2.35 · 8.3.36 · 8.4.27 · 8.5.12"
say ""
say "== PHP builds on this host"

CANDIDATES=$( {
  for n in php php-fpm php-cgi lsphp frankenphp; do command -v "$n" 2>/dev/null; done
  ls /usr/bin/php[0-9]* /usr/sbin/php-fpm* /usr/bin/php-cgi* 2>/dev/null
  ls /opt/cpanel/ea-php*/root/usr/bin/php /opt/cpanel/ea-php*/root/usr/sbin/php-fpm 2>/dev/null
  ls /opt/plesk/php/*/bin/php /opt/plesk/php/*/sbin/php-fpm 2>/dev/null
  ls /opt/alt/php*/usr/bin/php 2>/dev/null
  ls /usr/local/lsws/lsphp*/bin/lsphp /usr/local/lsws/lsphp*/bin/php 2>/dev/null
  ls /usr/local/bin/php /usr/local/sbin/php-fpm 2>/dev/null
  ls /opt/remi/php*/root/usr/bin/php /opt/remi/php*/root/usr/sbin/php-fpm 2>/dev/null
} | sort -u )

SEEN=""
FOUND=0
for b in $CANDIDATES; do
  [ -x "$b" ] || continue
  real=$(readlink -f "$b")
  case " $SEEN " in *" $real "*) continue ;; esac
  SEEN="$SEEN $real"
  case "$(basename "$b")" in
    frankenphp) out=$("$b" version 2>/dev/null) ;;
    *) out=$("$b" -v 2>/dev/null) ;;
  esac
  ver=$(printf '%s' "$out" | grep -oE 'PHP [0-9]+\.[0-9]+\.[0-9]+' | head -1 | cut -d' ' -f2)
  [ -z "$ver" ] && continue
  FOUND=1
  verdict "$b" "$ver"
done
[ "$FOUND" = 0 ] && say "No PHP binary found in the usual locations."

say ""
say "== Running PHP processes (the web SAPI can differ from the CLI)"
ps -eo pid,user,comm,args 2>/dev/null | grep -E 'php-fpm|php-cgi|lsphp|frankenphp|httpd|apache2' | grep -v grep | head -20 || true

if command -v apachectl >/dev/null 2>&1 || command -v apache2ctl >/dev/null 2>&1; then
  mods=$( (apachectl -M 2>/dev/null || apache2ctl -M 2>/dev/null) | grep -iE 'php[0-9_]*_module' )
  [ -n "$mods" ] && { say ""; say "Apache mod_php is loaded (highest-risk configuration): $mods"; }
fi

say ""
say "== Stopgap settings in PHP-FPM pools (not a fix — patch first)"
POOLS=$(ls /etc/php/*/fpm/pool.d/*.conf /etc/php-fpm.d/*.conf /etc/opt/remi/php*/php-fpm.d/*.conf /opt/cpanel/ea-php*/root/etc/php-fpm.d/*.conf /opt/plesk/php/*/etc/php-fpm.d/*.conf /usr/local/etc/php-fpm.d/*.conf 2>/dev/null)
if [ -n "$POOLS" ]; then
  for p in $POOLS; do
    s=$(grep -hE '^\s*php_admin_(value|flag)\[(memory_limit|session\.upload_progress\.enabled|fatal_error_backtraces|post_max_size)\]' "$p" 2>/dev/null | tr -s ' ' | tr '\n' ';')
    say "$p: ${s:-none of the advisory stopgaps set}"
  done
else
  say "No PHP-FPM pool files found."
fi

say ""
say "== PHP processes that spawned a shell or download tool (possible compromise)"
SUSP=$(ps -eo pid,ppid,comm 2>/dev/null | awk '
  NR>1 { comm[$1]=$3; parent[$1]=$2 }
  END {
    for (p in comm) {
      c=comm[p]; pp=parent[p]; pc=comm[pp]
      if (pc ~ /^(php|php-fpm|php-cgi|lsphp|frankenphp|httpd|apache2)/ && c ~ /^(sh|bash|dash|zsh|curl|wget|nc|ncat|python|python3|perl|socat)$/)
        printf "  pid %s %s  <- parent %s %s\n", p, c, pp, pc
    }
  }')
if [ -n "$SUSP" ]; then
  say "$SUSP"
  say "  Investigate these now: see Detection and hunting in the PHP2Shell guide."
  FIX=1
else
  say "None right now (this only sees the current moment; check your EDR or process logs too)."
fi

if [ "$DOCKER" = 1 ] && command -v docker >/dev/null 2>&1; then
  say ""
  say "== Running containers"
  docker ps --format '{{.ID}} {{.Image}}' 2>/dev/null | while read -r id img; do
    v=$(docker exec "$id" php -v 2>/dev/null | grep -oE 'PHP [0-9]+\.[0-9]+\.[0-9]+' | head -1 | cut -d' ' -f2)
    [ -n "$v" ] && say "$id ($img): PHP $v — a container keeps its PHP until the image is rebuilt and redeployed"
  done
fi

say ""
if [ "$FIX" = 1 ]; then
  say "Result: something to fix or verify. Remediation runbook:"
else
  say "Result: no PHP2Shell exposure found by this check. Remediation runbook:"
fi
say "https://zerohunt.ai/blog/php2shell-cve-2026-109618-php-rce/#remediation"
say "This script checks one host. Zero Hunt verifies exposure across the whole network, on-premise: https://zerohunt.ai/on-premise-ai-red-team/"
exit "$FIX"
