#!/bin/bash
# PHP2Shell, Steps 1-3 of https://ariksavage.com/development/guides/php2shell
# (php.ini settings, Apache request limit, multipart log), scripted after doing it
# by hand on a staging server. Written for CentOS 7, Apache 2.4.6 run by supervisord
# as [program:1.apache]. Read it and adapt it before running it anywhere else.
# Run as root on the box:  bash phase-c.sh
# Safe to re-run. Restarts 1.apache once (supervisord). Nothing restarts if configtest fails.
set -u
TS=$(date +%Y%m%d%H%M)
CONF=/etc/httpd/conf.d
DROPIN=$CONF/00-php2shell.conf
H=/etc/httpd/conf/httpd.conf
LOGLINE='   CustomLog logs/php2shell-multipart.log php2shell env=php2shell_multipart'
fail() { echo "FAILED: $*"; exit 1; }

[ "$(id -u)" = 0 ] || fail "run as root"
grep -q '^\[program:1\.apache\]' /etc/supervisord.conf || fail "no [program:1.apache] in /etc/supervisord.conf; do this box by hand"
php --ini | grep -q '/etc/php.d' || fail "PHP does not scan /etc/php.d"
echo "### $(hostname -s) Phase C"

echo "== C1 backup"
tar czf /root/pre-php2shell-$TS.tgz /etc/php.ini /etc/php.d /etc/httpd/conf /etc/httpd/conf.d /etc/supervisord.conf 2>/dev/null
ls -l /root/pre-php2shell-$TS.tgz || fail "backup"

# LimitRequestBody = post_max_size: bodies PHP would refuse anyway are refused before mod_php parses them.
PMS=$(php -r 'echo ini_get("post_max_size");')
case "$PMS" in
  *[Gg]) LRB=$(( ${PMS%[Gg]} * 1073741824 ));;
  *[Mm]) LRB=$(( ${PMS%[Mm]} * 1048576 ));;
  *[Kk]) LRB=$(( ${PMS%[Kk]} * 1024 ));;
  *)     LRB=$PMS;;
esac
[ "$LRB" -gt 0 ] 2>/dev/null || fail "could not read post_max_size ($PMS)"
echo "post_max_size=$PMS -> LimitRequestBody $LRB"

echo "== C2 php.d"
cat > /etc/php.d/zz-php2shell.ini <<'EOF'
; PHP2Shell stopgap (CVE-2026-109618/109619), 2026-10-23. See https://ariksavage.com/development/guides/php2shell
session.upload_progress.enabled = 0
session.use_strict_mode = 1
expose_php = Off
EOF

echo "== C3 Apache drop-in"
if [ -e "$DROPIN" ]; then
  echo "$DROPIN already exists, left as is"
else
  cat > "$DROPIN" <<EOF
# PHP2Shell stopgap, 2026-10-23. See https://ariksavage.com/development/guides/php2shell
# Named 00- so it loads before the vhost files that use its php2shell LogFormat.
# Refuse bodies PHP would refuse anyway (post_max_size), before mod_php's multipart parser runs.
LimitRequestBody $LRB
# Detection: every multipart request (the delivery format for both CVEs) to its own log.
# Apache 2.4.6 has no GlobalLog, so vhosts with their own CustomLog carry this line too.
SetEnvIfNoCase Content-Type "^multipart/" php2shell_multipart
LogFormat "%t %a %v %m \"%U%q\" %>s %I %D \"%{Content-Type}i\" \"%{User-Agent}i\"" php2shell
CustomLog logs/php2shell-multipart.log php2shell env=php2shell_multipart
ServerTokens Prod
EOF
fi

# Vhosts with their own CustomLog don't inherit the server-level one: add ours before their </VirtualHost>.
for f in "$CONF"/*.conf; do
  [ "$f" = "$DROPIN" ] && continue
  awk -v L="$LOGLINE" '
    /^[ \t]*<VirtualHost/ { inv=1; has=0; done=0 }
    inv && /^[ \t]*CustomLog/ { if ($0 ~ /php2shell/) done=1; else has=1 }
    inv && /^[ \t]*<\/VirtualHost>/ { if (has && !done) print L; inv=0 }
    { print }' "$f" > "$f.php2shell-new"
  # Only replace the file if a line was actually added (awk also "fixes" a missing final newline).
  if [ "$(grep -c php2shell-multipart "$f.php2shell-new")" = "$(grep -c php2shell-multipart "$f")" ]; then
    rm -f "$f.php2shell-new"
  else
    [ -e "$f.bak-php2shell" ] || cp -a "$f" "$f.bak-php2shell"
    cat "$f.php2shell-new" > "$f" && rm -f "$f.php2shell-new"
    echo "per-vhost CustomLog added: $f"
  fi
done

# ServerSignature/ServerTokens set in httpd.conf after the conf.d include would override the drop-in.
if grep -qE '^\s*(ServerSignature\s+On|ServerTokens\s+(OS|Full|Min|Minor|Major))' "$H"; then
  [ -e "$H.bak-php2shell" ] || cp -a "$H" "$H.bak-php2shell"
  sed -i -E 's/^(\s*)ServerSignature\s+On/\1ServerSignature Off/; s/^(\s*)ServerTokens\s+(OS|Full|Min|Minor|Major)/\1ServerTokens Prod/' "$H"
  echo "httpd.conf: ServerSignature Off / ServerTokens Prod"
fi

echo "== C4 configtest"
if ! apachectl configtest; then
  echo "configtest failed: restoring config"
  for b in "$H.bak-php2shell" "$CONF"/*.conf.bak-php2shell; do [ -e "$b" ] && cp -a "$b" "${b%.bak-php2shell}"; done
  rm -f "$DROPIN" /etc/php.d/zz-php2shell.ini
  apachectl configtest
  fail "nothing was restarted; config restored"
fi

echo "== C5 restart"
supervisorctl restart 1.apache
sleep 4
supervisorctl status 1.apache | grep -q RUNNING || fail "1.apache not RUNNING; check /var/log/httpd/error_log; the config backup is in /root"
supervisorctl status 1.apache
echo "local check: $(curl -s -o /dev/null -m 15 -w '%{http_code}' http://$(awk '/^[ \t]*Listen/ {print $2; exit}' /etc/httpd/conf/httpd.conf /etc/httpd/conf.d/*.conf | sed 's/^\([0-9]*\)$/127.0.0.1:\1/')/)"

echo "== C6 verify"
php -i | grep -E '^(session.upload_progress.enabled|session.use_strict_mode|expose_php) '
ls -l /var/log/httpd/php2shell-multipart.log
grep -c 'php2shell-multipart' "$CONF"/*.conf | grep -v ':0$'
echo "DONE Phase C on $(hostname -s)"
