#!/bin/sh
# php2shell-check.sh
# First-pass inventory for PHP2Shell (CVE-2026-109618 and CVE-2026-109619).
# It lists PHP installations, compares their versions with the upstream fixes
# and reads the workaround settings. It does NOT prove that a server is safe,
# nor that it is exploitable: compare its output with your distribution's
# security tracker and with the configuration actually loaded (phpinfo()).
#
# Read-only: this script does not change anything.
#
# Usage:
#   sudo sh php2shell-check.sh
#   docker exec -i <container> sh < php2shell-check.sh
#
# Exit code: 0 = nothing flagged by these checks, 1 = items to review,
#            2 = likely exposed (vulnerable PHP without the workarounds).
# Source: https://le-programmeur.com/php2shell-failles-critiques-php/

if [ -t 1 ]; then R=$(printf '\033[31m'); G=$(printf '\033[32m'); Y=$(printf '\033[33m'); Z=$(printf '\033[0m'); else R=; G=; Y=; Z=; fi
STATUS=0
FOUND=0
ML_UNLIMITED=0
ok()    { printf '%s[OK]%s %s\n' "$G" "$Z" "$*"; }
fail()  { printf '%s[KO]%s %s\n' "$R" "$Z" "$*"; STATUS=2; }
warn()  { printf '%s[!!]%s %s\n' "$Y" "$Z" "$*"; [ "$STATUS" -lt 1 ] && STATUS=1; }
info()  { printf '     %s\n' "$*"; }
title() { printf '\n== %s ==\n' "$*"; }
TAB=$(printf '\t')

# Severity used when a workaround is missing: "fail" for an upstream build
# below the fixed release, "warn" when a distribution may have backported the fix.
SEV=warn
report() { if [ "$SEV" = fail ]; then fail "$@"; else warn "$@"; fi; }

# First fixed upstream release for each supported branch (October 21, 2026)
fixed_patch() {
  case "$1" in 8.2) echo 35 ;; 8.3) echo 36 ;; 8.4) echo 27 ;; 8.5) echo 12 ;; *) echo "" ;; esac
}

lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; }

# Fixed distribution packages, by OS and source package (dpkg version syntax).
# Sources: https://ubuntu.com/blog/php2shell-vulnerability-fixes-available
#          https://security-tracker.debian.org/tracker/CVE-2026-109618
DISTRO_FIXES="
ubuntu trusty php5 5.5.9+dfsg-1ubuntu4.29+esm18
ubuntu xenial php7.0 7.0.33-0ubuntu0.16.04.16+esm22
ubuntu bionic php7.2 7.2.24-0ubuntu0.18.04.17+esm14
ubuntu focal php7.4 7.4.3-4ubuntu2.29+esm5
ubuntu jammy php8.1 8.1.2-1ubuntu2.28
ubuntu noble php8.3 8.3.6-0ubuntu0.24.04.13
ubuntu resolute php8.5 8.5.4-0ubuntu1.5
ubuntu stonking php8.5 8.5.9-0ubuntu2.1
debian bookworm php8.2 8.2.35-1~deb12u1
debian trixie php8.4 8.4.27-1~deb13u1
"
# shellcheck disable=SC1091
OS_ID=$(. /etc/os-release 2>/dev/null && echo "$ID")
# shellcheck disable=SC1091
OS_CODENAME=$(. /etc/os-release 2>/dev/null && echo "${VERSION_CODENAME:-$UBUNTU_CODENAME}")

# Fixed version of a Debian/Ubuntu binary package for this OS, if known. $1 = package name
distro_fix() {
  command -v dpkg-query >/dev/null 2>&1 || return
  src=$(dpkg-query -W -f='${source:Package}' "$1" 2>/dev/null)
  [ -z "$src" ] && return
  echo "$DISTRO_FIXES" | awk -v o="$OS_ID" -v c="$OS_CODENAME" -v s="$src" '$1 == o && $2 == c && $3 == s { print $4; exit }'
}

