#!/bin/bash
# PHP2Shell, Step 4 of https://ariksavage.com/development/guides/php2shell
# (Apache memory cgroup, then memory_limit -1 for Apache only), scripted after doing
# it by hand on a staging server. Written for CentOS 7 (cgroup v1), 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 after phase-c.sh:  bash phase-e.sh <limit-bytes> [--kill-test]
#   --kill-test runs E10 (deliberate memory hog in the cgroup). Non-prod boxes only.
# Restarts 1.apache once. Rolls itself back if Apache doesn't come up inside the cgroup.
set -u
LIMIT=${1:-}; KILLTEST=${2:-}
CGROOT=/sys/fs/cgroup/memory
CG=$CGROOT/apache
SV=/etc/supervisord.conf
D=/etc/httpd/conf.d/00-php2shell.conf
W=/usr/local/bin/httpd-cgroup.sh
fail() { echo "FAILED: $*"; exit 1; }
stanza() { sed -n '/^\[program:1\.apache\]/,/^\[/p' "$SV"; }
rollback() {
  echo "ROLLING BACK: $*"
  sed -i '/^# Phase E: PHP.s own memory cap/,/^<\/IfModule>/d' "$D"
  cp -a "$SV.bak-php2shell" "$SV"
  supervisorctl reread; supervisorctl update; sleep 5; supervisorctl status 1.apache
  exit 1
}

[ "$(id -u)" = 0 ] || fail "run as root"
[[ "$LIMIT" =~ ^[0-9]+$ ]] && [ "$LIMIT" -ge 1073741824 ] || fail "usage: $0 <limit-bytes, at least 1 GB> [--kill-test]"
[ "$(stat -fc %T /sys/fs/cgroup)" = tmpfs ] || fail "not cgroup v1; use E7b by hand"
[ -e "$CGROOT/memory.memsw.limit_in_bytes" ] || fail "no memsw accounting on this kernel"
[ -e "$D" ] || fail "run Phase C first"
echo "### $(hostname -s) Phase E, limit $LIMIT bytes ($(( LIMIT / 1048576 )) MB)"

OLD=$(stanza | grep '^command=')
echo "current 1.apache $OLD"
case "$OLD" in
  'command=/usr/sbin/httpd -DFOREGROUND'|'command=/usr/local/bin/httpd-cgroup.sh') ULIM='';;
  'command=/bin/bash -c "ulimit -c unlimited; exec /usr/sbin/httpd -DFOREGROUND"') ULIM='ulimit -c unlimited';;
  *) fail "unexpected 1.apache command; do this box by hand";;
esac
[ "$OLD" = "command=$W" ] && grep -q 'ulimit -c unlimited' "$W" 2>/dev/null && ULIM='ulimit -c unlimited'

echo "== E7 wrapper"
cat > "$W" <<EOF
#!/bin/sh
# PHP2Shell stopgap, 2026-10-23: run Apache inside a memory cgroup so a runaway mod_php
# worker is SIGKILLed by the kernel instead of hitting PHP's OOM unwind (CVE-2026-109618).
# RAM and RAM+swap are both capped, so a runaway can't escape into swap.
CG=$CG
mkdir -p "\$CG"
# memsw must stay >= memory: set it before and after, so raising or lowering both work.
echo $LIMIT > "\$CG/memory.memsw.limit_in_bytes" 2>/dev/null
echo $LIMIT > "\$CG/memory.limit_in_bytes" || exit 1
echo $LIMIT > "\$CG/memory.memsw.limit_in_bytes" || exit 1
echo \$\$ > "\$CG/cgroup.procs" || exit 1
$ULIM
exec /usr/sbin/httpd -DFOREGROUND
EOF
chmod 755 "$W" && sh -n "$W" || fail "wrapper syntax"
grep -v '^#' "$W"

echo "== E11 config (loaded by the same restart)"
if grep -q 'php_admin_value memory_limit -1' "$D"; then
  echo "already present"
else
  cp -a "$D" "$D.bak-phaseE"
  cat >> "$D" <<'EOF'

# Phase E: PHP's own memory cap removed for web requests only, so a runaway request never
# takes PHP's OOM unwind (the CVE-2026-109618 trigger). The apache memory cgroup
# (/usr/local/bin/httpd-cgroup.sh) is the ceiling now. NEVER keep this without that cgroup.
# CLI workers and cron still get memory_limit from /etc/php.ini.
<IfModule php5_module>
    php_admin_value memory_limit -1
</IfModule>
EOF
fi
apachectl configtest || { sed -i '/^# Phase E: PHP.s own memory cap/,/^<\/IfModule>/d' "$D"; fail "configtest failed; E11 block removed, nothing restarted"; }

echo "== E8 supervisord -> wrapper (restarts 1.apache)"
[ -e "$SV.bak-php2shell" ] || cp -a "$SV" "$SV.bak-php2shell"
sed -i '/^\[program:1\.apache\]/,/^\[/ s|^command=.*|command=/usr/local/bin/httpd-cgroup.sh|' "$SV"
stanza
stanza | grep -qx 'command=/usr/local/bin/httpd-cgroup.sh' || rollback "stanza edit didn't apply"
supervisorctl reread
if supervisorctl update | tee /dev/stderr | grep -q '1\.apache'; then :; else supervisorctl restart 1.apache; fi
sleep 5

echo "== E9 verify"
supervisorctl status 1.apache | grep -q RUNNING || rollback "1.apache not RUNNING"
supervisorctl status 1.apache
P=$(pgrep -o -x httpd)
grep -q ':/apache$' "/proc/$P/cgroup" || rollback "Apache parent not in apache cgroup"
OUT=0; for p in $(pgrep -x httpd); do grep -q ':/apache$' "/proc/$p/cgroup" || OUT=$((OUT+1)); done
echo "httpd procs: $(pgrep -x httpd | wc -l), outside cgroup: $OUT"
[ "$OUT" = 0 ] || rollback "httpd processes outside the cgroup"
for f in memory.limit_in_bytes memory.memsw.limit_in_bytes memory.usage_in_bytes memory.memsw.failcnt; do printf '%-28s %s\n' $f "$(cat $CG/$f)"; done
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 "CLI $(php -i | grep '^memory_limit ')"

if [ "$KILLTEST" = --kill-test ]; then
  echo "== E10 kill test"
  BEFORE=$(supervisorctl status 1.apache | awk '{print $4}')
  sh -c "echo \$\$ > $CG/cgroup.procs; exec timeout 300 php -d memory_limit=-1 -r '\$a=[]; while(true) \$a[]=str_repeat(\"x\", 1<<20);'"
  echo "exit code: $? (137 = killed by the cgroup, expected; 124 = timeout, NOT killed)"
  dmesg -T | grep -iE 'memory cgroup out of memory|killed process' | tail -2
  echo "apache pid before: $BEFORE  now: $(supervisorctl status 1.apache | awk '{print $4}')"
fi
echo "DONE Phase E on $(hostname -s)"
