#!/usr/bin/env bash
#
# php2shell-check.sh — test (and optionally patch) a server for PHP2Shell
# CVE-2026-109618 / CVE-2026-109619
#
# Run it anywhere PHP might be installed. By default it only LOOKS; it changes
# nothing until you add --patch.
#
#   ./php2shell-check.sh            # check every PHP on the box, print a verdict
#   ./php2shell-check.sh --patch    # check, then update PHP if it's behind (needs root)
#   ./php2shell-check.sh --quiet    # one line per PHP, good for a loop over many boxes
#   ./php2shell-check.sh --no-color # plain text (logs, pipes)
#
# Exit code: 0 everything patched · 1 something vulnerable · 2 couldn't tell
#
# How it tells patched from vulnerable, without needing internet or a CVE feed:
#   PHP 8.2+  — the fixed build carries the `memory_hard_limit_percent` ini setting.
#   PHP 7.1–8.1 (vendor backport) — the fixed binary contains the strings
#                `zend_mm_safe_error_ex` and `zend_new_array_singleton`.
#   Any version — if a running PHP process is still mapped to a DELETED binary,
#                the box was patched on disk but the old, vulnerable PHP is still
#                live in memory (restart needed).
# Fixed upstream releases: 8.2.35, 8.3.36, 8.4.27, 8.5.12. 8.1 and older are
# end-of-life upstream and only safe if the OS vendor backported the fix.

set -u

# ---- options ---------------------------------------------------------------
DO_PATCH=0; QUIET=0; COLOR=auto
for a in "$@"; do
  case "$a" in
    --patch)    DO_PATCH=1 ;;
    --quiet|-q) QUIET=1 ;;
    --no-color) COLOR=no ;;
    -h|--help)  sed -n '2,30p' "$0" | sed 's/^# \?//'; exit 0 ;;
    *) echo "unknown option: $a (try --help)" >&2; exit 2 ;;
  esac
done

# ---- colour ----------------------------------------------------------------
if [ "$COLOR" = auto ]; then [ -t 1 ] && COLOR=yes || COLOR=no; fi
if [ "$COLOR" = yes ]; then
  B=$'\e[1m'; DIM=$'\e[2m'; R=$'\e[0m'
  RED=$'\e[31m'; GRN=$'\e[32m'; YEL=$'\e[33m'; CYN=$'\e[36m'
  OKMARK="${GRN}✓${R}"; BADMARK="${RED}✗${R}"; UNKMARK="${YEL}?${R}"
else
  B=; DIM=; R=; RED=; GRN=; YEL=; CYN=; OKMARK="[OK]"; BADMARK="[!!]"; UNKMARK="[??]"
fi

say()  { [ "$QUIET" = 1 ] || printf '%s\n' "$*"; }
rule() { [ "$QUIET" = 1 ] || printf '%s\n' "${DIM}────────────────────────────────────────────────────────────${R}"; }

# ---- header ----------------------------------------------------------------
if [ "$QUIET" != 1 ]; then
  printf '\n'
  printf '  %sPHP2Shell check%s   %sCVE-2026-109618 / CVE-2026-109619%s\n' "$B" "$R" "$DIM" "$R"
  printf '  %s%s  ·  %s%s\n' "$DIM" "$(hostname)" "$(date '+%Y-%m-%d %H:%M %Z')" "$R"
  rule
fi

# ---- find every PHP binary on the box --------------------------------------
# cli/fpm/cgi in PATH, Debian versioned (php8.3), cPanel EA, CloudLinux alt-php.
declare -A seen
bins=()
add() { local p; p=$(command -v "$1" 2>/dev/null || { [ -x "$1" ] && echo "$1"; }); [ -n "${p:-}" ] || return; p=$(readlink -f "$p" 2>/dev/null || echo "$p"); [ -n "${seen[$p]:-}" ] && return; seen[$p]=1; bins+=("$p"); }