# Classify a version. $1 = upstream version (x.y.z), $2 = 1 if installed from a
# distribution package, $3 = package name, $4 = package version.
# Prints the verdict and sets SEV. Returns 1 if fixed.
classify() {
  v=$1; packaged=$2; pname=$3; pver=$4
  branch=${v%.*}; patch=${v##*.}; min=$(fixed_patch "$branch")
  if [ -n "$min" ] && [ "$patch" -ge "$min" ] 2>/dev/null; then
    ok "PHP $v includes the upstream fix (>= $branch.$min)"
    return 1
  fi
  if [ -z "$min" ]; then
    case "$branch" in
      [0-7].*|8.0|8.1)
        info "branch $branch no longer gets regular PHP updates; the PHP team published exceptional"
        info "source backports for 7.4, 8.0 and 8.1, and distributions or LTS vendors may ship fixes" ;;
      *) warn "branch $branch is unknown to this script: check the security advisories"; SEV=warn; return 0 ;;
    esac
  fi
  if [ "$packaged" = 1 ] && [ -n "$pname" ] && dfix=$(distro_fix "$pname") && [ -n "$dfix" ]; then
    if dpkg --compare-versions "$pver" ge "$dfix"; then
      ok "package $pname $pver includes the $OS_ID $OS_CODENAME fix (>= $dfix), even though PHP reports $v"
      return 1
    fi
    SEV=fail
    warn "package $pname $pver is below the $OS_ID $OS_CODENAME fix ($dfix): update it"
    return 0
  fi
  if [ "$packaged" = 1 ]; then
    SEV=warn
    warn "PHP reports $v, below the upstream fix${min:+ ($branch.$min)}: status to check with your distribution"
    info "distributions backport fixes without changing the PHP version number:"
    info "compare the package version above with your distribution's security tracker"
    info "(this script knows the fixed packages of Ubuntu and Debian only)"
  else
    SEV=fail
    if [ -n "$min" ]; then warn "PHP $v is an upstream build below the fix ($branch.$min): vulnerable, update or apply the workarounds"
    else warn "PHP $v is an upstream build of an end-of-life branch: vulnerable unless the source backport was applied"; fi
  fi
  return 0
}

check_ml() { # $1 label, $2 value, $3 origin
  if [ -z "$2" ]; then warn "$1: memory_limit could not be read"
  elif [ "$2" = "-1" ]; then
    ML_UNLIMITED=1
    case "$3" in
      php_admin*) ok "$1: memory_limit = -1 (admin level)" ;;
      php.ini) ok "$1: memory_limit = -1 (php.ini)"
        info "a .user.ini, .htaccess or php_value can still override it; php_admin_value prevents that" ;;
      *) warn "$1: memory_limit = -1, but set via $3: a .user.ini or .htaccess can override it, prefer php_admin_value" ;;
    esac
  else
    report "$1: memory_limit = $2 (CVE-2026-109618 workaround not applied)"
  fi
}

check_off() { # $1 label, $2 directive, $3 value, $4 CVE
  case "$(lower "$3")" in
    "") warn "$1: $2 could not be read" ;;
    0|off|false|no|none) ok "$1: $2 = Off" ;;
    *) report "$1: $2 = $3 ($4 workaround not applied)" ;;
  esac
}

check_all() { # $1 label, $2 branch, $3 memory_limit, $4 origin, $5 upload_progress, $6 fatal_error_backtraces
  check_ml "$1" "$3" "$4"
  check_off "$1" session.upload_progress.enabled "$5" CVE-2026-109619
  [ "$2" = 8.5 ] && check_off "$1" fatal_error_backtraces "$6" CVE-2026-109619
}

