#!/usr/bin/env bash
# check-php2shell.sh - READ-ONLY checker for PHP2Shell
#   CVE-2026-109618 (GHSA-w8hw-w9fx-j79w, RCE via OOM corruption)
#   CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp, RCE via stale global state)
# Patched upstream versions (verbatim from both advisories): 8.2.35, 8.3.36, 8.4.27, 8.5.12
# This script never writes, restarts or changes anything. Run as root for full log/ini visibility.
#
# Exit codes: 0 = all PHP found PATCHED (or no PHP found)
#             1 = at least one LIKELY VULNERABLE
#             2 = no vulnerable, but at least one UNKNOWN
#             3 = log hunt found suspicious entries (only when nothing is vulnerable/unknown; see summary)
set -u
export LC_ALL=C

CVES='CVE-2026-109618|CVE-2026-109619'
LOG_MAX_LINES=${LOG_LINES:-200000}   # max lines read from the tail of each log
case "$LOG_MAX_LINES" in ''|*[!0-9]*|0*) printf 'LOG_LINES must be a positive integer (got %s); using 200000\n' "$LOG_MAX_LINES" >&2; LOG_MAX_LINES=200000 ;; esac
VULN=0; UNK=0; PATCHED=0; SUSP=0

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

# returns 0 if $1 >= $2 (dotted numeric)
ver_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ]; }

upstream_verdict() { # $1 = X.Y.Z  -> echoes PATCHED|VULNERABLE|NOFIX
  local v=$1 mm fixed
  mm=$(printf '%s' "$v" | cut -d. -f1,2)
  case "$mm" in
    8.2) fixed=8.2.35 ;; 8.3) fixed=8.3.36 ;; 8.4) fixed=8.4.27 ;; 8.5) fixed=8.5.12 ;;
    *)
      if ver_ge "$v" 8.6.0; then echo PATCHED_NEWER; return; fi
      echo NOFIX; return ;;
  esac
  if ver_ge "$v" "$fixed"; then echo PATCHED; else echo "VULNERABLE(<$fixed)"; fi
}

# ---------- 1. discover binaries ----------
hdr "PHP binaries"
declare -A SEEN=()
BINS=()
add_bin() {
  local b=$1 r
  [ -f "$b" ] && [ -x "$b" ] || return 0
  r=$(readlink -f "$b" 2>/dev/null || printf '%s' "$b")
  # Only ever execute real PHP SAPI binaries: strict name match AND an ELF header.
  # (Globs like /usr/local/bin/php* otherwise match phpunit, phpcs, phpcbf, php-cs-fixer... which would be RUN.)
  case "$(basename "$r")" in
    php|php[0-9]*|php-cgi|php-cgi[0-9]*|php-fpm|php-fpm[0-9]*|php-cli|php[0-9]*-cli|lsphp|lsphp[0-9]*|frankenphp) ;;
    *) return 0 ;;
  esac
  case "$(basename "$r")" in *[!a-z0-9.-]*|*unit*|*cs*|*stan*|*md*|*dbg*|*ize*|*config*) return 0 ;; esac
  [ "$(head -c 4 "$r" 2>/dev/null | od -An -c | tr -d ' ')" = '177ELF' ] || { say "Skipping non-ELF $r"; return 0; }
  [ -n "${SEEN[$r]:-}" ] && return 0
  SEEN[$r]=1; BINS+=("$r")
}
for n in php php-cgi php-fpm php-cli; do
  p=$(command -v "$n" 2>/dev/null) && add_bin "$p"
done
for g in /usr/bin/php* /usr/sbin/php-fpm* /usr/local/bin/php* /usr/local/sbin/php-fpm* \
         /opt/*/bin/php* /opt/*/sbin/php-fpm* /opt/remi/*/root/usr/bin/php* /opt/remi/*/root/usr/sbin/php-fpm* \
         /opt/cpanel/ea-php*/root/usr/bin/php* /opt/cpanel/ea-php*/root/usr/sbin/php-fpm \
         /opt/plesk/php/*/bin/php* /opt/plesk/php/*/sbin/php-fpm /opt/lampp/bin/php* /opt/bitnami/php/bin/php* \
         /opt/bitnami/php/sbin/php-fpm /usr/local/php*/bin/php* /usr/local/php*/sbin/php-fpm /usr/lib/cgi-bin/php*; do
  case "$g" in *'*'*) continue ;; *config*|*ize*|*dbg*|*-fpm.d*|*.conf) continue ;; esac
  add_bin "$g"