for n in php php-cli php-fpm php-cgi lsphp; do add "$n"; done
for p in /usr/bin/php[0-9]* /usr/sbin/php-fpm* /usr/local/bin/php /usr/local/bin/lsphp \
         /opt/cpanel/ea-php*/root/usr/bin/{php,php-cgi,lsphp} \
         /opt/alt/php*/usr/bin/{php,php-cgi,lsphp} \
         /opt/remi/php*/root/usr/bin/php; do
  [ -e "$p" ] && add "$p"
done

if [ "${#bins[@]}" -eq 0 ]; then
  say "${CYN}No PHP found on this server.${R} Nothing is exposed to PHP2Shell."
  say ""
  exit 0
fi

# ---- test one binary -------------------------------------------------------
# echoes: <verdict> <version> <path>   verdict ∈ PATCHED | VULNERABLE | UNKNOWN
test_bin() {
  local bin="$1" ver vid verdict="UNKNOWN" why=""
  local canrun=1
  ver=$("$bin" -n -r 'echo PHP_VERSION;' 2>/dev/null)
  vid=$("$bin" -n -r 'echo PHP_VERSION_ID;' 2>/dev/null)
  # php-fpm / php-cgi print their usage text for -r instead of running code, so only trust a real version string
  [[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] && [[ "$vid" =~ ^[0-9]+$ ]] || ver=
  if [ -z "$ver" ]; then
    # php-fpm and php-cgi can't run code with -r: read the version from -v and check the binary itself
    canrun=0
    ver=$("$bin" -n -v 2>/dev/null | head -n1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
    [ -n "$ver" ] || { echo "UNKNOWN ? $bin|won't report a version"; return; }
    local ma mi pa; IFS=. read -r ma mi pa <<< "$ver"
    vid=$(( ma * 10000 + mi * 100 + pa ))
  fi

  if [ "${vid:-0}" -ge 80200 ]; then
    # 8.2+: the fix ships the memory_hard_limit_percent ini directive
    if [ "$canrun" = 1 ]; then
      "$bin" -n -r 'exit(ini_get("memory_hard_limit_percent")===false?1:0);' 2>/dev/null && marker=1 || marker=0
    else
      grep -qa memory_hard_limit_percent "$bin" 2>/dev/null && marker=1 || marker=0
    fi
    if [ "$marker" = 1 ]; then
      verdict=PATCHED; why="ini marker present"
    else
      verdict=VULNERABLE; why="ini marker missing"
    fi
  else
    # 7.1–8.1: look for the backport symbols inside the binary itself
    if grep -qa zend_mm_safe_error_ex "$bin" 2>/dev/null && grep -qa zend_new_array_singleton "$bin" 2>/dev/null; then
      verdict=PATCHED; why="backport symbols present"
    else
      verdict=VULNERABLE; why="no backport symbols (8.1 and older are EOL upstream)"
    fi
  fi

  echo "$verdict $ver $bin|$why"
}

# ---- are any running PHP processes still on a deleted (old) binary? ---------
running_old() {
  local hit=0 p
  for p in /proc/[0-9]*; do
    [ -r "$p/maps" ] || continue
    if grep -qE '/(php-fpm|libphp|php|lsphp)[^ ]* \(deleted\)$' "$p/maps" 2>/dev/null; then
      local cmd; cmd=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null)
      case "$cmd" in *php*|*lsphp*) echo "  ${YEL}${p##*/}${R}  ${cmd% }"; hit=1 ;; esac
    fi
  done
  return $hit
}

# ---- run it ----------------------------------------------------------------
worst=0   # 0 ok, 1 vulnerable, 2 unknown
for bin in "${bins[@]}"; do
  line=$(test_bin "$bin")
  verdict=${line%% *}; rest=${line#* }
  ver=${rest%% *}; pathwhy=${rest#* }
  path=${pathwhy%%|*}; why=${pathwhy#*|}
  case "$verdict" in
    PATCHED)    mark="$OKMARK ";  [ "$worst" -lt 1 ] && : ;;
    VULNERABLE) mark="$BADMARK "; worst=1 ;;
    *)          mark="$UNKMARK "; [ "$worst" -lt 1 ] && worst=2 ;;
  esac
  if [ "$QUIET" = 1 ]; then
    printf '%s  %-7s PHP %-8s %s\n' "$(hostname)" "$verdict" "$ver" "$path"
  else
    printf '  %s %-9s %sPHP %-9s%s %s%s%s\n' "$mark" "$verdict" "$B" "$ver" "$R" "$DIM" "$path" "$R"
    [ -n "$why" ] && printf '        %s%s%s\n' "$DIM" "$why" "$R"
  fi
