PHP2Shell on PHP you can’t upgrade

Mitigating CVE-2026-109618 and CVE-2026-109619 when upgrading PHP isn’t an option

If you can patch PHP, stop reading and go patch PHP. This guide is for everyone else: the legacy app pinned to an old PHP, the server nobody is allowed to rebuild, the platform that has to keep running until its retirement date. I spent an afternoon putting these mitigations on six servers across production, staging and development, all of them running PHP 5.6 under Apache with mod_php. There was no outage and no maintenance window, just a few seconds of Apache restart on each box.

None of this is a patch. It’s a set of layers that take away what the two bugs need to work, plus a way to see anyone trying.

The two bugs, briefly

CVE-2026-109619 abuses stale global state. One request leaves PHP’s internals half-dirty, and a later request served by the same worker trips over what’s left. The delivery route is PHP’s file-upload progress tracking (session.upload_progress), driven by a multipart upload.

CVE-2026-109618 turns PHP running out of memory into code execution. When PHP hits its own memory_limit, it unwinds the request with some internal state only half set up. Memory corruption during that cleanup is the exploit.

Both arrive as multipart/form-data requests. Keep that in mind, because it shapes the detection step.

The plan, in layers

There’s no single setting that makes this go away, so the plan is a stack of small layers. Each one handles a different part of the attack, and each one is easy to test and easy to undo on its own.

  1. Take away the delivery route. Switch off upload progress in php.ini. (Step 1)
  2. Turn trouble away at the door. Apache refuses oversized requests before PHP ever parses them. (Step 2)
  3. Watch the door. Log every multipart request, so an attempt leaves a trail. (Step 3)
  4. Defuse the trigger. Put Apache inside a kernel memory limit, prove it works, then remove PHP’s own limit for web requests. (Step 4)
  5. Cut the phone line. Let the server talk only to the places it actually needs. (Step 5)

The order matters. Steps 1 to 3 are safe to do any time. Step 4 is only safe in its own order: PHP’s limit comes off after you’ve watched the kernel do its job, because removing it with nothing underneath leaves you worse off than you started. Step 5 goes last, because its rules should come from real traffic, not guesses.

Step 0: know what you’re running

Everything below assumes Apache with mod_php (the prefork MPM). PHP-FPM setups need a different version of Step 4. Check before you change anything:

Shell
php -v
httpd -V | grep -i mpm
httpd -M | grep -iE 'php|logio|setenvif'
stat -fc %T /sys/fs/cgroup          # tmpfs = cgroup v1, cgroup2fs = v2
php -i | grep -E '^(memory_limit|post_max_size|session.upload_progress.enabled) '

Also find out what starts Apache. Mine were run by supervisord, not systemd, and that changes how you restart it later.

A word on the examples. Every command and path below comes from the servers I actually fixed: CentOS 7, Apache 2.4.6 (which CentOS calls httpd), PHP 5.6 under mod_php, and cgroup v1. Other systems differ in file locations, package names, Apache and systemd versions, even in what the Apache command is called. Debian and Ubuntu, for example, call it apache2 and lay out its config differently. Where I know of a difference, I’ve noted it, but treat this as a map of what to change, not a script to paste, and check each path on your own system first.

Step 1: three lines of php.ini

Put these together in their own file so they’re easy to find and easy to undo. Where that file goes is just below.

php.ini
session.upload_progress.enabled = 0

This closes the front door for 109619. Very few applications use upload progress (check yours with a quick grep for upload_progress), and uploads work exactly the same without it.

php.ini
session.use_strict_mode = 1

This makes PHP refuse session IDs it didn’t issue itself. Upload progress is tracked per session, so an attacker who can’t choose the session ID has less to work with. It’s cheap hardening either way.

php.ini
expose_php = Off

This stops PHP announcing its version in an X-Powered-By header. Hiding a version doesn’t fix anything, but there’s no reason to hand out a shopping list.