done
# running processes (e.g. container or odd prefix)
for pid in $(pgrep -f 'php' 2>/dev/null); do
  e=$(readlink -f "/proc/$pid/exe" 2>/dev/null) || continue
  case "$(basename "$e")" in php*|lsphp*|frankenphp) add_bin "$e" ;; esac
done
# Apache mod_php (libphp*.so) - versions read from package, not executable
MODPHP=$(ls /usr/lib/apache2/modules/libphp*.so /usr/lib64/httpd/modules/libphp*.so /etc/httpd/modules/libphp*.so 2>/dev/null)
[ -n "$MODPHP" ] && say "Apache mod_php module(s): $(echo "$MODPHP" | tr '\n' ' ') (version inferred from package/CLI of same branch)"
[ ${#BINS[@]} -eq 0 ] && say "No PHP executables found."

# ---------- 2. package changelogs ----------
pkg_of() { # echo owning package name or empty
  local f=$1
  if command -v dpkg-query >/dev/null 2>&1; then dpkg -S "$f" 2>/dev/null | head -n1 | cut -d: -f1; return; fi
  if command -v rpm >/dev/null 2>&1; then rpm -qf --qf '%{NAME}\n' "$f" 2>/dev/null | grep -v 'not owned' | head -n1; return; fi
  if command -v apk >/dev/null 2>&1; then apk info -W "$f" 2>/dev/null | awk '/owned by/{print $NF}' | sed -E 's/-[0-9][^-]*-r[0-9]+$//'; fi
}
pkg_changelog_has_cve() { # $1=pkg ; echo matched CVEs
  local p=$1 out=""
  if command -v dpkg-query >/dev/null 2>&1; then
    local src d
    src=$(dpkg-query -W -f='${source:Package}' "$p" 2>/dev/null)
    for d in "/usr/share/doc/$p" "/usr/share/doc/$src"; do
      for f in "$d"/changelog.Debian.gz "$d"/changelog.gz; do
        [ -r "$f" ] && out="$out $(zcat "$f" 2>/dev/null | grep -Eo "$CVES" | sort -u | tr '\n' ' ')"
      done
    done
  elif command -v rpm >/dev/null 2>&1; then
    out=$(rpm -q --changelog "$p" 2>/dev/null | grep -Eo "$CVES" | sort -u | tr '\n' ' ')
  fi
  printf '%s' "$out" | tr ' ' '\n' | grep -E . | sort -u | tr '\n' ' '
}
pkg_version() {
  if command -v dpkg-query >/dev/null 2>&1; then dpkg-query -W -f='${Version}' "$1" 2>/dev/null
  elif command -v rpm >/dev/null 2>&1; then rpm -q --qf '%{VERSION}-%{RELEASE}' "$1" 2>/dev/null
  elif command -v apk >/dev/null 2>&1; then apk list --installed "$1" 2>/dev/null | awk 'NR==1{print $1}' | sed "s/^$1-//"; fi
}

# ---------- 3. per-binary assessment ----------
ini_get() { # $1=bin ; php-fpm -i / php-cgi -i print phpinfo for that SAPI's php.ini (pool/dir overrides not included)
  [ "$2" = frankenphp ] && return 0
  # php-cgi emits HTML phpinfo; normalise "<td class="e">key </td><td class="v">val </td>" to "key => val"
  timeout 10 "$1" -i 2>/dev/null | sed -E 's#<tr><td class="e">\s*([^<]*[^< ]) *</td><td class="v">\s*([^<]*[^< ]) *</td>.*#\1 => \2#; s#<i>no value</i>#no value#g'
}
for b in "${BINS[@]}"; do
  hdr "$b"
  base=$(basename "$b"); kind=cli
  case "$base" in *fpm*) kind=fpm ;; *cgi*) kind=cgi ;; lsphp*) kind=lsapi ;; frankenphp) kind=frankenphp ;; esac
  if [ "$kind" = frankenphp ]; then
    vline=$(timeout 10 "$b" version 2>/dev/null | head -n1)
  else
    vline=$(timeout 10 "$b" -v 2>/dev/null | head -n1)
  fi
  ver=$(printf '%s' "$vline" | grep -Eo 'PHP [0-9]+\.[0-9]+\.[0-9]+' | head -n1 | cut -d' ' -f2)
  prerel=no; printf '%s' "$vline" | grep -Eqi 'PHP [0-9]+\.[0-9]+\.[0-9]+(-?(RC|alpha|beta|dev))' && prerel=yes
  case "$vline" in *'(cgi-fcgi)'*) kind=cgi ;; *'(fpm-fcgi)'*) kind=fpm ;; esac
  say "SAPI: $kind"
  say "Version string: ${vline:-<could not run binary>}"
  pkg=$(pkg_of "$b"); cves=""
  if [ -n "$pkg" ]; then
    say "Package: $pkg $(pkg_version "$pkg")"
    cves=$(pkg_changelog_has_cve "$pkg")
    say "Changelog CVE references: ${cves:-none found}"
  else
    say "Package: not package-managed (source build, vendor bundle or container image)"
  fi
  uv=NONE; [ -n "$ver" ] && uv=$(upstream_verdict "$ver")
  both=no
  if printf '%s' "$cves" | grep -q 109618 && printf '%s' "$cves" | grep -q 109619; then both=yes; fi
  if [ "$both" = yes ]; then verdict=PATCHED; why="package changelog references both CVE IDs (distro backport)"
  elif [ "$prerel" = yes ] && { [ "$uv" = PATCHED ] || [ "$uv" = PATCHED_NEWER ]; }; then verdict=UNKNOWN; why="pre-release/dev build $vline - cannot confirm it contains the final fix"
  elif [ "$uv" = PATCHED ] || [ "$uv" = PATCHED_NEWER ]; then verdict=PATCHED; why="upstream version $ver >= fixed release for its branch"
  elif [ -n "$cves" ]; then verdict=UNKNOWN; why="changelog references only: $cves - confirm both fixes with vendor"
  elif [ -z "$ver" ]; then verdict=UNKNOWN; why="could not determine version"
  elif [ -n "$pkg" ] && case "$uv" in VULNERABLE*|NOFIX) true ;; *) false ;; esac && case "$(pkg_version "$pkg")" in "$ver"*) false ;; *) true ;; esac; then
       verdict=UNKNOWN; why="package version differs from upstream; no CVE in changelog - check vendor advisory"
  elif [ "$uv" = NOFIX ]; then verdict="LIKELY VULNERABLE"; why="branch $ver has no upstream release with the fix (EOL); php.net published source backports for 7.4/8.0/8.1 which this script cannot detect in custom builds; no CVE in package changelog"
  else verdict="LIKELY VULNERABLE"; why="upstream $ver is $uv and no CVE in package changelog"
  fi
  [ "$prerel" = yes ] && [ "$verdict" = "LIKELY VULNERABLE" ] && why="$why (pre-release/dev build: $vline)"

  # effective ini
  info=$(ini_get "$b" "$kind")
  if [ -n "$info" ]; then
    for k in memory_limit post_max_size session.upload_progress.enabled fatal_error_backtraces; do
      val=$(printf '%s\n' "$info" | awk -v k="$k" -F' => ' '$1==k{print $2; exit}')
      say "ini $k = ${val:-<not present>}"
    done
    ml=$(printf '%s\n' "$info" | awk -F' => ' '$1=="memory_limit"{print $2;exit}')
    up=$(printf '%s\n' "$info" | awk -F' => ' '$1=="session.upload_progress.enabled"{print $2;exit}')
    fb=$(printf '%s\n' "$info" | awk -F' => ' '$1=="fatal_error_backtraces"{print $2;exit}')
    if [ "$verdict" != PATCHED ]; then
      mit=""
      [ "$ml" = "-1" ] && mit="$mit memory_limit=-1(partial 109618)"
      case "$up" in Off|0|"") [ -n "$up" ] && mit="$mit upload_progress=Off(partial 109619)" ;; esac
      case "$fb" in On|1) mit="$mit WARNING:fatal_error_backtraces=On" ;; esac
      say "Mitigations observed:${mit:- none}"
    fi
  else
    say "ini: could not read (fpm pool php_admin_value / Apache php_value / .user.ini overrides are NOT reflected)"
  fi
  say "VERDICT: $verdict - $why"
  case "$verdict" in PATCHED) PATCHED=$((PATCHED+1)) ;; "LIKELY VULNERABLE") VULN=$((VULN+1)) ;; *) UNK=$((UNK+1)) ;; esac