done

# running-on-old check (on-disk patched but old PHP still live in RAM)
if [ "$QUIET" != 1 ]; then
  oldprocs=$(running_old)
  if [ -n "$oldprocs" ]; then
    rule
    printf '  %s%s A running PHP is still the OLD binary (deleted on disk).%s\n' "$YEL" "$BADMARK" "$R"
    printf '  %sPatched on disk, but restart the service to load it:%s\n' "$DIM" "$R"
    printf '%s\n' "$oldprocs"
    printf '        %srestart php-fpm / apache / your web server%s\n' "$DIM" "$R"
    [ "$worst" -lt 1 ] && worst=1
  fi
fi

rule

# ---- verdict + optional patch ----------------------------------------------
if [ "$worst" -eq 0 ]; then
  say "  ${GRN}${B}All PHP on this server is patched.${R}"
  say ""
  exit 0
fi

if [ "$worst" -eq 2 ]; then
  say "  ${YEL}${B}Couldn't confirm.${R} At least one PHP wouldn't report cleanly — check it by hand."
  say ""
  [ "$DO_PATCH" -eq 1 ] || exit 2
fi

[ "$worst" -eq 1 ] && say "  ${RED}${B}Vulnerable PHP found.${R}"

if [ "$DO_PATCH" -ne 1 ]; then
  say ""
  say "  To fix it, re-run as root with ${B}--patch${R}, or update PHP by hand:"
  say "    ${DIM}Debian/Ubuntu${R}  apt-get update && apt-get install --only-upgrade 'php*'"
  say "    ${DIM}RHEL/Alma/Rocky${R} dnf update 'php*'   then  systemctl restart php-fpm"
  say "    ${DIM}cPanel/CloudLinux${R} the host (e.g. Namecheap) rebuilds alt-php; pick a current version in WHM/cPanel"
  say ""
  exit 1
fi

# ---- --patch: apply the vendor update --------------------------------------
if [ "$(id -u)" -ne 0 ]; then
  say "  ${RED}--patch needs root.${R} Re-run with sudo."
  say ""
  exit 1
fi

say "  ${CYN}${B}Patching…${R}"
patched_something=0
if   command -v apt-get >/dev/null 2>&1; then
  say "  ${DIM}apt-get update && apt-get install --only-upgrade php*${R}"
  apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --only-upgrade 'php*' && patched_something=1
elif command -v dnf >/dev/null 2>&1; then
  say "  ${DIM}dnf update php*${R}"
  dnf -y update 'php*' && patched_something=1
elif command -v yum >/dev/null 2>&1; then
  say "  ${DIM}yum update php*${R}"
  yum -y update 'php*' && patched_something=1
else
  say "  ${YEL}No apt/dnf/yum here.${R} This looks like a managed host (cPanel/CloudLinux):"
  say "  the hosting provider rebuilds PHP — pick a current alt-php version in WHM/cPanel."
  say ""
  exit 1
fi

# reload anything still running the old binary
if [ "$patched_something" -eq 1 ]; then
  for svc in php-fpm php8.3-fpm php8.2-fpm php8.1-fpm php8.4-fpm httpd apache2 php-fpm.service; do
    if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$svc" 2>/dev/null; then
      say "  ${DIM}restart $svc${R}"; systemctl restart "$svc" 2>/dev/null
    fi
  done
fi

say ""
say "  ${CYN}Re-checking…${R}"
say ""
exec "$0" ${QUIET:+--quiet} $( [ "$COLOR" = no ] && echo --no-color )