package_of() { # prints "name version" if a package owns $1
  if command -v dpkg >/dev/null 2>&1; then
    p=$(dpkg -S "$1" 2>/dev/null | cut -d: -f1 | head -n 1)
    [ -n "$p" ] && { echo "$p $(dpkg-query -W -f='${Version}' "$p" 2>/dev/null)"; return; }
  fi
  if command -v rpm >/dev/null 2>&1; then
    p=$(rpm -qf "$1" 2>/dev/null) && echo "$p"
  fi
}

# Value of a directive as seen by a PHP binary (master value)
ini() {
  "$1" -i 2>/dev/null | sed -e 's/<[^>]*>/ => /g' |
    awk -F' => ' -v k="$2" '{ gsub(/^ +| +$/, "", $1) } $1 == k { v = $3; if (v == "") v = $2; gsub(/^ +| +$/, "", v); print v; exit }'
}

# Configuration files read by PHP-FPM for a branch: main file plus its include= lines
fpm_files() {
  b=$1; bc=$(echo "$b" | tr -d .); found=0
  for main in /etc/php/"$b"/fpm/php-fpm.conf /etc/opt/remi/php"$bc"/php-fpm.conf \
              /etc/php-fpm.conf /usr/local/etc/php-fpm.conf; do
    [ -f "$main" ] || continue
    found=1; echo "$main"
    grep -E '^[ \t]*include[ \t]*=' "$main" | sed 's/^[^=]*=[ \t]*//; s/[ \t;].*$//' | while read -r inc; do
      case "$inc" in /*) ;; *) inc="$(dirname "$(dirname "$main")")/$inc" ;; esac
      for f in $inc; do [ -f "$f" ] && echo "$f"; done
    done
  done
  if [ "$found" = 0 ]; then
    for f in /etc/php/"$b"/fpm/pool.d/*.conf /etc/php-fpm.d/*.conf /usr/local/etc/php-fpm.d/*.conf; do
      [ -f "$f" ] && echo "$f"
    done
  fi
}

# FPM pools: name, memory_limit, origin, upload_progress, fatal_error_backtraces
pools() {
  b=$1; files=$(fpm_files "$b" | awk '!seen[$0]++')
  [ -z "$files" ] && return
  # shellcheck disable=SC2086
  cat $files | awk -v ML="$2" -v UP="$3" -v FB="$4" '
    /^[ \t]*;/ { next }
    /^[ \t]*\[[^]]+\]/ { p = $0; sub(/^[ \t]*\[/, "", p); sub(/\].*/, "", p)
      if (p != "global" && !(p in ml)) { n++; names[n] = p; ml[p] = ML; mk[p] = "php.ini"; up[p] = UP; fb[p] = FB }
      next }
    p != "" && p != "global" && /^[ \t]*php_(admin_)?(value|flag)\[/ {
      kind = ($0 ~ /php_admin_/) ? "php_admin_value" : "php_value"
      d = $0; sub(/^[^[]*\[/, "", d); sub(/\].*/, "", d)
      v = $0; sub(/^[^=]*=[ \t]*/, "", v); sub(/[ \t;].*$/, "", v); gsub(/"/, "", v)
      if (d == "memory_limit" && !(mk[p] == "php_admin_value" && kind == "php_value")) { ml[p] = v; mk[p] = kind }
      if (d == "session.upload_progress.enabled") up[p] = v
      if (d == "fatal_error_backtraces") fb[p] = v
    }
    END { for (i = 1; i <= n; i++) { p = names[i]; print p "\t" ml[p] "\t" mk[p] "\t" up[p] "\t" fb[p] } }'
}

# ---------------------------------------------------------------------------
title "PHP binaries (CLI and PHP-FPM)"
LIST=$( {
  for f in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php /usr/sbin/php-fpm* \
           /usr/local/sbin/php-fpm /opt/remi/php*/root/usr/bin/php /opt/remi/php*/root/usr/sbin/php-fpm; do
    [ -f "$f" ] && [ -x "$f" ] && readlink -f "$f"
  done
  for c in php php-fpm; do c=$(command -v "$c" 2>/dev/null) && readlink -f "$c"; done
  # running PHP processes, whatever their install path (needs root)
  for e in /proc/[0-9]*/exe; do
    t=$(readlink "$e" 2>/dev/null) || continue
    case "${t##*/}" in php|php[0-9]*|php-fpm*|php-cgi*) echo "${t% (deleted)}" ;; esac
  done
} | sort -u)

for b in $LIST; do
  [ -x "$b" ] || continue
  version=$("$b" -v 2>/dev/null | head -n 1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)
  [ -z "$version" ] && continue
  FOUND=1; branch=${version%.*}
  printf '\n%s (PHP %s)\n' "$b" "$version"
  pkg=$(package_of "$b"); packaged=0
  [ -n "$pkg" ] && { packaged=1; info "package $pkg"; }
  classify "$version" "$packaged" "${pkg%% *}" "${pkg#* }" || continue

  ml=$(ini "$b" memory_limit); up=$(ini "$b" session.upload_progress.enabled); fbt=$(ini "$b" fatal_error_backtraces)
  case "$b" in
    *fpm*)
      p=$(pools "$branch" "$ml" "$up" "$fbt")
      if [ -z "$p" ]; then
        warn "no PHP-FPM pool configuration found: values below come from php.ini only"
        check_all "FPM php.ini" "$branch" "$ml" "php.ini" "$up" "$fbt"
      else
        while IFS="$TAB" read -r name pml pmk pup pfb; do
          check_all "pool [$name]" "$branch" "$pml" "$pmk" "$pup" "$pfb"
        done <<EOF
