Accessibility settings

Changes apply right away and are remembered in this browser. Until you choose otherwise, motion follows your device's setting.

Motion
Text size
Reading

Knowledge Direct: PHP2Shell, Without a Patch

In October 2026 two remote-code-execution bugs nicknamed PHP2Shell landed on nearly every version of PHP. Knowledge Direct ran PHP 5.6, which was never going to see a fix, on servers that were five months from retirement and couldn’t be rebuilt or taken offline. So instead of a patch, I took away what the bugs need to work. Six servers across production, staging and development were covered in one afternoon, with no outage and no maintenance window. The full method is written up as a guide: Mitigating PHP2Shell on PHP you can’t upgrade.

Two bugs, zero patches

One bug, CVE-2026-109619, uses PHP’s upload-progress tracking to leave a worker’s internals half-dirty for the next request to trip over. The other, CVE-2026-109618, is nastier: when PHP runs out of memory, the cleanup it does on the way out can be steered into running an attacker’s code. Both arrive as ordinary-looking file uploads.

The usual answer is “upgrade PHP.” That wasn’t on the table. The platform was built on PHP 5.6 and the Phalcon framework, and it was scheduled for retirement in a few months. A rewrite to a supported PHP version would have taken longer than the platform had left.

Let the kernel be the bouncer

The upload bug was the easy one. Nothing in the platform used upload progress, so turning it off closed that door outright. While I was in php.ini, I also made PHP refuse session IDs it didn’t create itself. And since it’s impolite to ask an older server its version, Apache and PHP no longer volunteer one.

The memory bug was the fun one. Its trigger is PHP enforcing its own memory_limit, so the advisory suggests turning the limit off. On its own, that just trades one problem for another: now a runaway request can eat the whole server. My fix was to move the ceiling below PHP. Apache now runs inside a Linux memory cgroup, so when a request gets greedy, the kernel kills that one worker outright. PHP never gets to run the cleanup the exploit depends on, and Apache carries on serving everyone else.

Never trust a safety net until you’ve done a swan dive. So on two non-production servers, I deliberately ran a process out of memory inside Apache’s cgroup. Both times the kernel killed it just short of the limit, and Apache didn’t even restart. Only then did PHP’s own memory cap come off, and only for web requests. The background workers read the same php.ini and run outside the cgroup, so they kept their limit.

Moving the ceiling

Who enforces the memory limit, before and after Before: a runaway or malicious request keeps using memory until PHP hits its own memory_limit. PHP then runs its out-of-memory cleanup with some internal state only half set up, and that cleanup is what CVE-2026-109618 turns into running an attacker's code. After: Apache runs inside a Linux memory cgroup whose ceiling sits below PHP's. When the request reaches it, the kernel kills that one worker outright with SIGKILL, so PHP's cleanup never runs. Apache keeps serving every other request. Before: PHP enforces its own memory limit After: the kernel enforces it instead Runaway request memory keeps growing PHP memory_limit PHP hits its own cap OOM cleanup state half set up ✗ Code execution CVE-2026-109618 Runaway request memory keeps growing Kernel cgroup ceiling below PHP’s SIGKILL one worker, no cleanup ✓ Apache serves on other workers untouched
Before: PHP enforces its own memory limit, and the cleanup it runs on the way out is what the exploit hijacks. After: Apache’s cgroup sets the ceiling and PHP’s own cap is off for web requests, so a runaway worker is killed outright by the kernel and PHP’s cleanup never runs. Apache carries on serving everyone else.

The full set of layers

  • Upload-progress tracking switched off, the delivery route for one of the bugs
  • Strict session handling, so the servers accept only session IDs they issued
  • Apache refuses request bodies larger than PHP would accept, before PHP parses them
  • Every file-upload request written to its own log, so attempts are visible
  • Apache inside a kernel memory cgroup, capping RAM and swap, so out-of-memory ends in a clean kill
  • No Apache or PHP version numbers in any response
  • Outbound traffic rules for the app servers, set from a day of recorded connections (in progress)

Making it boring to roll out

By hand first, then by script

The first server got every step by hand, one command at a time, with a check after each. What I learned there went straight into two small scripts. Each one takes a backup, refuses to restart anything if Apache’s config test fails, and puts the old setup back by itself if Apache doesn’t come up healthy inside its cgroup. The other five servers took a few minutes each.

  • Bash
  • Apache
  • Linux System Administration

Old Apache, old surprises

Apache 2.4.6 has no server-wide log directive, and a virtual host with its own log ignores the server one. So the upload log had to be added to exactly the virtual hosts that needed it, and nowhere else. One server only listened on its private IP, and a setting buried late in httpd.conf overrode the new one. Each surprise got fixed once, in the script, rather than six times by hand.

  • Apache
  • Legacy Systems

Monitoring that doesn’t need me

The same week, I set up security monitoring for the whole 14-server fleet, designed to run with nobody watching until the platform retires. AWS GuardDuty watches for known attack patterns. Every server also checks itself each morning against a known-good baseline (accounts, SSH keys, privileged files, running processes, network connections) and emails the team only when something changes. A Monday heartbeat proves the checks are still alive.

  • AWS
  • AWS GuardDuty
  • Bash
  • Security Audits

Tightening the build pipeline

After a security incident on our legacy build infrastructure, I led the review that followed. The Jenkins server now answers only to the staff network, and deployments use dedicated keys that work only from the build server and can only read source code. Every server got the current operating-system security updates, and internal services can only be reached from inside our private network.

  • Jenkins
  • CI/CD
  • Incident Response
  • AWS

Looking back

If you can’t move out, you can at least lock the doors. Moving to a supported PHP was the real fix, and it wasn’t going to happen five months before the lease ran out. So I looked at what each bug needed instead. One needed a feature nobody was using, and the other needed PHP to be the one enforcing its own memory limit. Take those away and the bugs are still in there, with nothing to do. It isn’t as satisfying as a patch, but a platform with five months left needs to be safe for five months, and that’s a different problem with a much cheaper answer.

Stack & skills

  • PHP 5.6
  • Apache 2.4 (prefork, mod_php)
  • CentOS 7
  • Linux cgroups
  • supervisord
  • Bash
  • AWS (EC2, GuardDuty, SNS, SES)
  • Jenkins
  • PHP
  • Apache
  • Linux System Administration
  • Security Audits
  • Incident Response
  • AWS
  • AWS GuardDuty
  • Legacy Systems
  • Documentation