How PHP finds the file

You don’t register the file anywhere. PHP reads its main php.ini, then every .ini file in its scan directory, in alphabetical order, and a setting read later wins. So dropping the file into that directory is all it takes, and the name is what makes it stick: it should sort last, so nothing loaded after it can quietly undo it.

Shell
php --ini | grep -i 'scan'
# CentOS / RHEL: one directory, shared by Apache and the command line
#   /etc/php.d/zz-php2shell.ini
# Debian / Ubuntu: Apache has its own directory, separate from the command line's
#   /etc/php/<version>/apache2/conf.d/99-php2shell.ini

CentOS’s files aren’t numbered, so I used zz-. Debian-family systems number theirs (10-opcache.ini, 20-curl.ini), so 99- is the way to sort last there. Debian has one more catch: the command-line PHP and Apache’s PHP each read their own set of .ini files, from /etc/php/<version>/cli/ and /etc/php/<version>/apache2/. php --ini only lists the command line’s, so it won’t show the directory that matters here. Put the file in apache2/conf.d/.

Apache config can also set PHP values with php_value or php_admin_value, and those beat any .ini file. Make sure nothing there switches these back:

Shell
grep -rniE 'upload_progress|use_strict_mode|expose_php' /etc/httpd/ /etc/apache2/ 2>/dev/null

mod_php reads its settings when Apache starts, so nothing changes until the restart in Step 2. (On PHP-FPM, restart the FPM service instead.) Afterwards, curl -sI https://your-site/ should no longer show an X-Powered-By header. That’s your proof that Apache’s PHP picked the file up.

Step 2: let Apache say no first

Apache
LimitRequestBody 314572800
ServerTokens Prod

Set LimitRequestBody to your post_max_size in bytes (300M is 314,572,800). PHP would reject anything bigger anyway, so nothing legitimate breaks, but now Apache refuses it with a 413 before mod_php’s multipart parser ever sees a byte. Check how your uploads work first: one of my apps sends files in 1MB chunks, so the limit applies to each chunk rather than to the whole file.

ServerTokens Prod trims the Server header down to just “Apache”.

Where it goes, and how Apache picks it up

These are server-wide settings, so they get a file of their own, just like Step 1. On CentOS, every .conf file in /etc/httpd/conf.d/ is read automatically, so creating the file is enough. Debian and Ubuntu do it differently, with a separate step to switch the file on. If you’re not sure which you have, ls /etc/httpd /etc/apache2 will tell you.

Shell
# CentOS / RHEL: create /etc/httpd/conf.d/00-php2shell.conf, then
sudo apachectl configtest && sudo systemctl restart httpd

# Debian / Ubuntu: create /etc/apache2/conf-available/zz-php2shell.conf, then
sudo a2enconf zz-php2shell
sudo apache2ctl configtest && sudo systemctl restart apache2

The names are opposites for a reason. On CentOS, conf.d/ holds the virtual hosts too, and Step 3 needs this file read before them, hence 00-. The CentOS catch is ServerSignature On, which sits in httpd.conf after the line that includes conf.d/, so change that one in httpd.conf itself. On Debian, the virtual hosts live separately in sites-enabled/, which loads after all of these, but security.conf in the same folder as yours already sets ServerTokens OS. Whichever file is read last wins, so there zz- puts yours after it.

Always run the config test first. A typo in a file Apache reads at startup means no Apache. And if systemd isn’t what runs your Apache (mine was run by supervisord), restart it whichever way it’s started, for example supervisorctl restart apache. Step 4 shows how to check.

Step 3: log every multipart request

You can’t fix what you can’t see. Both bugs ride in on multipart requests, so give those their own log. These lines go in the same file as Step 2:

Apache
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