$p
EOF
      fi ;;
    *)
      info "CLI: does not serve HTTP requests, values shown for information"
      info "memory_limit = ${ml:-?}, session.upload_progress.enabled = ${up:-?}" ;;
  esac
done

# ---------------------------------------------------------------------------
# mod_php (Apache): settings come from php.ini and from the Apache configuration
apache_files() {
  for f in /etc/apache2/apache2.conf /etc/apache2/conf-enabled/* /etc/apache2/sites-enabled/* /etc/apache2/mods-enabled/*.conf \
           /etc/httpd/conf/httpd.conf /etc/httpd/conf.d/*.conf /etc/httpd/conf.modules.d/*.conf; do
    [ -f "$f" ] && echo "$f"
  done
}

ini_file_value() { # $1 directive, then ini files; last occurrence wins
  d=$1; shift
  cat "$@" 2>/dev/null | grep -E "^[ \t]*$(echo "$d" | sed 's/\./\\./g')[ \t]*=" | tail -n 1 |
    sed 's/^[^=]*=[ \t]*//; s/[ \t;].*$//; s/^"//; s/"$//'
}

modphp() { # $1 label, $2 upstream version, $3 "package version" (empty if upstream build),
           # $4 "bin:<php binary>" to read the values through that binary, or ini files
  label=$1; v=$2; pkg=$3; shift 3
  FOUND=1; branch=${v%.*}
  printf '\nmod_php %s (PHP %s)\n' "$label" "$v"
  if [ -n "$pkg" ]; then info "package $pkg"; classify "$v" 1 "${pkg%% *}" "${pkg#* }" || return
  else classify "$v" 0 || return; fi
  case "$1" in
    bin:*) bin=${1#bin:}
      ml=$(ini "$bin" memory_limit); up=$(ini "$bin" session.upload_progress.enabled); fbt=$(ini "$bin" fatal_error_backtraces) ;;
    *) ml=$(ini_file_value memory_limit "$@"); up=$(ini_file_value session.upload_progress.enabled "$@")
      fbt=$(ini_file_value fatal_error_backtraces "$@")
      ml=${ml:-128M}; up=${up:-On}; fbt=${fbt:-On} ;;
  esac
  check_all "php.ini" "$branch" "$ml" "php.ini" "$up" "$fbt"
  overrides=$(apache_files | while read -r f; do
    awk -v f="$f" '/^[ \t]*php_(admin_)?(value|flag)[ \t]+(memory_limit|session\.upload_progress\.enabled|fatal_error_backtraces)[ \t]/ {
      val = $3; gsub(/"/, "", val); print f ":" FNR "\t" $1 "\t" $2 "\t" val }' "$f"
  done)
  if [ -n "$overrides" ]; then
    info "Apache overrides (each one applies only to the context where it is defined):"
    while IFS="$TAB" read -r loc kind dir val; do
      case "$kind" in php_admin_*) origin=php_admin_value ;; *) origin=php_value ;; esac
      case "$dir" in
        memory_limit) check_ml "$loc" "$val" "$origin" ;;
        *) check_off "$loc" "$dir" "$val" CVE-2026-109619 ;;
      esac
    done <<EOF
