On October 20, 2026, details of two PHP engine vulnerabilities leaked a day ahead of their coordinated release. The next day the PHP project shipped emergency patches, and by October 26 both flaws sat in CISA's Known Exploited Vulnerabilities catalog with active exploitation confirmed. The name that stuck — PHP2Shell — is the honest summary: a memory-corruption path that turns a web request into remote code execution, no vulnerable plugin or sloppy application code required.
This one deserves a calm look after the fact. Not because the panic was unwarranted — it wasn't — but because the event exposed a gap in how most of us think about PHP hardening. I had already disabled shell execution functions and isolated every site to its own FPM pool before the patch reached my servers. I want to be precise about what that bought me, what it didn't, and what I've kept in place since.
Why this bug was different
Most PHP security stories are application bugs — a WordPress plugin, a template engine, an editor extension. This one lived in PHP itself, in the engine's error handling. CVE-2026-109618 works like this: when PHP hits its memory limit mid-request, it uses longjmp() to bail out of whatever it was doing. If that bailout lands while request data is only half-initialized, the half-built structure is later treated as valid, and a carefully shaped request can turn that into code execution. CVE-2026-109619 is the sibling flaw: fatal-error handling leaves request-owned global state pointing at freed memory, and the next request on the same worker reads or branches on it.
Both scored 9.2 under CVSS v4. Every PHP version since 5.4 is affected. Every SAPI is affected — PHP-FPM, mod_php, php-cgi, FrankenPHP, LiteSpeed. The vendor flagged mod_php as the highest risk because the exploit can fire before any application code runs. Patched releases are 8.2.35, 8.3.36, 8.4.27, and 8.5.12.
The leak timing is why sites went dark. Technical details circulated a full day before the fix, researchers had demonstrated unauthenticated remote code execution against WordPress, Drupal, and Joomla, and major European government portals, universities, and forums took themselves offline rather than gamble on the window between leak and patch. For once, taking a site down was the defensible move.
What disable_functions doesn't do
Let me be direct, because this was my first assumption too: disable_functions would not have stopped PHP2Shell. Neither would open_basedir.
disable_functions gates what PHP scripts can call. It works at the function-dispatch layer — a script asks for system(), PHP checks the list, and refuses. PHP2Shell corrupts memory and hijacks control flow below that layer. Once the attacker owns the instruction pointer, they are executing machine code, and machine code calls the execve syscall directly. It never asks PHP for permission. open_basedir has the same limitation: it's enforced inside PHP's file functions, and memory corruption bypasses those functions entirely.
That is not an argument against either setting. It's an argument for matching the tool to the threat. There are two distinct threat classes here:
- Application-level RCE — an uploaded web shell, a template-injection flaw, a plugin that lets someone run
system(). These go through normal PHP dispatch, anddisable_functionsis the difference between a working shell and an inert one. - Engine-level RCE — PHP2Shell. The attacker is already past PHP's function table. No INI setting that gates functions can help, because no PHP function is involved.
I had disabled shell execution functions before the patch, and I've kept them disabled. They protect against the first class of bug. They were never going to protect against the second. Knowing which class you're defending against is the entire point of this article.
The layer that actually mattered: per-site workers
The RCE executes with the privileges of the PHP worker process. That one fact determines the blast radius more than any INI directive. If every site on a server shares one www-data pool, then one site's compromise is every site's compromise — the attacker can read every wp-config.php, every database credential, every upload directory.
If each site runs its own FPM pool under its own system user, the attacker lands inside a single site's sandbox. They can deface that site, read that site's database credentials, and write to that site's directories. They cannot reach the next site over without a second privilege escalation, because the OS never gave their user permission to. That's containment — not prevention, but the difference between an incident and a catastrophe.
; /etc/php/8.5/fpm/pool.d/example.com.conf
[example.com]
user = exampleuser
group = examplegroup
listen = /run/php/php8.5-fpm-example.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 10s
pm.max_requests = 500
; Application-level shell hardening — unchangeable by the app
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
php_admin_value matters here: it sets the directive at the pool level where application code and .user.ini files cannot override it. The disable_functions list covers the common shell-execution paths. That's application-level hardening — it stops an uploaded shell from calling system(). It does nothing against engine-level memory corruption.
open_basedir is enforced at the nginx layer instead, where it can be carried on every request. In the PHP location block, nginx passes the directive along with each FastCGI call:
# Backend nginx — the PHP location block
location ~ \.php$ {
include php.conf;
# Lock this request's PHP to the site's own directory tree
fastcgi_param PHP_ADMIN_VALUE "open_basedir=/home/exampleuser/www/example.com:/tmp";
}
Each request carries its own constraint explicitly, so nothing is inherited from a previous request on the same worker. That pairs cleanly with the per-site pool: the worker's OS user can only touch the site's files, and PHP's file functions get the same boundary from the other direction. But be precise about the limit. open_basedir is enforced inside PHP's file functions — and engine-level memory corruption bypasses those functions entirely. It's a boundary for well-behaved code, not a wall for a hijacked instruction pointer. Add any system paths the application genuinely needs to the list — /tmp is usually required for uploads.
The per-site isolation is set up in the WordPress on Nginx guide, and the full pool hardening rationale is in Securing Nginx and PHP.
Permanent settings that shrink the surface
Some settings remove entire attack surfaces permanently and cost almost nothing. PHP2Shell's most reliable trigger chain used the session upload-progress unserializer and a nested-array heap amplifier in $_FILES parsing. Both have off switches.
; Request-shape bounds — smallest values your application actually needs
post_max_size = 16M
upload_max_filesize = 12M
max_input_vars = 1000
max_file_uploads = 20
max_input_nesting_level = 32 ; default is 64 — halve it if your forms tolerate it
; Feature surface
session.upload_progress.enabled = Off
fatal_error_backtraces = Off ; PHP 8.5+
max_multipart_body_parts = 100 ; integer, not boolean — well under the 1500 shipped suggestion
What each one does:
post_max_sizeandupload_max_filesize— cap the size of any request body and any single upload. PHP's pre-patch guidance pushed these even lower (8M/4M) where uploads weren't needed. Set them to the smallest values your site actually uses, not the defaults.max_input_vars,max_file_uploads,max_input_nesting_level— bound the shape of incoming data, not just its size. The nesting default is 64; halving it to 32 collapses the deep-nesting amplifier while leaving normal forms untouched. Keep the values your forms actually need.session.upload_progress.enabled = Off— removes the upload-progress unserializer entirely. Boolean directive, soOffand0are equivalent. The cost is cosmetic: upload progress bars fall back to indeterminate spinners.fatal_error_backtraces = Off— PHP 8.5+ only. Stops fatal errors from storing request-owned backtrace pointers, which was one of the stale-state triggers. Boolean, soOffand0are equivalent. The cost is less debug detail in fatal error logs; on production, you accept that.max_multipart_body_parts = 100— an integer cap on how many MIME parts a single multipart body may contain, present in 8.5.12. When unset, PHP uses-1, which resolves tomax_input_vars+max_file_uploads(1020 with stock defaults); the shipped php.ini suggests 1500. Either way, 100 is far below both and still plenty for normal admin uploads. Raise it only if a form genuinely sends more parts.
None of these settings "fix" PHP2Shell. What they do is shrink the input surface the next engine bug has to work with. A memory-corruption exploit needs a reachable trigger; every trigger you remove is a chain that never gets built.