done

# ---------- 4. log hunt ----------
hdr "Log hunt (read-only)"
LOGS=()
for f in /var/log/php*-fpm*.log* /var/log/php-fpm/*.log* /var/log/php*/*.log* /var/log/apache2/*error*.log* \
         /var/log/httpd/*error*log* /var/log/nginx/*error*.log* /var/log/php_errors.log* /opt/*/logs/*error*log* \
         /usr/local/*/logs/*error*log* /opt/lampp/logs/*.log /var/log/syslog /var/log/messages; do
  [ -r "$f" ] && [ -f "$f" ] && LOGS+=("$f")
done
readlog() { case "$1" in *.gz) zcat "$1" 2>/dev/null | tail -n "$LOG_MAX_LINES" ;; *) tail -n "$LOG_MAX_LINES" "$1" 2>/dev/null ;; esac; }
PAT_OOM='Allowed memory size of [0-9]+ bytes exhausted|Out of memory'
PAT_SIG='(child [0-9]+ exited on signal [0-9]+|exited on signal (6|7|11)|SIGSEGV|SIGBUS|SIGABRT|segfault at .* php|child pid [0-9]+ exit signal)'
if [ ${#LOGS[@]} -eq 0 ]; then say "No readable PHP/Apache/nginx logs found in standard paths."; fi
for f in "${LOGS[@]}"; do
  o=$(readlog "$f" | grep -Eci "$PAT_OOM"); s=$(readlog "$f" | grep -Eci "$PAT_SIG")
  # syslog/messages: only count php-related lines
  case "$f" in */syslog|*/messages)
    o=$(readlog "$f" | grep -i php | grep -Eci "$PAT_OOM"); s=$(readlog "$f" | grep -Ei 'php|httpd|apache2' | grep -Eci "$PAT_SIG") ;; esac
  if [ "${o:-0}" -gt 0 ] || [ "${s:-0}" -gt 0 ]; then
    SUSP=$((SUSP+1))
    say "[!] $f : memory errors=$o, worker signal exits=$s"
    readlog "$f" | grep -Ei "$PAT_OOM|$PAT_SIG" | tail -n 5 | sed 's/^/      /'
  fi