$overrides
EOF
    info "check that every VirtualHost serving PHP gets the expected values"
  fi
}

MODPHP_DONE=0
if [ -d /etc/apache2 ]; then
  for d in /etc/php/*/apache2; do
    [ -d "$d" ] || continue
    b=$(basename "$(dirname "$d")")
    if [ ! -e /etc/apache2/mods-enabled/php"$b".load ]; then info "mod_php $b installed but not enabled in Apache"; continue; fi
    pv=$(dpkg-query -W -f='${Version}' libapache2-mod-php"$b" 2>/dev/null)
    uv=$(echo "${pv#*:}" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+')
    [ -z "$uv" ] && { warn "mod_php $b: version could not be read"; continue; }
    MODPHP_DONE=1
    modphp "$b" "$uv" "libapache2-mod-php$b $pv" "$d/php.ini" "$d"/conf.d/*.ini
  done
fi
if [ -d /etc/httpd ] && grep -qs 'libphp' /etc/httpd/conf.modules.d/*.conf /etc/httpd/conf/httpd.conf; then
  pv=$(rpm -q --qf '%{VERSION}-%{RELEASE}' php 2>/dev/null)
  uv=$(echo "$pv" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+')
  if [ -n "$uv" ]; then MODPHP_DONE=1; modphp "(httpd)" "$uv" "php $pv" /etc/php.ini /etc/php.d/*.ini
  else warn "mod_php is loaded by httpd but its version could not be read"; fi
fi
# mod_php built from source, as in the official php:apache Docker images:
# the module shares its php.ini with the CLI binary next to it.
if [ "$MODPHP_DONE" = 0 ]; then
  so=$(cat /etc/apache2/mods-enabled/*.load /etc/apache2/apache2.conf /etc/httpd/conf.modules.d/*.conf \
           /etc/httpd/conf/httpd.conf /usr/local/apache2/conf/httpd.conf 2>/dev/null |
       awk '$1 == "LoadModule" && $2 ~ /^php[0-9]*_module$/ { print $3; exit }')
  if [ -n "$so" ]; then
    cli=$(command -v php 2>/dev/null)
    cv=""
    [ -n "$cli" ] && cv=$("$cli" -v 2>/dev/null | head -n 1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)
    if [ -z "$cv" ]; then warn "mod_php loaded from $so, but no PHP CLI found to read its version and settings"
    else
      spkg=$(package_of "$so")
      modphp "($so)" "$cv" "$spkg" "bin:$cli"
    fi
  fi
fi

[ "$FOUND" = 0 ] && warn "no PHP installation detected (custom install paths are not covered: check manually)"

# ---------------------------------------------------------------------------
title "Container memory limit"
if [ -f /.dockerenv ] || [ -f /run/.containerenv ] || [ -n "$KUBERNETES_SERVICE_HOST" ] ||
   grep -qE 'docker|kubepods|containerd|lxc' /proc/1/cgroup 2>/dev/null; then
  lim=$(cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
  if [ -z "$lim" ] || [ "$lim" = "max" ] || [ "${#lim}" -ge 18 ]; then
    if [ "$ML_UNLIMITED" = 1 ]; then warn "no memory limit on this container while memory_limit = -1: one request can use all the host's memory"
    else info "no memory limit on this container (only needed if you set memory_limit = -1)"; fi
  else
    ok "container memory limit: $((lim / 1048576)) MiB"
  fi
else
  info "not running in a container"
fi

if command -v docker >/dev/null 2>&1 && docker ps -q >/dev/null 2>&1; then
  title "Docker containers on this host"
  list=$(docker ps --format '{{.ID}} {{.Names}}')
  [ -z "$list" ] && info "no running container"
  for id in $(echo "$list" | cut -d' ' -f1); do
    name=$(echo "$list" | awk -v i="$id" '$1 == i { print $2 }')
    # Quick look inside the container: PHP version, memory_limit, cgroup memory limit
    probe=$(docker exec "$id" sh -c '
      for b in $(command -v php-fpm php 2>/dev/null) /usr/local/sbin/php-fpm /usr/sbin/php-fpm* /usr/bin/php*; do
        [ -f "$b" ] && [ -x "$b" ] || continue
        v=$("$b" -v 2>/dev/null | head -n 1) || continue
        echo "V:$v"; echo "M:$("$b" -i 2>/dev/null | grep -m 1 "^memory_limit" | sed "s/.*=> //")"
        echo "U:$("$b" -i 2>/dev/null | grep -m 1 "^session.upload_progress.enabled" | sed "s/.*=> //")"; break
      done
      echo "C:$(cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)"' 2>/dev/null)
    cv=$(echo "$probe" | sed -n 's/^V://p' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)
    [ -z "$cv" ] && continue
    cml=$(echo "$probe" | sed -n 's/^M://p' | tr -d ' ')
    cgl=$(echo "$probe" | sed -n 's/^C://p')
    printf '\n%s (PHP %s)\n' "$name" "$cv"
    cb=${cv%.*}; cmin=$(fixed_patch "$cb")
    if [ -n "$cmin" ] && [ "${cv##*.}" -ge "$cmin" ] 2>/dev/null; then
      ok "$name: PHP $cv includes the upstream fix"
    else
      warn "$name: PHP $cv is below the upstream fix${cmin:+ ($cb.$cmin)}: check the image, or run the script inside"
      info "php.ini values: memory_limit = ${cml:-?}, session.upload_progress.enabled = $(echo "$probe" | sed -n 's/^U://p')"
      info "pools and Apache overrides are only checked from inside: docker exec -i $name sh < php2shell-check.sh"
    fi
    # Memory limit: Docker setting, or the cgroup seen from inside the container
    mem=$(docker inspect -f '{{.HostConfig.Memory}}' "$id" 2>/dev/null)
    if [ -n "$mem" ] && [ "$mem" != 0 ]; then
      ok "$name: container memory limit $((mem / 1048576)) MiB"
    elif [ -n "$cgl" ] && [ "$cgl" != max ] && [ "${#cgl}" -lt 18 ]; then
      ok "$name: container memory limit $((cgl / 1048576)) MiB (cgroup)"
    elif [ "$cml" = "-1" ]; then
      warn "$name: memory_limit = -1 and no container memory limit (--memory / mem_limit)"
    else
      info "$name: no container memory limit (only needed if you set memory_limit = -1; value read: ${cml:-?})"
    fi
  done
fi

# ---------------------------------------------------------------------------
title "Summary"
case "$STATUS" in
  0) ok "nothing flagged by these checks (this does not prove the server is not vulnerable)" ;;
  1) warn "items to review above: check your distribution's fixes and the settings actually loaded" ;;
  2) fail "likely exposed: vulnerable PHP without the workarounds, update PHP" ;;
esac
info "this is a first-pass inventory based on files and versions, not an exploitability test;"
info "phpinfo() through the web server shows the values actually applied"
info "restart PHP-FPM or Apache after any update or configuration change"
exit "$STATUS"
