PHP2Shell: A Fire Door for the Server That Gets No Patch
PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 fix two critical RCE flaws. For the servers you cannot update yet: four temporary doors (blocking multipart, php_admin settings, pulling the server off the network, hunting for traces), each tested in a lab, plus three approval-gated task prompts for AI agents.
This post is also available in Turkish →
Today, 21 October 2026, at 14:00 UTC, the PHP team shipped four releases at once: 8.5.12, 8.4.27, 8.3.36 and 8.2.35. They fix two critical flaws, CVE-2026-109618 and CVE-2026-109619, which picked up the name PHP2Shell when they leaked. Both score 9.2 on CVSS v4 and both open the door to unauthenticated remote code execution (RCE).
If you can update, the job is simple: update, restart, done. This article is for the other servers. The client site forgotten on PHP 7.4, the 8.1 your control panel compiled, the e-commerce box nobody is allowed to touch this week. They get no patch today, and some will never get one. Below: how to put those servers behind protection, what each measure closes and what it leaves open, and ready task prompts if you hand the work to an AI agent.
One thing up front, because the rest of the article is built on it: temporary measures are a fire door. A fire door does not put out the fire, it buys time. PHP's own advisory says the same thing: these measures reduce exposure to specific attack paths "but do not make an installation safe".
What is PHP2Shell, and how did it unfold?#
The timeline is short:
- 20 October 2026, 19:33 UTC: Jakub Zelenka, for the PHP security team, posted the advance notice: a security release for two critical vulnerabilities the next day at 14:00 UTC, earlier if details went public.
- 20 October: Details leaked ahead of coordinated disclosure. According to Huntress, watchTowr confirmed the flaws were real and reproduced one of them.
- 21 October, 14:00 UTC: Four releases and the official security advisory went live.
The two flaws, as the official advisories describe them:
- CVE-2026-109618, "RCE via OOM corruption" (GHSA-w8hw-w9fx-j79w). When PHP runs out of memory it aborts the request with
longjmp(). If that abort lands while internal request data is only half built, request shutdown treats the half-built data as valid. According to the advisory, several multipart parsing issues can push a request into OOM while it still looks within PHP's limits. - CVE-2026-109619, "RCE via stale global state" (GHSA-6wmf-4rv5-p3rp). Request-owned internal state can survive in the worker after its memory is freed, and the next request on the same worker can use it before normal initialization resets it (CWE-416, use after free). Two consumers are confirmed: warnings from INI settings applied before request startup, and session upload progress.
Who is at risk? The official advisory puts it in one line: every PHP installation serving web requests. PHP-FPM, Apache mod_php, php-cgi, FrankenPHP and LiteSpeed. Risk is highest on mod_php, where an unauthenticated request has been shown to trigger the attack before any of your application code runs. In practice that means the maintenance mode in WordPress, Laravel or your own framework will not save you. PHP parses the request before the application ever sees it.
Exploitation status: sources disagree here, so I am reporting them as they disagree. Patchstack said in its 07:34 UTC update on 21 October that it had confirmed multiple active exploitation incidents. Huntress had seen none in its partner environments at the time of writing, and the first version of CERT-EU advisory 2026-018 said no technical details were public. The releases are out now and the fixes are in public commits. The quiet window between leak and patch is over; attackers now have both the claim and the diff.
The advisories credit the reporters by GitHub handle: lcfr-hax, sagitzadik-wiz, tomash-areion and amyb-oai. The fix was developed by arnaud-lb, iluuu1994, bukka and ndossche. Patching four live branches and three retired ones overnight is the heavy end of volunteer work, and they have earned the thanks before anything else.
Which PHP are you on? Version numbers can lie#
I wrote it in the PHP 8.5 upgrade article: a version number on its own can lie to you. Today both directions matter.
On Debian, Ubuntu, RHEL and their relatives, the distribution backports fixes onto the old version number. Ubuntu's 8.3.6 may well be patched; what decides it is the package changelog, not the number. According to the advisory, distributions and LTS vendors received the patches in advance. In the other direction, PHP compiled by a control panel (CWP, cPanel's EasyApache and the like) gets no backports: that binary is the code as it stood the day it was built, and it is patched only when it is rebuilt.
One more warning from the official advisory: the volunteer-maintained rpms.remirepo.net repository will not have updates today. If you depend on it, switch to distribution packages or build from source.
# Which PHP? Whose build?
php -v
which -a php php-fpm php-fpm8.3 2>/dev/null
dpkg -l | grep -E '^ii +(php|libapache2-mod-php)' # Debian/Ubuntu
rpm -qa | grep -i '^php' # RHEL/AlmaLinux
# Distribution package? Is the CVE in its changelog?
apt changelog php8.3-fpm 2>/dev/null | grep -m1 CVE-2026-109618
rpm -q --changelog php-fpm | grep -m1 CVE-2026-109618
If the CVE is not in the changelog, or the package manager does not know that PHP at all, treat the server as unpatched.
There is one more trap, and I hit it head-on in the lab I built for this article. What the CLI says is not what the web lives with. On the same machine, with the same PHP 8.3.6, the script below reported session upload progress as enabled under the CLI and disabled on the page served by Apache mod_php, because the CLI and the web SAPI read different php.ini files. Checking a setting in the terminal and calling it done is putting the lock on the wrong door. Check through the web:
<?php
declare(strict_types=1);
/**
* Quick status report for CVE-2026-109618 / CVE-2026-109619 (PHP2Shell).
* Run it through the web to see the web SAPI's real settings;
* the CLI's php.ini may not match FPM's or mod_php's.
* Delete the file when done: do not leave version info exposed.
*/
function php2shellStatus(): array
{
$patched = ['8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.27', '8.5' => '8.5.12'];
$branch = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
if (isset($patched[$branch])) {
$state = version_compare(PHP_VERSION, $patched[$branch], '>=')
? 'PATCHED'
: 'LOOKS UNPATCHED: needs ' . $patched[$branch] . ' (distro package? check its changelog)';
} elseif (version_compare(PHP_VERSION, '8.2.0', '<')) {
$state = 'END OF LIFE: treat as unpatched unless your distro/LTS vendor backported the fix';
} else {
$state = 'UNKNOWN BRANCH: check manually';
}
$warnings = [];
if (ini_get('session.upload_progress.enabled')) {
$warnings[] = 'session.upload_progress.enabled is on';
}
if (ini_get('memory_limit') !== '-1') {
$warnings[] = 'memory_limit is finite (' . ini_get('memory_limit') . ')';
}
if (PHP_VERSION_ID >= 80500 && ini_get('fatal_error_backtraces')) {
$warnings[] = 'fatal_error_backtraces is on';
}
return [
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'state' => $state,
'post_max' => ini_get('post_max_size'),
'warnings' => $warnings,
];
}
header('Content-Type: text/plain; charset=utf-8');
echo json_encode(php2shellStatus(), JSON_PRETTY_PRINT), "\n";
The Turkish version of this script passed php -l and ran under the CLI, Apache mod_php and FPM; this English one passed php -l and the CLI run. Drop it in under a random name, read the result, delete it.
Patched branches and the retired ones#
| Branch | Patched release | Status |
|---|---|---|
| 8.5 | 8.5.12 | Update |
| 8.4 | 8.4.27 | Update |
| 8.3 | 8.3.36 | Update |
| 8.2 | 8.2.35 | Update; the branch reaches end of life on 31 December 2026 |
| 8.1, 8.0, 7.4 | No official release | The PHP team published minimal source backport branches for people building from source |
| 7.3 and older, 5.x | None | Isolation and migration only |
The last two rows are today's real news. Given the severity, the security team published source patches even for retired branches: the PHP-7.4, PHP-8.0 and PHP-8.1 branches are in the repository; I checked all three with git ls-remote today. These are not releases, they are a hand held out to people who build from source and have no LTS vendor. If you run a panel-compiled 8.1, ask your provider when the panel will build from that branch. Do not wait without asking.
For 7.3, 7.2 and PHP 5 there is neither a release nor a source patch. For those servers, every door below is not temporary; it is life support.
The server that gets no patch: four fire doors#
I have ordered the doors from the outside in. The outermost is the strongest, because the request never reaches PHP; the innermost closes the least. Under each door I wrote down what it does not close.
1. Cut multipart at the web server#
According to the advisories, the path that exhausts memory in the OOM flaw is multipart parsing, and one confirmed path in the stale-state flaw is session upload progress, which only kicks in on multipart/form-data requests. The OOM advisory itself suggests blocking these requests at the frontend if your application does not use multipart. That means PHP never sees them.
Apache 2.4 (in the vhost or under conf-available/):
# PHP2Shell (CVE-2026-109618 / CVE-2026-109619) temporary shield.
# Rejects multipart/form-data POST before it reaches PHP.
# Put your admin IP range in the exception; uploads keep working from there.
<If "%{REQUEST_METHOD} == 'POST' && req('Content-Type') =~ m#multipart/form-data#i && ! -R '203.0.113.0/24'">
Require all denied
</If>
LimitRequestBody 8388608
LimitRequestFields 50
nginx (in the server block):
# PHP2Shell temporary shield: no multipart reaches PHP.
client_max_body_size 8m;
set $p2s_block 0;
if ($content_type ~* "multipart/form-data") { set $p2s_block 1; }
if ($remote_addr = "203.0.113.10") { set $p2s_block 0; }
if ($p2s_block) { return 403; }
I tested both in the lab with five requests. A plain GET and a urlencoded form POST got 200; multipart sent with -F, a header written as MULTIPART/FORM-DATA, and chunked multipart with no Content-Length all got 403. On Apache a 9 MB body got 413 and multipart from the exempt IP got 200. The advisory specifically says frontend limits must also cover requests without a usable Content-Length; that is why the chunked case is in the test.
The cost: file uploads, forms that send multipart, and any third-party callbacks (webhooks) that arrive as multipart will break. WordPress media uploads, WooCommerce product images and the "attach your CV" form stop working. Grant the exception by IP, not by path; a path exception tells an attacker which door is open.
What it does not close: the advisories say plainly that other variants may exist; if a non-multipart path turns up, this door will not see it. The same goes for signature-based WAF rules: a WAF may parse a multipart body differently from PHP, and a signature only catches the known path. The strength of this door is not a signature, it is cutting multipart off entirely.
2. PHP settings, where the application cannot override them#
These are the settings from the official advisory. Where they go is the critical detail: they must apply before the request is parsed, and the application must not be able to undo them. ini_set() and FPM's .user.ini are not enough; the advisory rules both out by name.
PHP-FPM pool (pool.d/www.conf or the site's own pool):
; PHP2Shell temporary shield (per pool)
php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[memory_limit] = -1
php_admin_value[post_max_size] = 8M
php_admin_value[upload_max_filesize] = 4M
; The advisory's "if performance allows" option: a fresh worker per request
pm.max_requests = 1
Apache mod_php:
<IfModule mod_php.c>
php_admin_flag session.upload_progress.enabled off
php_admin_value memory_limit -1
php_admin_value post_max_size 8M
php_admin_value upload_max_filesize 4M
</IfModule>
On PHP 8.5, add fatal_error_backtraces=0; according to the advisory, the confirmed RCE against mod_php on 8.5 runs through that feature. If you need uploads, the advisory's ceiling for post_max_size is 32M, and upload_max_filesize must always stay below it.
I validated the FPM pool with php-fpm8.3 -t, and a request sent over FastCGI read all three values as expected (0 -1 8M). On mod_php I tried to override the same settings from .htaccess with php_flag ... on and php_value memory_limit 64M; the php_admin_* values held. That is what the admin is for.
After writing the settings, restart every worker. The stale state lives in the worker; as long as an old worker is up, the new setting does not clean it.
Why memory_limit=-1? To remove the "hit the memory limit" moment an attacker can control. It is not free: a single request can now ask for all the memory on the box. That is why the advisory suggests moving the limit from PHP to the operating system: a container or cgroup limit, preferably with a policy that kills only the affected worker. Do not use RLIMIT_AS or RLIMIT_DATA; according to the advisory they produce exactly the allocation failure the flaw feeds on. The advisory adds that this setting removes the controllable failure, but an underlying allocation failure can still reach the vulnerable path.
What it does not close: session.upload_progress.enabled=0 closes the confirmed session paths, not other variants. In the advisory's words, hiding diagnostics with error_reporting, display_errors or @ does not help. Remove invalid or deprecated INI settings applied per request, and fix the fatal errors and timeouts an attacker can reach.
3. Pull it off the network#
For 7.3 and older, for any 7.4/8.0/8.1 whose patch you cannot build, and for any server where you cannot test the two doors above today: take it off the internet. That is CERT-EU's advice too: isolate internet-facing applications from public networks until they can be patched.
In practice there are three options. Put anything that does not need to be public, such as admin panels, internal tools or client portals, behind a VPN or an IP allowlist. For a site that must stay public, a short maintenance page first, then read-only: let the reverse proxy pass only GET and HEAD. If none of that works, shutting the site down today and moving it to 8.x tomorrow is cheaper than rebuilding the server from scratch tomorrow.
4. Look for traces: did anyone get through?#
There is no published IOC (indicator of compromise) list for PHP2Shell yet. Huntress recommends watching behaviour: a shell (sh, bash), an interpreter (python, perl), a download tool (curl, wget, nc), or recon commands like whoami, id and uname -a appearing under a PHP worker.
# Any foreign process among the direct children of PHP/web workers?
P=$(pgrep -d, -x 'php-fpm.*|apache2|httpd|php-cgi.*|lsphp.*')
[ -n "$P" ] && ps --ppid "$P" -o pid,ppid,user,etime,cmd --no-headers \
| grep -vE 'php-fpm|apache2|httpd|php-cgi|lsphp'
# PHP files in the web root changed in the last two days
find /var/www -name '*.php' -mtime -2 -printf '%TY-%Tm-%Td %TH:%TM %p\n' | sort
# The web user's crontab
crontab -l -u www-data 2>/dev/null
My first version of the first command picked up unrelated processes in the lab; slicing --forest output with grep -A drags in neighbouring branches of the tree. The version above only looks at the workers' direct children: it came back empty on a clean box and caught the line when I started sh under a process named apache2. It does not see grandchildren; if in doubt, read the whole tree with ps -ef --forest. If it returns anything, cut the server off the network, preserve the web server and PHP logs, and look for new files in the web root, cron jobs and new accounts. Keep the evidence before you start cleaning.
Ready task prompts for AI agents#
Doing this by hand across a hundred client servers takes a day; an agent does it in an hour. But the rule from Urban Renewal #1 applies twice over here: make the agent read before it writes. "Read-only" means something different in every tool; Claude Code's plan mode, Codex's --sandbox read-only, OpenCode's Plan agent and Cursor's Ask mode do not draw the same line. Whichever one you run against a live server, close off restarts and config writes in the tool's permission settings, not in the prompt. The prompt states intent; permissions draw the line.
The three prompts below are tool-agnostic. Use them in order: inventory, then the shield, then the update.
Task 1: Inventory (read-only)#
Task: PHP2Shell (CVE-2026-109618 / CVE-2026-109619) inventory report.
Do NOT modify any file on this server, install or remove packages, or
restart services. Run read-only commands only. Write the report in this chat.
Patched releases: 8.5.12, 8.4.27, 8.3.36, 8.2.35. 8.1 and older get no
official release.
For every PHP installation, add a table row with:
- Path and version (php -v, which -a, the panel's PHP directories)
- Source: distribution package, panel build, or hand-compiled
- If a distribution package: does its changelog mention CVE-2026-109618
- SAPI: FPM pools, mod_php, php-cgi; which site uses which
- Values as seen by the web SAPI (do not trust the CLI):
session.upload_progress.enabled, memory_limit, post_max_size,
(on 8.5) fatal_error_backtraces
- Where each value is set: php.ini, pool, vhost, .htaccess, .user.ini
- Whether the site uses multipart/form-data (uploads, forms)
Final section: things you could not verify by reading. Do not write
passwords, keys or .env values into the report; mask them.
Task 2: Temporary shield (approval-gated)#
Task: Prepare the PHP2Shell temporary shield for the UNPATCHED servers in
the Task 1 report. The shield does not replace the patch; say so in the report.
Rules:
1. Back up every file before changing it (file.bak-YYYYMMDD-HHMM).
2. Show each proposed change as a diff and WAIT FOR MY APPROVAL. Do not
write to any config file without approval.
3. After approval, validate syntax first (apachectl configtest,
nginx -t, php-fpmX.Y -t). On error, restore the backup and stop.
4. Do NOT run any service restart; show me the command and I will run it.
Parts of the shield:
- At the web server, reject multipart/form-data POST with 403; exempt
these admin IPs: <IP list>. List the sites that use file uploads and
leave the decision for those sites to me.
- Body limits: post_max_size 8M (32M if uploads are needed),
upload_max_filesize below that; the same limit at the web server.
- In the FPM pool or Apache config, via php_admin_*:
session.upload_progress.enabled=off, memory_limit=-1;
on PHP 8.5 also fatal_error_backtraces=0. No ini_set(), no .user.ini.
- For memory_limit=-1, propose a service-level memory cap (cgroup /
systemd MemoryMax). Do not propose RLIMIT_AS or RLIMIT_DATA.
After the restart, give me verification commands: GET 200, urlencoded
POST 200, multipart POST 403, chunked multipart POST 403, and a
settings check through the web.
Task 3: Update (in a maintenance window)#
Task: Plan the move of PHP installations on supported branches to the
patched releases: 8.5 -> 8.5.12, 8.4 -> 8.4.27, 8.3 -> 8.3.36, 8.2 -> 8.2.35.
Plan first, then work. For each server:
- Method by source: distribution package (apt/dnf update), panel build
(the panel's rebuild step), source build. Flag servers that use
rpms.remirepo.net separately; that repository has no updates today.
- 8.1, 8.0, 7.4: is there an LTS vendor; if not, the php-src
PHP-X.Y-CVE-2026-109618-109619 source backport branch, or moving to 8.2+.
- 7.3 and older: there is no update path; write an isolation and
migration plan.
Rules: Do NOT run package installs or builds on a live server without MY
APPROVAL. Write the backup and rollback steps first. After the update,
check that every PHP worker restarted (no old processes left) and verify
the version through the web. Propose removing the temporary shield as a
separate step; if session.upload_progress is not used, keep it off, as the
advisory recommends.
What the three prompts share is not the phrase "wait for my approval" but where it sits: before writing, before restarting, before installing. An agent is fast; on a live server, speed also means the wrong command runs fast.
When is it not enough?#
These doors buy time; they do not make the server safe. The advisories are blunt: updating is "the only complete mitigation" and there is no complete configuration mitigation. The multipart block breaks part of your site and is not something to leave on forever. memory_limit=-1 without a cgroup cap opens a different door, one request taking the server down. pm.max_requests=1 noticeably raises load on a busy site. And a clean log is not proof; the advisory says that too.
Put the doors up today, and put the dates in the calendar today: which server moves to a patched branch on which day, which one gets switched off on which day.
A fire door does not put out the fire#
Not even nineteen hours passed between the advance notice and the patch. The patched releases are out, and there are source patches even for retired branches. Every setting and rule in this article has one job: holding out until that patch.
Close the door, clear the stairs, call the fire brigade. In that order. Closing the door and staying in the building is just accepting the fire.
Stay with technology, and keep your versions fresh.
Comments
0 comments
Leave a comment