#!/usr/bin/env bash # php2shell-check.sh - read-only exposure check for PHP2Shell # (CVE-2026-109618 / CVE-2026-109619). # # Published by NOC.org: https://noc.org/articles/php2shell-what-to-do-when-you-cant-just-upgrade # Download: curl -fsSL -o php2shell-check.sh https://noc.org/articles/files/php2shell-check.txt # # What it does: # 1. Looks for PHP in the usual places (distro packages, /usr/local, /opt, # cPanel EA-PHP, Plesk, LiteSpeed, Remi) and, as root, in every running # Apache / PHP-FPM / CGI / lsphp process. # 2. Decides per install: fixed upstream, fixed by a distro backport (both CVEs # named in the changelog of the installed revision), or not fixed. # 3. Checks that running processes are not still using replaced PHP code (root). # 4. Reports the SAPI (mod_php prefork is the worst case), the related PHP # settings, and whether 80/443 listen on non-loopback addresses. # 5. Optional --probe: sends a GET and two POSTs (multipart, chunked) to a URL # you choose, to see whether something in front of PHP rejects them. These # are real requests to your application. # # It installs nothing and changes no configuration. It runs PHP binaries it finds # with -v (with a timeout and no stdin) to read their versions, but only binaries # that are root-owned in root-owned, non-writable directories; anything else is # reported, not executed. Output includes the hostname and listening addresses: # redact them before sharing. # # Usage: # sudo bash php2shell-check.sh # sudo bash php2shell-check.sh --probe http://127.0.0.1/ --host www.example.com # # Exit codes: 0 = no unfixed PHP among those found, 1 = unfixed PHP found, # 2 = could not assess everything, 64 = bad arguments. main() { set -u PROBE_URL="" PROBE_HOST="" while [ $# -gt 0 ]; do case "$1" in --probe) case "${2:-}" in ''|-*) PROBE_URL="http://127.0.0.1/" ;; *) PROBE_URL="$2"; shift ;; esac ;; --host) case "${2:-}" in ''|-*) echo "--host needs a hostname" >&2; return 64 ;; *) PROBE_HOST="$2"; shift ;; esac ;; -h|--help) echo "usage: php2shell-check.sh [--probe [URL]] [--host HOSTNAME]"; return 0 ;; *) echo "unknown option: $1 (try --help)" >&2; return 64 ;; esac shift done if [ -t 1 ]; then R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; B=$'\e[1m'; N=$'\e[0m'; else R= G= Y= B= N=; fi FAILS=0; WARNS=0; UNKNOWN=0; FOUND=0 ok() { printf ' %s[ OK ]%s %s\n' "$G" "$N" "$(clean "$*")"; } bad() { printf ' %s[FAIL]%s %s\n' "$R" "$N" "$(clean "$*")"; FAILS=$((FAILS+1)); } warn() { printf ' %s[WARN]%s %s\n' "$Y" "$N" "$(clean "$*")"; WARNS=$((WARNS+1)); } unk() { printf ' %s[ ?? ]%s %s\n' "$Y" "$N" "$(clean "$*")"; UNKNOWN=$((UNKNOWN+1)); } info() { printf ' %s\n' "$(clean "$*")"; } hdr() { printf '\n%s== %s%s\n' "$B" "$*" "$N"; } have() { command -v "$1" >/dev/null 2>&1; } # every run of a discovered binary gets a timeout and no stdin (phpdbg and friends wait on stdin) to() { if have timeout; then timeout 10 "$@" /dev/null) || return 1 m=$(stat -c '%a' "$p" 2>/dev/null) || return 1 [ "$o" = 0 ] || return 1 [ $(( 8#$m & 022 )) -eq 0 ] || return 1 [ "$p" = / ] && return 0 p=$(dirname "$p") done } # strip control characters from anything read off the box before printing it clean() { printf '%s' "$*" | tr -cd '[:print:]'; } version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } gunz() { if have gzip; then gzip -dc "$1" 2>/dev/null; elif have zcat; then zcat "$1" 2>/dev/null; else return 1; fi; } IS_ROOT=0; [ "$(id -u)" -eq 0 ] && IS_ROOT=1 # First fixed upstream release per supported branch. fixed_for_branch() { case "$1" in 8.5) echo 8.5.12 ;; 8.4) echo 8.4.27 ;; 8.3) echo 8.3.36 ;; 8.2) echo 8.2.35 ;; *) echo "" ;; esac } owning_pkg() { local f; f=$(readlink -f "$1" 2>/dev/null || echo "$1") if have dpkg-query; then dpkg-query -S "$f" 2>/dev/null | head -1 | cut -d: -f1 elif have rpm; then rpm -qf "$f" 2>/dev/null | grep -v 'not owned' | head -1 fi } pkg_version() { if have dpkg-query; then dpkg-query -W -f '${Version}' "$1" 2>/dev/null elif have rpm; then rpm -q --qf '%{VERSION}-%{RELEASE}' "$1" 2>/dev/null; fi } # changelog_status PKG -> prints both | one | none | unreadable # Debian/Ubuntu: the changelog's newest entry must be the INSTALLED revision. # /usr/share/doc/ is often a symlink to the -common package, which can be # newer than this package after a partial upgrade. changelog_status() { local pkg="$1" doc text pv n=0 [ -n "$pkg" ] || { echo unreadable; return; } if have dpkg-query; then pv=$(pkg_version "$pkg") for doc in /usr/share/doc/"$pkg"/changelog.Debian.gz /usr/share/doc/"$pkg"/changelog.gz; do [ -r "$doc" ] || continue text=$(gunz "$doc") || continue if ! printf '%s\n' "$text" | head -1 | grep -qF "($pv)"; then echo none; return # changelog belongs to a different revision: no proof fi break done [ -n "${text:-}" ] || { echo unreadable; return; } elif have rpm; then text=$(rpm -q --changelog "$pkg" 2>/dev/null) [ -n "$text" ] || { echo unreadable; return; } else echo unreadable; return fi printf '%s\n' "$text" | grep -q 'CVE-2026-109618' && n=$((n+1)) printf '%s\n' "$text" | grep -q 'CVE-2026-109619' && n=$((n+1)) case $n in 2) echo both ;; 1) echo one ;; *) echo none ;; esac } # assess LABEL PHP_VERSION OWNING_PACKAGE assess() { local label="$1" ver="$2" pkg="$3" branch fixed pv="" cl FOUND=$((FOUND+1)) if [ -z "$ver" ]; then unk "$label: could not read the PHP version"; return; fi branch=$(echo "$ver" | cut -d. -f1,2) fixed=$(fixed_for_branch "$branch") [ -n "$pkg" ] && pv=" (package $pkg $(pkg_version "$pkg"))" if [ -n "$fixed" ] && version_ge "$ver" "$fixed"; then ok "$label: PHP $ver, fixed upstream (>= $fixed)$pv"; return fi if [ -z "$fixed" ] && version_ge "$branch" 8.6; then ok "$label: PHP $ver, branch newer than the affected ones$pv"; return fi if [ -z "$pkg" ]; then if [ -n "$fixed" ]; then bad "$label: PHP $ver (source build) is NOT fixed, update to $fixed or later" else bad "$label: PHP $ver (source build), branch $branch is end of life upstream: not fixed"; fi return fi cl=$(changelog_status "$pkg") case "$cl" in both) ok "$label: PHP $ver, both CVEs named in the changelog of the installed package (distro backport)$pv" ;; one) warn "$label: PHP $ver, the installed package's changelog names only ONE of the two CVEs. Treat as not fixed$pv"; FAILS=$((FAILS+1)) ;; unreadable) unk "$label: PHP $ver, no readable package changelog to look for a backport$pv. Ask your vendor." ;; *) if [ -n "$fixed" ]; then bad "$label: PHP $ver is NOT fixed, update to $fixed or later$pv" else bad "$label: PHP $ver, branch $branch is end of life upstream and the installed package shows no backport$pv"; fi ;; esac } # -------------------------------------------------------------------------- hdr "System" OS_NAME="unknown"; OS_ID=""; OS_VER="" if [ -r /etc/os-release ]; then OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-unknown}") OS_ID=$(. /etc/os-release; echo "${ID:-}") OS_VER=$(. /etc/os-release; echo "${VERSION_ID:-}") fi info "Host: $(uname -n) OS: $OS_NAME Kernel: $(uname -r)" if [ "$OS_ID" = ubuntu ] && [ -n "$OS_VER" ]; then case "$OS_VER" in 22.04|24.04|26.04) ;; *) info "Ubuntu $OS_VER: check that this release still gets security updates for PHP (standard support or ESM). If not, see the article for options." ;; esac fi [ "$IS_ROOT" -eq 1 ] || warn "Not running as root: running processes are not inspected and some files cannot be read. Results are incomplete." # -------------------------------------------------------------------------- hdr "PHP installations" CANDS=$( { ls -1d /usr/bin/php /usr/bin/php[0-9]* /usr/bin/php-cgi* /usr/sbin/php-fpm* \ /usr/local/bin/php /usr/local/bin/php-cgi /usr/local/sbin/php-fpm \ /opt/*/bin/php /opt/*/bin/php-cgi /opt/*/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 \ /usr/local/lsws/lsphp*/bin/lsphp /usr/bin/lsphp 2>/dev/null if [ "$IS_ROOT" -eq 1 ]; then for p in /proc/[0-9]*; do c=$(cat "$p/comm" 2>/dev/null) || continue case "$c" in php-fpm*|php-cgi*|lsphp*|php) readlink "$p/exe" 2>/dev/null | sed 's/ (deleted)$//' ;; esac done fi } | while IFS= read -r b; do # Allowlist on the RESOLVED path: process names can be spoofed, so only # absolute paths to binaries actually named like PHP get through. r=$(readlink -f "$b" 2>/dev/null) || continue case "$r" in /*) ;; *) continue ;; esac case "$r" in *[\*\?\[]*) continue ;; esac case "${r##*/}" in php|php[0-9]*|php-cgi*|php-fpm*|lsphp*) ;; *) continue ;; esac case "${r##*/}" in *dbg*|*config*|*ize*) continue ;; esac echo "$r" done | sort -u ) while IFS= read -r b; do [ -n "$b" ] && [ -x "$b" ] || continue pkg=$(owning_pkg "$b") if trusted_path "$b"; then ver=$(to "$b" -v 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) elif [ -n "$pkg" ]; then info "$b: not executed (untrusted path); version taken from package metadata" ver=$(pkg_version "$pkg" | sed -E 's/^[0-9]+://; s/[-+~].*//') else unk "$b: not root-owned (or in a writable directory), so it was not executed. Check it by hand." continue fi assess "$b" "$ver" "$pkg" done </dev/null \ | awk '{print $3}' | while read -r m; do case "$m" in /*) echo "$m" ;; *) for root in /usr/lib/apache2 /etc/httpd /usr/local/apache2 /usr/local/apache; do [ -e "$root/$m" ] && { echo "$root/$m"; break; } done ;; esac done if [ "$IS_ROOT" -eq 1 ]; then for p in /proc/[0-9]*; do c=$(cat "$p/comm" 2>/dev/null) || continue case "$c" in apache2|httpd) grep -oE '/[^ ]*libphp[0-9.]*\.so' "$p/maps" 2>/dev/null ;; esac done fi } | sort -u ) while IFS= read -r m; do [ -n "$m" ] || continue [ -e "$m" ] || { warn "mod_php is configured but $m does not exist (Apache will fail to start)"; continue; } ver=$(grep -aoE 'X-Powered-By: PHP/[0-9]+\.[0-9]+\.[0-9]+' "$m" 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+$') pkg=$(owning_pkg "$m") if [ -z "$ver" ] && [ -n "$pkg" ]; then ver=$(pkg_version "$pkg" | sed -E 's/^[0-9]+://; s/[-+~].*//'); fi assess "mod_php $m" "$ver" "$pkg" done </dev/null) if ! printf '%s\n' "$MODLIST" | grep -q '_module'; then unk "Could not list Apache modules with $CTL -M (permissions or a config error)." else MPM=$(printf '%s\n' "$MODLIST" | grep -oE 'mpm_[a-z]+' | head -1) if printf '%s\n' "$MODLIST" | grep -qE 'php[0-9]*_module'; then [ -n "$MODS" ] || unk "Apache has a PHP module loaded but this script could not locate its file to assess it." if [ "$MPM" = "mpm_prefork" ]; then warn "Apache mod_php with prefork: the most exposed setup for this bug. Moving to PHP-FPM removes the worst path." else warn "Apache mod_php is loaded (${MPM:-unknown MPM})." fi else info "Apache is not running PHP in-process (no mod_php loaded)." fi fi fi NFPM=$(ps -eo comm= 2>/dev/null | grep -cE '^php-fpm') [ "$NFPM" -gt 0 ] && info "PHP-FPM processes running: $NFPM (a failed request startup ends the child, a smaller blast radius than mod_php)" ps -eo comm= 2>/dev/null | grep -qE '^(lsphp|litespeed)' && info "LiteSpeed/lsphp detected: confirm with your vendor that the lsphp build is fixed." # -------------------------------------------------------------------------- hdr "Running processes still on the old PHP" if [ "$IS_ROOT" -ne 1 ]; then info "skipped (needs root)" else STALE=0 for p in /proc/[0-9]*; do c=$(cat "$p/comm" 2>/dev/null) || continue case "$c" in apache2|httpd) grep -qE 'libphp[0-9.]*\.so.*\(deleted\)' "$p/maps" 2>/dev/null && STALE=$((STALE+1)) ;; php-fpm*|php-cgi*|lsphp*) if readlink "$p/exe" 2>/dev/null | grep -q '(deleted)' \ || grep -qE '/php[^ ]*\.so.*\(deleted\)' "$p/maps" 2>/dev/null; then STALE=$((STALE+1)); fi ;; esac done if [ "$STALE" -gt 0 ]; then bad "$STALE web/PHP processes are still running PHP code that was replaced on disk. Restart Apache and every PHP-FPM service (a graceful reload is not enough for mod_php)." else ok "No running web/PHP process is using replaced PHP code." fi fi # -------------------------------------------------------------------------- hdr "Related PHP settings (information)" for ini in /etc/php/*/apache2/php.ini /etc/php/*/fpm/php.ini /etc/php/*/cgi/php.ini /etc/php.ini \ /usr/local/etc/php.ini /usr/local/lib/php.ini /opt/*/etc/php.ini /opt/*/lib/php.ini; do [ -r "$ini" ] || continue dir=$(dirname "$ini") vals=$(cat "$ini" "$dir"/conf.d/*.ini "$dir"/php.d/*.ini 2>/dev/null \ | grep -E '^\s*(session\.upload_progress\.enabled|memory_limit|post_max_size)\s*=' \ | sed -E 's/;.*//; s/\s+//g' | awk -F= '{v[$1]=$2} END{for(k in v) printf "%s=%s ", k, v[k]}') info "$ini: ${vals:-defaults (session.upload_progress.enabled=On)}" done APC=$(cat /etc/apache2/apache2.conf /etc/apache2/conf-enabled/*.conf /etc/apache2/sites-enabled/* \ /etc/apache2/mods-enabled/*.conf /etc/httpd/conf/httpd.conf /etc/httpd/conf.d/*.conf 2>/dev/null \ | grep -iE '^\s*php_admin_(flag|value)\s+session\.upload_progress\.enabled\s+(0|off)' | head -1) [ -n "$APC" ] && info "Apache config sets session.upload_progress.enabled off (only affects mod_php; partial, CVE-2026-109619 only)." info "Not read: .htaccess php_value lines and FPM pool overrides. Setting upload_progress in .user.ini does not protect you." # -------------------------------------------------------------------------- hdr "Exposure" LST="" if have ss; then LST=$(ss -tln 2>/dev/null | awk 'NR>1 {print $4}') elif have netstat; then LST=$(netstat -tln 2>/dev/null | awk 'NR>2 {print $4}') fi if [ -z "$LST" ]; then info "Could not list listening sockets (no ss or netstat)." else PUB=$(printf '%s\n' "$LST" | grep -E ':(80|443)$' | grep -vE '^(127\.|\[::1\]|::1)' | sort -u | tr '\n' ' ') if [ -n "$PUB" ]; then info "80/443 listening on non-loopback addresses: $PUB" info "If this server sits behind a WAF/CDN, allow 80/443 only from the provider's edge IPs, for every hostname on the box." else info "80/443 not listening on any non-loopback address." fi fi SEG=$(grep -hsc 'exit signal Segmentation fault' /var/log/apache2/error.log /var/log/httpd/error_log 2>/dev/null | awk '{s+=$1} END{print s+0}') if [ "${SEG:-0}" -gt 0 ]; then warn "$SEG 'Segmentation fault' child exits in the current Apache error log. Clusters of these can be failed exploit attempts; line them up with access log timestamps." fi # -------------------------------------------------------------------------- if [ -n "$PROBE_URL" ]; then hdr "Probe: is anything rejecting the exploit's request shape? ($PROBE_URL)" if ! have curl; then unk "curl not installed, probe skipped" else code() { if [ -n "$PROBE_HOST" ]; then curl -sk -o /dev/null -m 15 -w '%{http_code}' -H "Host: $PROBE_HOST" "$@" "$PROBE_URL" else curl -sk -o /dev/null -m 15 -w '%{http_code}' "$@" "$PROBE_URL" fi } base=$(code) mp=$(code -F 'probe=1') ch=$(code -H 'Transfer-Encoding: chunked' --data-binary 'probe=1') info "GET: $base multipart POST: $mp chunked POST: $ch" case "$base" in 2??|3??) case "$mp" in 400|403|406|413) [ "$mp" != "$base" ] && ok "multipart POST is rejected ($mp)" ;; *) warn "multipart POST was accepted ($mp). Fine if PHP is fixed; on an unfixed PHP this is the entry point." ;; esac case "$ch" in 400|403|406|411|413) [ "$ch" != "$base" ] && ok "chunked POST without Content-Length is rejected ($ch)" ;; *) warn "chunked POST was accepted ($ch)." ;; esac info "A rejection may come from a WAF, the web server or the application itself; check which before relying on it." ;; *) unk "The plain GET returned '$base', so the POST results cannot be interpreted. Check the URL and --host." ;; esac info "After an Apache reload, wait about 15 seconds before probing: old workers keep serving until they recycle." fi fi # -------------------------------------------------------------------------- hdr "Result" if [ "$FAILS" -gt 0 ]; then echo " ${R}${B}ACTION NEEDED${N}: $FAILS failing check(s), $WARNS warning(s), $UNKNOWN unknown." info "If you have applied a mitigation instead of a fixed PHP, this will keep failing: you are mitigated, not fixed." return 1 elif [ "$UNKNOWN" -gt 0 ] || [ "$FOUND" -eq 0 ]; then echo " ${Y}${B}INCONCLUSIVE${N}: $UNKNOWN item(s) could not be assessed, $WARNS warning(s)." return 2 else echo " ${G}${B}NO UNFIXED PHP AMONG THOSE FOUND${N}: $FOUND install(s) checked, $WARNS warning(s) to review." return 0 fi } main "$@" exit $?