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-----"