That’s time, client IP, virtual host, path, status, request size (%I needs mod_logio) and duration. A normal day is a tidy list of uploads. An attack looks like a burst of odd paths, odd sizes or odd user agents. On CentOS, logs/ means /var/log/httpd. On Debian, swap it for ${APACHE_LOG_DIR}/ so the log lands in /var/log/apache2.

Making sure every site is covered

A <VirtualHost> that defines its own CustomLog stops inheriting the server-level one, so on its own this line can miss your busiest sites. What to do about it depends on your Apache version:

Shell
httpd -v    # Debian / Ubuntu: apache2 -v

Older than 2.4.19 (CentOS 7 ships 2.4.6, which is what I had): there’s no GlobalLog, so the same CustomLog line also goes inside each virtual host that has its own log, just before its </VirtualHost>. Hosts without their own log already inherit it, so leave them alone. This finds the ones that need it:

Shell
apachectl -S                                   # every virtual host, and the file it's in
grep -l CustomLog /etc/httpd/conf.d/*.conf     # Debian: /etc/apache2/sites-enabled/*.conf

2.4.19 or newer (every current Debian and Ubuntu, for example): change CustomLog to GlobalLog, with everything else on the line the same. It logs every virtual host, including ones with their own log, and you can skip the per-host lines.

The LogFormat has to be read before any virtual host uses it, and the file names from Step 2 already take care of that. Config test, restart, and remember Apache won’t accept a comment on the same line as a directive.

Step 4: let the kernel be the bouncer

This is the interesting part. 109618 fires when PHP enforces its own memory limit, so the limit itself is the trigger. The advisory’s suggestion is memory_limit = -1, which removes the trigger, but on its own it also removes the only thing stopping a runaway request from eating the whole server.

The trick is to move the ceiling below PHP. Put Apache inside a Linux memory cgroup. When the group is full, the kernel SIGKILLs a process outright. PHP never gets to run its out-of-memory cleanup, so there’s nothing half-built for an exploit to use. Under prefork every worker is its own process, so the kernel kills the one greedy worker and Apache carries on serving everyone else. Address-space limits (RLIMIT_AS, RLIMIT_DATA) won’t do this: they make allocations fail inside PHP, which is exactly the path you’re trying to avoid.

First, find out what starts Apache

How you set up the cgroup depends on what launches Apache, so check its parent process:

Shell
ps -o ppid= -p $(pgrep -o httpd) | xargs ps -o pid,cmd -p    # Debian: pgrep -o apache2

If the parent is PID 1, that’s systemd. Mine was supervisord. Either way, the wrapper below is the starting point, and the sections after it show how to hook it up for each.

The wrapper

On cgroup v1 (CentOS/RHEL 7), a tiny script creates the cgroup, sets the limit, moves itself into it, then becomes Apache:

Shell
#!/bin/sh
CG=/sys/fs/cgroup/memory/apache
mkdir -p "$CG"
echo 8589934592 > "$CG/memory.memsw.limit_in_bytes" 2>/dev/null
echo 8589934592 > "$CG/memory.limit_in_bytes" || exit 1
echo 8589934592 > "$CG/memory.memsw.limit_in_bytes" || exit 1
echo $$ > "$CG/cgroup.procs" || exit 1
exec /usr/sbin/httpd -DFOREGROUND

Save it as /usr/local/bin/httpd-cgroup.sh and make it executable with chmod 755. The exec on the last line matters: the script becomes Apache instead of starting it, so it keeps the same process ID and whatever launched it still tracks Apache directly. Every worker Apache forks after that inherits the cgroup. On Debian, replace the last line with . /etc/apache2/envvars followed by exec /usr/sbin/apache2 -DFOREGROUND, because Debian’s Apache won’t start without the settings in that file.

Now Apache has to be started through the script instead of directly, and that’s the part that depends on your setup.

What I did: Apache under supervisord

On my servers, supervisord started Apache, and its configuration was in /etc/supervisord.conf. (On other setups it can be in /etc/supervisord.d/ instead, and supervisorctl status lists the program names.) Apache’s section looked like this:

supervisord
[program:1.apache]
command=/usr/sbin/httpd -DFOREGROUND
priority=900
autorestart=true

The only change is the command= line, so it points at the wrapper:

supervisord
[program:1.apache]
command=/usr/local/bin/httpd-cgroup.sh
priority=900
autorestart=true

Back up the file first, make the edit, then let supervisord pick it up. update restarts Apache by itself, because its command changed:

Shell
cp -a /etc/supervisord.conf /etc/supervisord.conf.bak
vi /etc/supervisord.conf                  # change the command= line as above
supervisorctl reread                      # should say: 1.apache: changed
supervisorctl update                      # restarts Apache with the wrapper
supervisorctl status 1.apache

The wrapper needs root to write to the cgroup, so supervisord has to run as root (it normally does), and the section can’t have a user= line. Apache switches to its own user by itself. To undo it, copy the backup back and run reread and update again.

Stock CentOS 7: Apache under systemd

If Apache came from CentOS’s own packages and nobody changed how it starts, systemd runs it, using /usr/lib/systemd/system/httpd.service. Don’t edit that file: a package update will overwrite it. Add an override instead:

Shell
sudo systemctl edit httpd          # creates /etc/systemd/system/httpd.service.d/override.conf
systemd
[Service]
ExecStart=
ExecStart=/usr/local/bin/httpd-cgroup.sh

The empty ExecStart= clears the original command, and the second line replaces it with the wrapper. The stock unit also passes $OPTIONS from /etc/sysconfig/httpd. If you set anything there, add it to the wrapper’s last line, because the wrapper doesn’t pass it along. Then:

Shell
sudo systemctl restart httpd
systemctl status httpd

Why not systemd’s own MemoryLimit= setting? On CentOS 7’s systemd it caps RAM but not swap, so on a box with swap a runaway worker gets paged out instead of killed (more on that below). My servers ran Apache under supervisord, so I haven’t put this systemd variant through the kill test. Do that before you trust it.

Newer systems: let systemd do it

With cgroup v2 and systemd 243 or later (current Debian, Ubuntu or RHEL 9, for example), you can skip the wrapper entirely. systemd already runs Apache in a cgroup of its own, so you only need to tell it the limit. That goes in the same kind of override file as above:

Shell
sudo systemctl edit httpd          # Debian: apache2. Opens /etc/systemd/system/httpd.service.d/override.conf

An editor opens on that file. Add these lines, then save and close. systemd reloads its configuration by itself when you do:

systemd
[Service]
MemoryMax=8G
MemorySwapMax=0
OOMPolicy=continue

MemoryMax is the limit itself. MemorySwapMax=0 closes the swap escape. OOMPolicy=continue is the one people miss: by default these versions stop the whole service when the kernel kills any one of its processes for memory, which would turn one bad request into an outage. Then restart Apache and confirm systemd took the settings:

Shell
sudo systemctl restart httpd
systemctl show httpd -p MemoryMax -p MemorySwapMax -p OOMPolicy

Check it took

However you started it, confirm Apache is actually inside the limit before you rely on it:

Shell
grep memory /proc/$(pgrep -o httpd)/cgroup      # wrapper: ends in /apache
cat /sys/fs/cgroup/memory/apache/memory.limit_in_bytes
wc -l < /sys/fs/cgroup/memory/apache/cgroup.procs; pgrep -c httpd   # the two counts should match

Two details that matter more than they look

Cap swap too. memory.memsw.limit_in_bytes limits RAM plus swap. Without it, a runaway worker on a box with swap doesn’t get killed, it gets paged out, and the whole server crawls first. In my kill test it was the swap cap that fired, not the RAM one. (It’s written twice because the RAM+swap limit can never be lower than the RAM limit, so this order works whether you’re raising the limit or lowering it.)

Size it from real numbers. Add up Apache’s resident memory under real traffic (ps -C httpd -o rss=; on Debian the process is apache2), leave room for the database, the OS and anything else on the box, and pick something comfortably above Apache’s busiest moment. I used 2GB on a small staging box and 8GB on a 32GB production server.

Prove it, then take PHP’s limit off

On a non-production box, deliberately starve it:

Shell
sh -c 'echo $$ > /sys/fs/cgroup/memory/apache/cgroup.procs;
  exec timeout 120 php -d memory_limit=-1 -r "\$a=[]; while(true) \$a[]=str_repeat(\"x\", 1<<20);"'
dmesg -T | grep -iE 'memory cgroup out of memory|killed process' | tail -2

Under systemd, use the service’s own cgroup.procs instead (on cgroup v2, /sys/fs/cgroup/system.slice/httpd.service/cgroup.procs). You want exit code 137, a kernel line saying it killed your php process, and Apache still running with the same PID. Both of my test hogs died just under the limit, and Apache didn’t blink.

Only then remove PHP’s own cap, and only for Apache, in the same file as Step 2:

Apache
<IfModule php5_module>
    php_admin_value memory_limit -1
</IfModule>

The module is called php5_module on PHP 5, php7_module on PHP 7 and php_module on PHP 8. httpd -M | grep php shows yours, and if the name is wrong the block is silently skipped. Don’t put memory_limit = -1 in php.ini. Queue workers, cron jobs and CLI scripts read php.ini too, and they live outside Apache’s cgroup, so you’d be uncapping them with nothing underneath. In Apache config, the change applies to web requests only. Config test and restart one last time.

Step 5: restrict outbound traffic

An attacker who gets code running will want to call home, fetch a second stage or quietly ship data out. Most web servers only need to talk to a short list of places: the database, the mail relay, a few APIs. Capture a day of outbound connections (tcpdump on new outbound SYNs works well), then allow only what you saw. I’m doing this step last because it’s the one most likely to break something you forgot existed, so it gets real data before it gets rules.

Rolling it out without drama

I did the first server entirely by hand, one step at a time, checking each result. Then I turned what worked into two small scripts. Each one takes a backup first, refuses to restart anything if apachectl configtest fails, and puts the old setup back by itself if Apache doesn’t come up healthy inside the cgroup. That turned an afternoon of careful typing into a few minutes per server, with a safety net under every one.

Here they are, as they ran on my CentOS 7 servers, with only internal names taken out. They’re an example, not an installer: they assume Apache 2.4.6, cgroup v1, and supervisord running Apache as [program:1.apache], and they stop with an error if that isn’t what they find. The labels in their output (C1 to C6, E7 to E11) are from my own working notes. The first script covers Steps 1 to 3 of this guide, and the second covers Step 4.

php2shell-phase-c.sh Steps 1 to 3: php.ini, Apache request limit, multipart log
Shell
#!/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)"

Download php2shell-phase-c.sh

php2shell-phase-e.sh Step 4: memory cgroup, then PHP’s limit off for Apache
Shell
#!/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)"

Download php2shell-phase-e.sh

The second one takes the limit in bytes and an optional --kill-test for non-production boxes, for example bash php2shell-phase-e.sh 8589934592 for 8GB.

After each server, check from the outside: real pages load, the Server header says only “Apache”, there’s no X-Powered-By, an oversized POST gets a 413, and a test upload shows up in the multipart log with a 200.

What this doesn’t do

It doesn’t fix PHP. It removes the delivery route for one bug, turns the trigger for the other into a clean kernel kill, and makes attempts visible. If a new route to either bug turns up, these layers may not cover it, so watch the advisories. The real fix is still a supported PHP version. This is how you stay safe until you get there.

Need a hand?

If you’re stuck on an old PHP and this list looks like a lot, I do this kind of work. Tell me what you’re running and I’ll tell you what it needs.