done
if command -v journalctl >/dev/null 2>&1; then
  j=$(journalctl --no-pager -q --since "-14 days" 2>/dev/null | grep -Ei 'php|httpd|apache2' | grep -Ei "$PAT_OOM|$PAT_SIG" | tail -n 5)
  [ -n "$j" ] && { SUSP=$((SUSP+1)); say "[!] journald (14 days):"; printf '%s\n' "$j" | sed 's/^/      /'; }
fi
[ "$SUSP" -eq 0 ] && say "No memory-exhaustion or worker-signal entries found (absence is NOT proof of safety)."

# ---------- summary ----------
hdr "Summary"
say "PATCHED=$PATCHED LIKELY_VULNERABLE=$VULN UNKNOWN=$UNK suspicious_logs=$SUSP"
if [ "$VULN" -gt 0 ]; then say "OVERALL: LIKELY VULNERABLE"; exit 1
elif [ "$UNK" -gt 0 ]; then say "OVERALL: UNKNOWN"; exit 2
elif [ "$PATCHED" -eq 0 ] && [ "$SUSP" -gt 0 ]; then say "OVERALL: NO PHP FOUND (but review suspicious log entries)"; exit 3
elif [ "$SUSP" -gt 0 ]; then say "OVERALL: PATCHED (but review suspicious log entries)"; exit 3
elif [ "$PATCHED" -eq 0 ]; then say "OVERALL: NO PHP FOUND"; exit 0
else say "OVERALL: PATCHED"; exit 0; fi
