php2shell-scanner.git

php2shell-scanner

~ Check PHP2Shell (CVE-2026-109618, CVE-2026-109619) from inside your server: distribution backports, missed restarts and interim mitigations, verdicts computed in the browser.
git clone https://github.com/debuman/php2shell-scanner.git

scripts/php2shell-server-check.sh (24124B)

      1 #!/bin/sh
      2 # php2shell-server-check.sh
      3 # The server script for PHP2Shell (CVE-2026-109618, CVE-2026-109619)
      4 #
      5 # - Read-only. It changes no files, restarts no services and makes no network connections.
      6 # - Run it as root so it can also check processes and logs owned by other accounts (www-data etc.).
      7 # - Paste the "PHP2SHELL SERVERCHECK" block at the end into the check page.
      8 #
      9 # Usage: sh php2shell-server-check.sh [--logs] [--webroot DIR]...
     10 #   --logs         Search web server and PHP logs, the kernel log and session files for signs of an attack.
     11 #   --webroot DIR  List PHP files under DIR changed in the last 3 days (to spot web shells). Can be repeated.
     12 
     13 set -u
     14 
     15 TOOL_VERSION=1.0.0
     16 CVES="CVE-2026-109618 CVE-2026-109619"
     17 DO_LOGS=0
     18 WEBROOTS=""
     19 
     20 while [ $# -gt 0 ]; do
     21   case "$1" in
     22     --logs) DO_LOGS=1 ;;
     23     --webroot)
     24       shift
     25       if [ $# -eq 0 ]; then echo "--webroot needs a directory." >&2; exit 2; fi
     26       WEBROOTS="$WEBROOTS
     27 $1"
     28       ;;
     29     -h|--help) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
     30     *) echo "Unknown option: $1 (see --help)" >&2; exit 2 ;;
     31   esac
     32   shift
     33 done
     34 
     35 NL='
     36 '
     37 MACHINE=""
     38 add() { MACHINE="$MACHINE$1$NL"; }
     39 # Machine lines are '|'-delimited, so replace any '|' and line breaks inside values.
     40 clean() { printf '%s' "$1" | tr '|\r\n' '/  '; }
     41 section() { printf '\n== %s ==\n' "$1"; }
     42 have() { command -v "$1" >/dev/null 2>&1; }
     43 # File names, log lines and command lines can be chosen by an attacker. Replace control characters (C0, DEL and
     44 # UTF-8 encoded C1) before printing them, so that escape sequences cannot change what the terminal shows.
     45 C1_RE=$(printf '\302[\200-\237]')
     46 safe() { tr '\t' ' ' | LC_ALL=C sed "s/[[:cntrl:]]/?/g; s/$C1_RE/?/g"; }
     47 # A count in the result block is 0 only when every source behind it was read. When a source could not be read,
     48 # a count above 0 is still given (it is then a lower bound), and a 0 becomes "unknown".
     49 count_or_unknown() { if [ "$1" -gt 0 ] || [ "$2" = 0 ]; then printf '%s' "$1"; else printf 'unknown'; fi; }
     50 
     51 IS_ROOT=0
     52 if [ "$(id -u 2>/dev/null)" = "0" ]; then IS_ROOT=1; fi
     53 
     54 add "tool|php2shell-server-check|$TOOL_VERSION"
     55 add "env|root|$IS_ROOT"
     56 
     57 echo "php2shell-server-check $TOOL_VERSION"
     58 echo "Read-only check. Nothing is changed."
     59 if [ "$IS_ROOT" = 0 ]; then
     60   echo "Note: not running as root, so processes owned by other accounts and some logs may not be checked. Counts that could not be checked are given as unknown."
     61 fi
     62 
     63 # ---------------------------------------------------------------- Operating system
     64 section "Operating system"
     65 OS_FILE=""
     66 for f in /etc/os-release /usr/lib/os-release; do
     67   if [ -r "$f" ]; then OS_FILE=$f; break; fi
     68 done
     69 if [ -n "$OS_FILE" ]; then
     70   for key in ID ID_LIKE VERSION_ID VERSION_CODENAME PRETTY_NAME; do
     71     val=$(sed -n "s/^$key=//p" "$OS_FILE" | head -n 1 | sed "s/^[\"']//; s/[\"']\$//")
     72     if [ -n "$val" ]; then add "os|$key|$(clean "$val")"; fi
     73     if [ "$key" = PRETTY_NAME ] && [ -n "$val" ]; then echo "$val"; fi
     74   done
     75 elif [ -r /etc/redhat-release ]; then
     76   rel=$(cat /etc/redhat-release)
     77   echo "$rel"
     78   add "os|ID|rhel"
     79   major=$(printf '%s' "$rel" | sed -n 's/.*release \([0-9][0-9]*\).*/\1/p')
     80   if [ -n "$major" ]; then add "os|VERSION_ID|$major"; fi
     81 else
     82   echo "No operating system information found."
     83 fi
     84 
     85 # ---------------------------------------------------------------- Packages
     86 section "Installed PHP packages"
     87 PKG_LINES=""
     88 if have dpkg-query; then
     89   PKG_LINES=$(dpkg-query -W -f='${Status}|${Package}|${Version}|${Source}\n' 'php*' 'libapache2-mod-php*' 2>/dev/null |
     90     awk -F'|' '$1 ~ / installed$/ && $2 ~ /^(libapache2-mod-php[0-9.]+|php[0-9.]+-(common|fpm|cli|cgi))$/ {
     91       src = $4; sub(/ .*/, "", src); print "pkg|dpkg|" $2 "|" $3 "|" src }')
     92 fi
     93 if have rpm; then
     94   # php-common: RHEL family and Fedora. php8.4-common: RHEL 10 and Amazon Linux 2023 (php8.4-modphp is the Apache
     95   # module there). php82-php-common: Remi's collections. Extensions, PEAR and PECL packages are left out.
     96   RPM_LINES=$(rpm -qa --qf '%{NAME}|%{EPOCH}:%{VERSION}-%{RELEASE}\n' 2>/dev/null | sed 's/|(none):/|/' |
     97     awk -F'|' '$1 ~ /^(php|php-(common|fpm|cli|cgi)|php[0-9]+\.[0-9]+(-(common|fpm|cli|cgi|modphp))?|php[0-9]+-php-(common|fpm|cli)|mod_php)$/ {
     98       print "pkg|rpm|" $1 "|" $2 "|" }')
     99   if [ -n "$RPM_LINES" ]; then PKG_LINES="$PKG_LINES
    100 $RPM_LINES"; fi
    101 fi
    102 if have apk; then
    103   APK_LINES=$(apk info -v 2>/dev/null | grep -E '^php[0-9]*(-(common|fpm|cgi|apache2))?-[0-9]' |
    104     sed -E 's/^(.*)-([0-9][^-]*-r[0-9]+)$/pkg|apk|\1|\2|/')
    105   if [ -n "$APK_LINES" ]; then PKG_LINES="$PKG_LINES
    106 $APK_LINES"; fi
    107 fi
    108 PKG_LINES=$(printf '%s\n' "$PKG_LINES" | sed '/^$/d')
    109 if [ -n "$PKG_LINES" ]; then
    110   printf '%s\n' "$PKG_LINES" | awk -F'|' '{ printf "  %-28s %s (%s)\n", $3, $4, $2 }'
    111   add "$PKG_LINES"
    112 else
    113   echo "No PHP installed through a package manager was found. It may have been built from source or come from a container image."
    114 fi
    115 
    116 # Does the package changelog record the CVE fixes? (On RHEL-family systems this is the most reliable evidence.)
    117 # The changelog of every matched dpkg and rpm package is read, so that the check page can use the one that belongs
    118 # to the PHP that actually runs. A package whose changelog cannot be read gets no changelog line.
    119 section "CVE entries in package changelogs"
    120 cve_counts() {  # $1 = package, $2 = version, $3 = changelog text
    121   echo "  $1 $2"
    122   for c in $CVES; do
    123     n=$(printf '%s\n' "$3" | grep -cF "$c")
    124     echo "    $c: found on $n line(s)"
    125     add "changelog|$(clean "$1")|$c|$n"
    126   done
    127 }
    128 CL_PKGS=$(printf '%s\n' "$PKG_LINES" | awk -F'|' '($2 == "rpm" || $2 == "dpkg") && !seen[$2 "|" $3]++ { print $2 "|" $3 "|" $4 }')
    129 set -f
    130 for entry in $CL_PKGS; do
    131   mgr=${entry%%|*}; rest=${entry#*|}; p=${rest%%|*}; v=${rest#*|}
    132   if [ "$mgr" = rpm ]; then
    133     if text=$(rpm -q --changelog "$p" 2>/dev/null); then
    134       cve_counts "$p" "$v" "$text"
    135     else
    136       echo "  $p $v: the changelog could not be read."
    137     fi
    138   else
    139     log=""
    140     for f in "/usr/share/doc/$p/changelog.Debian.gz" "/usr/share/doc/$p/changelog.gz"; do
    141       if [ -r "$f" ]; then log=$f; break; fi
    142     done
    143     if [ -n "$log" ]; then
    144       cve_counts "$p" "$v" "$(gzip -dc "$log" 2>/dev/null)"
    145     else
    146       echo "  $p $v: no changelog installed (minimal and container images often leave out /usr/share/doc)."
    147     fi
    148   fi
    149 done
    150 set +f
    151 if [ -z "$CL_PKGS" ]; then echo "  No changelogs to check."; fi
    152 
    153 # ---------------------------------------------------------------- Restart status
    154 # A process that was not restarted after an update replaced its files shows them as "(deleted)" in /proc.
    155 section "Processes running PHP"
    156 if [ -d /proc/self ]; then
    157   PROC_LINES=""
    158   UNREAD_LIST=""
    159   READ_COMMS=""
    160   for d in /proc/[0-9]*; do
    161     comm=$(cat "$d/comm" 2>/dev/null) || continue
    162     case "$comm" in
    163       php-fpm*|php5-fpm*|apache2|httpd|frankenphp|lsphp*|php-cgi*|php5-cgi*) ;;
    164       *) continue ;;
    165     esac
    166     comm=$(clean "$comm")
    167     # Another account's maps file is readable only as root (inside a container, with CAP_SYS_PTRACE).
    168     maps=$(cat "$d/maps" 2>/dev/null)
    169     if [ -z "$maps" ]; then
    170       UNREAD_LIST="$UNREAD_LIST$comm$NL"
    171       continue
    172     fi
    173     # Remembered even when it turns out to be out of scope, so that unreadable processes of the same name
    174     # (the workers of an Apache without mod_php) are not reported as PHP processes that could not be checked.
    175     READ_COMMS="$READ_COMMS$comm$NL"
    176     status=ok
    177     case "$comm" in
    178       apache2|httpd)
    179         # Apache without mod_php (a proxy, or in front of PHP-FPM) is out of scope. For Apache only the PHP
    180         # module and extensions count: an apache2-bin or httpd update alone leaves no old PHP code in memory.
    181         printf '%s\n' "$maps" | grep -Eq 'libphp|mod_php' || continue ;;
    182       *)
    183         exe=$(readlink "$d/exe" 2>/dev/null)
    184         case "$exe" in *" (deleted)") status=stale ;; esac ;;
    185     esac
    186     if printf '%s\n' "$maps" | grep -Eq '(libphp|php[^ ]*\.so|/php/).* \(deleted\)$'; then status=stale; fi
    187     PROC_LINES="$PROC_LINES$comm|$status$NL"
    188   done
    189   if [ -n "$PROC_LINES" ]; then
    190     SUMMARY=$(printf '%s' "$PROC_LINES" | sort | uniq -c |
    191       awk '{ n = $1; sub(/^[ \t]*[0-9]+[ \t]/, ""); split($0, a, "|"); print "proc|" a[1] "|" n "|" a[2] }')
    192     printf '%s\n' "$SUMMARY" | safe | awk -F'|' '{ s = ($4 == "stale") ? "using pre-update files, restart needed" : "using current files"; printf "  %-14s %3s process(es): %s\n", $2, $3, s }'
    193     add "$SUMMARY"
    194   else
    195     echo "  No web server processes running PHP were found."
    196   fi
    197   # Child processes inherit the memory of their parent (master) process, so if at least one
    198   # process with the same name was read, the ones that could not be read do not count.
    199   if [ -n "$UNREAD_LIST" ]; then
    200     MISSING=$(printf '%s' "$UNREAD_LIST" | sort | uniq -c | while read -r cnt c; do
    201       printf '%s' "$READ_COMMS" | grep -qxF "$c" || echo "$cnt"
    202     done | awk '{ s += $1 } END { print s + 0 }')
    203     SKIPPED=$(printf '%s' "$UNREAD_LIST" | grep -c .)
    204     if [ "$MISSING" -gt 0 ]; then
    205       if [ "$IS_ROOT" = 1 ]; then
    206         echo "  ${MISSING} process(es) could not be checked, even as root. Inside a container, reading another account's"
    207         echo "  process memory map needs CAP_SYS_PTRACE: run the script on the host, or use the PHP probe."
    208       else
    209         echo "  ${MISSING} process(es) could not be checked for lack of permission (run again as root)"
    210       fi
    211       add "proc|unreadable|$MISSING|unknown"
    212     else
    213       echo "  (Skipped ${SKIPPED} process(es) that could not be read; a process with the same name was checked instead.)"
    214     fi
    215   fi
    216   if [ "$IS_ROOT" = 0 ] && ! cat /proc/1/comm >/dev/null 2>&1; then
    217     echo "  /proc hides other accounts' processes from this account (hidepid), so PHP processes may be missing here. Run again as root."
    218   fi
    219 else
    220   echo "  Could not check: /proc is not available."
    221 fi
    222 
    223 # ---------------------------------------------------------------- Configuration files
    224 section "Configuration files with interim mitigation settings"
    225 CONF_HITS=""
    226 for d in /etc/php /etc/php.ini /etc/php.d /etc/php-fpm.conf /etc/php-fpm.d /etc/php[0-9]* /etc/opt/remi \
    227   /usr/local/etc/php /usr/local/etc/php-fpm.conf /usr/local/etc/php-fpm.d \
    228   /opt/cpanel/ea-php*/root/etc /opt/plesk/php/*/etc /opt/alt/php*/etc /usr/local/lsws/lsphp*/etc \
    229   /etc/apache2 /etc/httpd; do
    230   [ -e "$d" ] || continue
    231   # The setting name must stand alone: "max_memory_limit" or a commented-out line does not count.
    232   # Templates and package manager leftovers (php.ini-production, *.default, *.rpmnew, *.dpkg-dist ...) are never loaded.
    233   hits=$(grep -rIsnHE '^[[:space:]]*([^;#[:space:]][^;#]*[^a-z_.;#])?(memory_limit|session\.upload_progress\.enabled|session\.auto_start|fatal_error_backtraces|max_input_nesting_level)([^a-z_]|$)' "$d" 2>/dev/null |
    234     grep -vE '^[^:]*\.(ini-development|ini-production|default|dist|rpmnew|rpmsave|dpkg-dist|dpkg-old|dpkg-new|ucf-dist|ucf-old):[0-9]+:')
    235   if [ -n "$hits" ]; then CONF_HITS="$CONF_HITS$hits$NL"; fi
    236 done
    237 if [ -n "$CONF_HITS" ]; then
    238   printf '%s' "$CONF_HITS" | head -n 60 | safe | sed 's/^/  /'
    239   echo "  (Each file applies to different targets. Use the PHP probe to see the values actually in effect.)"
    240 else
    241   echo "  None found in the usual configuration directories. The defaults may be in effect, or the settings are made"
    242   echo "  elsewhere (a hosting panel, .user.ini, a pool file in another place). The PHP probe shows the values in effect."
    243 fi
    244 
    245 # ---------------------------------------------------------------- Web server account processes
    246 section "Other processes running as the web server account"
    247 # A process counts as the web server only when its executable is a known server binary in a system directory,
    248 # owned by root. Names and arguments alone are not trusted: a miner started as "/tmp/.x/httpd" or with
    249 # "php-fpm" in its arguments must still be listed.
    250 server_name() {
    251   case "$1" in php-fpm*|php5-fpm*|php-cgi*|php5-cgi*|lsphp*|apache2|httpd|httpd.*|nginx|frankenphp|lighttpd) return 0 ;; esac
    252   return 1
    253 }
    254 system_path() {
    255   case "$1" in /usr/*|/bin/*|/sbin/*|/opt/*) return 0 ;; esac
    256   return 1
    257 }
    258 # A genuine PHP binary that runs a script named on its command line (php-cgi -f x.php, frankenphp php-cli x.php)
    259 # is not serving web requests. $1 = binary name, $2 = arguments.
    260 runs_script() {
    261   case "$1" in php-cgi*|php5-cgi*|lsphp*|frankenphp) ;; *) return 1 ;; esac
    262   case " $2 " in
    263     *" -f "*|*" -r "*|*" -a "*|*" php-cli "*|*".php "*|*".phar "*|*".phtml "*|*".inc "*) return 0 ;;
    264   esac
    265   return 1
    266 }
    267 WEBUSER=unknown
    268 if ! have ps; then
    269   echo "  Could not check: ps is not installed."
    270 else
    271   PS_OUT=$(ps -eo user=,pid=,ppid=,args= 2>/dev/null)
    272   OTHERS=$(printf '%s\n' "$PS_OUT" | awk -v me="$(id -un 2>/dev/null)" 'NF && $1 != me { n++ } END { print n + 0 }')
    273   if [ -z "$PS_OUT" ]; then
    274     echo "  Could not check: ps listed no processes."
    275   elif [ "$IS_ROOT" = 0 ] && [ "$OTHERS" = 0 ]; then
    276     echo "  Could not check: other accounts' processes are hidden from this account (hidepid). Run again as root."
    277   else
    278     # Leave out this script, the processes it starts and the shells it was started from.
    279     CANDIDATES=$(printf '%s\n' "$PS_OUT" | awk -v self="$$" '
    280       NF >= 3 { n++; u[n] = $1; p[n] = $2; pp[$2] = $3; a = $0
    281         sub(/^[ \t]*[^ \t]+[ \t]+[^ \t]+[ \t]+[^ \t]+[ \t]*/, "", a); args[n] = a }
    282       END {
    283         x = self
    284         while (x != "" && !(x in skip)) { skip[x] = 1; x = pp[x] }
    285         mine[self] = 1
    286         do { c = 0; for (i = 1; i <= n; i++) if (!(p[i] in mine) && (pp[p[i]] in mine)) { mine[p[i]] = 1; c = 1 } } while (c)
    287         for (i = 1; i <= n; i++)
    288           if (!(p[i] in skip) && !(p[i] in mine) && u[i] ~ /^(www-data|apache|nginx|http|wwwrun|nobody|lighttpd)$/)
    289             print u[i], p[i], args[i]
    290       }')
    291     RES=$(printf '%s\n' "$CANDIDATES" | while read -r user pid args; do
    292       [ -n "$pid" ] || continue
    293       # busybox ps puts "{name} " in front of the arguments when the first argument is not the process name.
    294       cmd=$args
    295       # Patterns inside $(...) carry a leading "(": bash before 4.4 otherwise ends the substitution at ")".
    296       case "$cmd" in ("{"*"} "*) cmd=${cmd#*"} "} ;; esac
    297       exe=$(readlink "/proc/$pid/exe" 2>/dev/null)
    298       if [ -n "$exe" ]; then
    299         path=${exe% (deleted)}
    300         if server_name "${path##*/}" && system_path "$path" && ! runs_script "${path##*/}" "$cmd" &&
    301           [ -n "$(find -H "/proc/$pid/exe" -prune -user 0 2>/dev/null)" ]; then
    302           continue
    303         fi
    304       else
    305         # The executable of another account's process is readable only as root (in a container, with
    306         # CAP_SYS_PTRACE). Fall back to the kernel's process name and the first argument.
    307         comm=$(cat "/proc/$pid/comm" 2>/dev/null)
    308         argv0=${cmd%% *}
    309         by_name=0
    310         if server_name "$comm" && ! runs_script "$comm" "$cmd"; then
    311           case "$argv0" in
    312             (/*) if system_path "$argv0" && server_name "${argv0##*/}"; then by_name=1; fi ;;
    313             (*/*) ;;
    314             (*) by_name=1 ;;
    315           esac
    316         fi
    317         if [ "$by_name" = 1 ]; then echo N; continue; fi
    318         exe="(not readable)"
    319       fi
    320       printf 'O %s %s exe=%s args=%s\n' "$user" "$pid" "$exe" "$args"
    321     done)
    322     ODD=$(printf '%s\n' "$RES" | sed -n 's/^O //p')
    323     WEBUSER=$(printf '%s\n' "$ODD" | grep -c .)
    324     BY_NAME=$(printf '%s\n' "$RES" | grep -c '^N$')
    325     if [ "$WEBUSER" -gt 0 ]; then
    326       printf '%s\n' "$ODD" | head -n 20 | safe | cut -c1-240 | sed 's/^/  /'
    327       if [ "$WEBUSER" -gt 20 ]; then echo "  ... and $((WEBUSER - 20)) more"; fi
    328       echo "  Check what these processes are. If a shell, download tool or coin miner is running as the web server account, suspect a compromise."
    329     else
    330       echo "  None"
    331     fi
    332     if [ "$BY_NAME" -gt 0 ]; then
    333       echo "  (${BY_NAME} web server process(es) were accepted by name only, because their executable could not be read."
    334       echo "  Run as root on the host to check the executables too.)"
    335     fi
    336   fi
    337 fi
    338 add "webuser_proc|$WEBUSER"
    339 
    340 # ---------------------------------------------------------------- Logs (--logs)
    341 LOGS_READ=0
    342 LOGS_UNREAD=0
    343 LOG_DIRS_SEEN=$NL
    344 OOM_TOTAL=0
    345 CRASH_TOTAL=0
    346 # Succeeds when the files in directory $1 should be read: it exists, is readable and was not read under another name.
    347 log_dir() {
    348   [ -d "$1" ] || return 1
    349   if [ ! -r "$1" ] || [ ! -x "$1" ]; then
    350     echo "  $1: directory not readable"
    351     LOGS_UNREAD=$((LOGS_UNREAD + 1))
    352     return 1
    353   fi
    354   real=$(cd "$1" 2>/dev/null && pwd -P) || return 1
    355   case "$LOG_DIRS_SEEN" in *"$NL$real$NL"*) return 1 ;; esac
    356   LOG_DIRS_SEEN="$LOG_DIRS_SEEN$real$NL"
    357   return 0
    358 }
    359 # Prints "<memory exhaustion count> <crash count>", then the last two matching lines.
    360 log_counts() {
    361   LC_ALL=C awk '
    362     /exhausted.*in Unknown on line 0/ { o++; hit = 1 }
    363     /SIGSEGV|signal 11|Segmentation fault/ { c++; hit = 1 }
    364     hit { last1 = last2; last2 = $0; hit = 0 }
    365     END { print o + 0, c + 0; if (last1 != "") print last1; if (last2 != "") print last2 }'
    366 }
    367 scan_log() {
    368   [ -f "$1" ] || return 0
    369   case "$1" in
    370     *.gz) reader=gzip ;;
    371     *.xz) reader=xz ;;
    372     *.bz2) reader=bzip2 ;;
    373     *.zst) reader=zstd ;;
    374     *) reader="cat" ;;
    375   esac
    376   res=""
    377   if [ -r "$1" ] && have "$reader"; then
    378     if [ "$reader" = cat ]; then
    379       res=$(log_counts 2>/dev/null <"$1")
    380     else
    381       res=$("$reader" -dc 2>/dev/null <"$1" | log_counts)
    382     fi
    383   fi
    384   if [ -z "$res" ]; then
    385     printf '  %s: not readable\n' "$1" | safe
    386     LOGS_UNREAD=$((LOGS_UNREAD + 1))
    387     return 0
    388   fi
    389   LOGS_READ=$((LOGS_READ + 1))
    390   counts=$(printf '%s\n' "$res" | head -n 1)
    391   oom=${counts% *}
    392   crash=${counts#* }
    393   if [ "$oom" -gt 0 ] || [ "$crash" -gt 0 ]; then
    394     printf '  %s: %s memory exhaustion error(s) before the script ran, %s crash(es)\n' "$1" "$oom" "$crash" | safe
    395     printf '%s\n' "$res" | sed '1d' | safe | cut -c1-300 | sed 's/^/      /'
    396   fi
    397   OOM_TOTAL=$((OOM_TOTAL + oom))
    398   CRASH_TOTAL=$((CRASH_TOTAL + crash))
    399 }
    400 
    401 if [ "$DO_LOGS" = 1 ]; then
    402   section "Suspicious log entries"
    403   # Web server error logs, including per-site and TLS logs (ssl_error_log, example.com-error.log) and panel paths.
    404   for d in /var/log/apache2 /var/log/httpd /var/log/nginx /var/log/lighttpd /usr/local/apache/logs /etc/apache2/logs \
    405     /usr/local/apache2/logs /usr/local/lsws/logs /var/www/vhosts/system/*/logs; do
    406     if log_dir "$d"; then
    407       for f in "$d"/*error*; do scan_log "$f"; done
    408     fi
    409   done
    410   # PHP and PHP-FPM logs: /var/log/php-fpm/, /var/log/php83/ (Alpine), panel PHP-FPM directories, /var/log/php8.2-fpm.log.
    411   for d in /var/log/php* /var/log/plesk-php* /opt/cpanel/ea-php*/root/usr/var/log/php-fpm; do
    412     if log_dir "$d"; then
    413       for f in "$d"/*log*; do scan_log "$f"; done
    414     fi
    415   done
    416   for f in /var/log/php*.log*; do scan_log "$f"; done
    417   if [ "$LOGS_READ" = 0 ]; then
    418     echo "  No web server or PHP log file could be read in the usual places. Logs may go to the journal, to a"
    419     echo "  hosting panel's own directory or, in a container, to stderr."
    420   fi
    421   FILES_INCOMPLETE=0
    422   if [ "$LOGS_READ" = 0 ] || [ "$LOGS_UNREAD" -gt 0 ]; then FILES_INCOMPLETE=1; fi
    423 
    424   KERNEL_READ=0
    425   KCOUNT=0
    426   if KLOG=$(dmesg 2>/dev/null); then
    427     KERNEL_READ=1
    428   elif have journalctl && KLOG=$(journalctl -k -q --no-pager 2>/dev/null) && [ -n "$KLOG" ]; then
    429     KERNEL_READ=1
    430   fi
    431   if [ "$KERNEL_READ" = 1 ]; then
    432     KERNEL=$(printf '%s\n' "$KLOG" | grep 'segfault' | grep -E 'php|apache2|httpd')
    433     if [ -n "$KERNEL" ]; then
    434       KCOUNT=$(printf '%s\n' "$KERNEL" | wc -l | tr -d ' ')
    435       echo "  ${KCOUNT} PHP/Apache segfault(s) in the kernel log"
    436       printf '%s\n' "$KERNEL" | tail -n 3 | safe | cut -c1-300 | sed 's/^/      /'
    437     fi
    438   elif [ "$IS_ROOT" = 1 ]; then
    439     echo "  Kernel log: not readable (inside a container the kernel log is usually not available)."
    440   else
    441     echo "  Kernel log: not readable (run as root)."
    442   fi
    443   CRASH_INCOMPLETE=$FILES_INCOMPLETE
    444   if [ "$KERNEL_READ" = 0 ]; then CRASH_INCOMPLETE=1; fi
    445 
    446   BIG=0
    447   SESS_INCOMPLETE=0
    448   for d in /var/lib/php/sessions /var/lib/php/session /var/lib/php5 /var/lib/php /tmp; do
    449     [ -d "$d" ] || continue
    450     if [ ! -r "$d" ] || [ ! -x "$d" ]; then
    451       echo "  $d: session directory not readable"
    452       SESS_INCOMPLETE=1
    453       continue
    454     fi
    455     n=$(find "$d" -maxdepth 1 -name 'sess_*' -size +1048576c 2>/dev/null | wc -l | tr -d ' ')
    456     if [ "$n" -gt 0 ]; then echo "  $d: ${n} session file(s) over 1 MB"; fi
    457     BIG=$((BIG + n))
    458   done
    459 
    460   OOM_OUT=$(count_or_unknown "$OOM_TOTAL" "$FILES_INCOMPLETE")
    461   CRASH_OUT=$(count_or_unknown "$((CRASH_TOTAL + KCOUNT))" "$CRASH_INCOMPLETE")
    462   BIG_OUT=$(count_or_unknown "$BIG" "$SESS_INCOMPLETE")
    463   echo "  Log files read: ${LOGS_READ}. Log files or directories that could not be read: ${LOGS_UNREAD}."
    464   echo "  Total: memory exhaustion errors ${OOM_OUT}, crashes ${CRASH_OUT}, large session files ${BIG_OUT}"
    465   case "$OOM_OUT $CRASH_OUT $BIG_OUT" in
    466     *unknown*) echo "  (unknown: a source for that count was not found or could not be read, so a 0 would not be reliable.)" ;;
    467   esac
    468   echo "  (These entries can also appear in normal operation. If they repeat right after unusual multipart requests, suspect an attack.)"
    469   add "log|oom|$OOM_OUT"
    470   add "log|crash|$CRASH_OUT"
    471   add "log|bigsession|$BIG_OUT"
    472 fi
    473 
    474 # ---------------------------------------------------------------- Recently changed PHP files (--webroot)
    475 if [ -n "$WEBROOTS" ]; then
    476   section "PHP files changed in the last 3 days"
    477   RECENT=0
    478   WR_INCOMPLETE=0
    479   OLD_IFS=$IFS
    480   IFS=$NL
    481   set -f
    482   for dir in $WEBROOTS; do
    483     [ -n "$dir" ] || continue
    484     if [ ! -e "$dir" ]; then
    485       echo "  $dir: not found. Check the path."
    486       WR_INCOMPLETE=1
    487       continue
    488     elif [ ! -d "$dir" ]; then
    489       echo "  $dir: not a directory."
    490       WR_INCOMPLETE=1
    491       continue
    492     elif [ ! -r "$dir" ] || [ ! -x "$dir" ]; then
    493       echo "  $dir: not readable (run as root)."
    494       WR_INCOMPLETE=1
    495       continue
    496     fi
    497     # find -H follows the web root itself when it is a symbolic link (current -> releases/20261020).
    498     # Symbolic links to directories inside it (a shared uploads/) are scanned too, one level deep,
    499     # unless they point back into the web root or above it.
    500     top=$(cd "$dir" 2>/dev/null && pwd -P)
    501     prefix=${top%/}/
    502     roots=$dir
    503     seen=$NL$top$NL
    504     linked=0
    505     links=$(find -H "$dir" -type l 2>/dev/null)
    506     for l in $links; do
    507       [ -d "$l" ] || continue
    508       real=$(cd "$l" 2>/dev/null && pwd -P) || continue
    509       case "$real/" in "$prefix"*) continue ;; esac
    510       case "$prefix" in "${real%/}/"*) continue ;; esac
    511       case "$real" in /proc|/proc/*|/sys|/sys/*|/dev|/dev/*) continue ;; esac
    512       case "$seen" in *"$NL$real$NL"*) continue ;; esac
    513       seen=$seen$real$NL
    514       roots=$roots$NL$l
    515       linked=$((linked + 1))
    516     done
    517     files=""
    518     partial=0
    519     for r in $roots; do
    520       out=$(find -H "$r" -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \) -mtime -3 2>/dev/null) || partial=1
    521       if [ -n "$out" ]; then files=$files$out$NL; fi
    522     done
    523     n=$(printf '%s' "$files" | grep -c .)
    524     note=""
    525     if [ "$linked" -gt 0 ]; then note=" (including ${linked} linked directory(ies) outside it)"; fi
    526     echo "  $dir: ${n} file(s)$note"
    527     printf '%s' "$files" | head -n 30 | safe | sed 's/^/      /'
    528     if [ "$partial" = 1 ]; then
    529       echo "      Some subdirectories could not be read, so files may be missing from this list (run as root)."
    530       WR_INCOMPLETE=1
    531     fi
    532     RECENT=$((RECENT + n))
    533   done
    534   set +f
    535   IFS=$OLD_IFS
    536   echo "  (Files changed by deployments are listed too. Check the contents of any file you do not recognise.)"
    537   add "webroot|recent_php|$(count_or_unknown "$RECENT" "$WR_INCOMPLETE")"
    538 fi
    539 
    540 # ---------------------------------------------------------------- Result block
    541 echo
    542 echo "Paste the block below into the check page. It holds OS release fields, package names and versions, process names, statuses and counts. No log lines and no file paths."
    543 echo "-----BEGIN PHP2SHELL SERVERCHECK-----"
    544 printf '%s' "$MACHINE" | sed '/^$/d' | safe
    545 echo "-----END PHP2SHELL SERVERCHECK-----"