PHP2Shell: install the updates and block multipart in Caddy and Apache until then
Summary
On 21 October 2026 the PHP team closed two critical vulnerabilities with 8.5.12, 8.4.27, 8.3.36 and 8.2.35 that let attackers run code without logging in. I show how to check whether the update has really arrived, and how to protect servers until then with a multipart block in Caddy or Apache and the official PHP settings. That includes an honest look at what these stopgaps cost, what does not help and what should stay after the update.
What is confirmed now
On 21 October 2026 the PHP team released 8.5.12, 8.4.27, 8.3.36 and 8.2.35. They fix two vulnerabilities, both rated CVSS v4.0 9.2: CVE-2026-109618 and CVE-2026-109619. According to PHP, every installation serving web requests is at risk, that is PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. PHP considers the risk highest for mod_php, where a demonstrated attack without login works before any application code runs.
The first vulnerability concerns running out of memory. When PHP hits memory_limit it aborts the request while internal data is only half built, and cleanup then treats that data as valid. Crafted multipart uploads can trigger this, among other things: without a usable length, with very many headers per part, or with empty file fields under nested names. The second vulnerability concerns state that survives an aborted request. A later request in the same process uses it before PHP resets it. Confirmed triggers are warnings from settings applied per request and the session upload progress feature, against mod_php and also against PHP-FPM.
The reports before the release pointed in the right direction but were too narrow: multipart is a confirmed trigger, not the only one. Out-of-memory conditions can also come from the application itself, and for the second vulnerability any fatal error in the right place is enough, a timeout for example.
What you can take away: Only the update closes both vulnerabilities completely. Everything else in this post shrinks the attack surface until then and does not replace the update.
Checking whether the update has really arrived
Distributions received the patches in advance and build them into their own packages. The version number often stays the same. Ubuntu 24.04, for example, ships the fix as 8.3.6-0ubuntu0.24.04.13, and php -v still shows 8.3.6. What counts is the package version and the changelog entry naming the two CVE numbers, not the PHP version.
Release and availability are not the same moment. On 21 October the Ubuntu package was listed as published in Launchpad at 15:41 CEST, and the mirrors served it about an hour later. Anyone running apt-get upgrade in between gets no package and may easily conclude there is none yet:
apt-get update
apt-cache policy php8.3-fpm
apt-get changelog php8.3-fpm | head -20With containers the image counts, not the container. Restarting the old image changes nothing, and a new tag does not mean PHP inside it is patched. On the afternoon of 21 October, for example, the dokuwiki/dokuwiki:stable image rebuilt that morning still contained PHP 8.4.26, and the official tags php:8.4.27-apache and php:8.5.12-apache did not exist on Docker Hub yet. The version is stored as an environment variable in the image:
docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' dokuwiki/dokuwiki:stable | grep PHP_VERSION
docker exec <container> php -vPHP 8.1 and older no longer receive updates from the PHP project. Given the severity, the security team has nevertheless provided minimal source backports for 7.4, 8.0 and 8.1, intended for anyone building from source. Users of the volunteer-maintained repository rpms.remirepo.net get no updates there on release day, according to PHP, and should switch to distribution packages or their own builds.
What you can take away: After the update, check the package version or php -v inside the container for every running service, and restart every PHP-FPM service, Apache with mod_php and long-running workers. An installed package does not protect you while the old process keeps running.
Why the block at the proxy still makes sense
Until the update runs on every system, a block at the web server or reverse proxy remains the most effective quick measure against the confirmed upload path. PHP parses a multipart body before the application runs a single line. A login, a maintenance page or a check in the code therefore comes too late. The proxy, by contrast, sees the Content-Type header and can reject any request containing “multipart” without reading the body.
The PHP team recommends exactly this in its advisory: applications that do not need multipart data should block such requests at the frontend. The block is deliberately coarse. It does not look for suspicious fields, because the proxy would have to parse the body, and exactly the way PHP does. It applies to all HTTP methods and regardless of upper or lower case, because PHP accepts both.
What you can take away: The proxy block covers the upload path, not the whole vulnerability. Out-of-memory conditions caused by the application and fatal errors on other paths remain, which is why the PHP settings further down belong with it.
Caddy: reject multipart before PHP
In Caddy a reusable building block, a so-called snippet, is enough. It goes at the very top of the Caddyfile and is imported in every site block that hands requests to PHP, whether via reverse_proxy or php_fastcgi:
(php2shell_block) {
# Stopgap until the PHP update: no multipart to PHP
@multipart header_regexp Content-Type (?i)multipart
respond @multipart "Uploads temporarily blocked" 403
}
example.org {
import php2shell_block
reverse_proxy php-app:80
}The header_regexp matcher checks all values of the header, (?i) makes it case-insensitive. Caddy runs respond before reverse_proxy and php_fastcgi in its fixed directive order, so the request never reaches PHP. Blocks that only redirect (redir) do not need the import.
If individual editorial machines must be allowed to upload, exclude them in the matcher. That shrinks the protection, because exactly these addresses remain exposed:
@multipart {
header_regexp Content-Type (?i)multipart
not client_ip 192.0.2.10
}Validate and apply without a restart:
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfileIf Caddy runs in Docker with the Caddyfile mounted as a single file, edit the file in place. Many editors write a new file and rename it. The container then keeps seeing the old one.
What you can take away: A snippet with import keeps the block in one place. After the update you delete the import lines and the snippet, and everything is as before.
Apache: the same block
In Apache 2.4 an <If> block in the VirtualHost does the job. The variable matters: %{HTTP:Content-Type} is the request header. %{CONTENT_TYPE}, by contrast, means the type of the response and does not help here.
<VirtualHost *:443>
# ... existing configuration ...
# Stopgap until the PHP update: no multipart to PHP
<If "%{HTTP:Content-Type} =~ /multipart/i">
Require all denied
</If>
</VirtualHost>Apache evaluates <If> sections after <Directory> and <Location>. A “Require all granted” elsewhere therefore does not lift the block. This applies equally to mod_php, PHP-FPM via proxy_fcgi and Apache as a reverse proxy in front of a PHP container.
Without access to the server configuration, for example on shared hosting, use mod_rewrite in .htaccess:
RewriteEngine On
RewriteCond %{HTTP:Content-Type} multipart [NC]
RewriteRule ^ - [F]Then check with apachectl configtest and reload with apachectl graceful. Open connections stay up.
What you can take away: Test the block from outside, not just the syntax. A rule in the wrong VirtualHost, or an .htaccess that is ignored because of AllowOverride, reports no error and still protects nothing.
Testing whether the block works
Three requests are enough: a real upload, a variant with another method and upper case, and a normal page request that must keep working.
curl -s -o /dev/null -w '%{http_code}\n' -F x=1 https://example.org/
curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: MULTIPART/form-data; boundary=a' --data x https://example.org/
curl -s -o /dev/null -w '%{http_code}\n' https://example.org/The first two must return 403, the third the usual status, i.e. 200 or a redirect. Also check whether the PHP server can be reached another way, such as a published container port. The block has no effect there.
What you can take away: A successful reload only proves the configuration is valid. Only the 403 on the upload shows that it works.
Second layer: the PHP settings from the official advisory
For servers that cannot be updated yet, the PHP team lists several settings. Each one closes only certain attack paths, none makes an installation safe. The simplest approach is a separate file that is loaded after all others:
; /etc/php/8.x/fpm/conf.d/99-php2shell.ini
; or /usr/local/etc/php/conf.d/zz-php2shell.ini in Docker images
session.upload_progress.enabled = 0
max_input_nesting_level = 8
post_max_size = 8M ; 32M if uploads are needed
fatal_error_backtraces = 0 ; PHP 8.5 only
memory_limit = -1 ; only together with a memory limit on the container- session.upload_progress.enabled = 0 cuts the confirmed session path of the second vulnerability. The setting must apply before the request is parsed, that is in php.ini, the FPM pool or the server configuration. According to PHP, ini_set() and a .user.ini are not enough.
- fatal_error_backtraces = 0 is only needed on PHP 8.5, where it is on by default and the basis of the confirmed attack on mod_php.
- memory_limit = -1 takes away the controllable out-of-memory condition behind the first vulnerability. Without a limit, however, a single request can push the whole server into memory pressure. Set the limit on the container or via cgroup instead, not with RLIMIT_AS or RLIMIT_DATA, which trigger exactly the vulnerable path. On shared hosts without a limit per pool this is a bad trade.
- post_max_size kept low: according to PHP 8M without uploads, 32M with uploads, upload_max_filesize below that. Limits on size, field count, parts and headers at the proxy help as well, including for requests without a length.
- Clean up per-request settings: outdated or invalid values set in .htaccess via php_value or sent by the web server as PHP_VALUE produce warnings before initialisation and are a confirmed trigger. error_reporting, display_errors or @ do not help against this.
- pm.max_requests set low: the more often PHP-FPM replaces a process, the shorter leftover state lives. A value of 1 is safest and costs noticeable performance.
Check the values in the running service. The command line often reads a different php.ini, and some images load their own files after yours. In the official DokuWiki image, for example, dokuwiki.ini sets memory_limit from an environment variable and loads after custom.ini; there only PHP_MEMORYLIMIT=-1 on the container helps. For PHP-FPM, php-fpm8.3 -i shows the values new workers receive.
What you can take away: session.upload_progress.enabled = 0 and, on 8.5, fatal_error_backtraces = 0 cost almost nothing and should stay after the update. memory_limit = -1 is a trade-off and should be reverted after the update.
What the stopgaps cost and what does not help
- Uploads stop working: media in the CMS, forms with attachments and upload components in frameworks return 403. Some forms send multipart even without a file; those break too.
- Exceptions stay exposed: every allowed address or route is open until the update. Keep an exception as small as possible and derive it only from your actual traffic.
- Not sufficient: the application’s maintenance mode or login, because the attack works earlier; hiding warnings with error_reporting, display_errors or @; disable_functions, which at most limits the damage after a break-in. A small post_max_size reduces the risk but does not close the vulnerability.
- The proxy only protects what passes through it: directly reachable PHP servers, admin ports and internal services without a proxy remain open. That includes devices with a PHP web interface such as firewalls and NAS systems.
A familiar pattern: earlier “shell” vulnerabilities
PHP2Shell joins a series in which a widely used tool processes input from the network before any application logic can step in. The defence follows the same three steps every time: know your inventory, catch the trigger at the edge, then patch quickly.
- Shellshock (2014): Bash executed commands from environment variables that a web server set from request headers via CGI. There, too, the attack ran before the actual script started.
- Tracking down Heartbleed in the data centre (2014): shows why an inventory across all services matters. As with PHP, the vulnerable library sat in more services than one would guess at first.
- ImageTragick (2016): ImageMagick turned uploaded image files into commands. The stopgap was likewise a block in front of the parser, back then via policy.xml.
- Log4Shell (2021): the Java library Log4j evaluated logged strings and loaded code. The post also asks who pays for such fixes to free software; according to heise, the code audit commissioned by the PHP Group in 2024 and the PHP Ecosystem Security Team founded in May 2026 are one answer to that.
- regreSSHion (2024): a race condition in OpenSSH before login. As with PHP2Shell the attack needed many attempts but could be automated.
Once the update is installed
- Check the package version or php -v in every service and restart PHP-FPM, Apache with mod_php and long-running PHP workers. Pull or rebuild container images and recreate the containers.
- Only then remove the proxy block and test that uploads work again. Set memory_limit back to its old value.
- Keep session.upload_progress.enabled = 0 and, on 8.5, fatal_error_backtraces = 0 as long as no application needs these features.
- Look for traces: new PHP files in web directories, a set auto_prepend_file, new cron jobs or systemd units, new SSH keys and new admin accounts in applications. If in doubt, isolate the server, preserve the logs and renew all credentials on the system.
Numbers at a glance
- PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 of 21 October 2026 fix CVE-2026-109618 and CVE-2026-109619, both CVSS v4.0 9.2.
- For PHP 7.4, 8.0 and 8.1 there are only minimal source backports from the security team, no regular releases.
- Ubuntu 24.04 ships the fix as php8.3 8.3.6-0ubuntu0.24.04.13; the PHP version number stays 8.3.6.
- fatal_error_backtraces is on by default in PHP 8.5 and the basis of the confirmed attack on mod_php.
- session.upload_progress.enabled is on by default and cannot be switched off effectively with ini_set() or a .user.ini.
References
- PHP: Security advisory, Critical remote code execution vulnerabilities (21 Oct 2026)
- GHSA-w8hw-w9fx-j79w: RCE via OOM corruption (CVE-2026-109618)
- GHSA-6wmf-4rv5-p3rp: RCE via stale global state (CVE-2026-109619)
- php-src: commit “Avoid state corruption via bailout”
- Ubuntu: php8.3 8.3.6-0ubuntu0.24.04.13 (noble-security)
- PHP: Upcoming Critical PHP Security Release (20 Oct 2026)
- heise: php2shell, Kritische Sicherheitslücken erlauben Codeeinschleusung in PHP (German)
- PHP manual: Session Upload Progress
- Caddy: request matchers
- Apache 2.4: expressions in <If>
Remarks
- The first version was written on the morning of 21 October 2026, before the release, based on the announcement and early reports. This evening version follows the PHP team’s security advisory and its two detailed advisories; I replaced outdated statements rather than leaving them in.
- I checked the Caddy rule from outside on a production Caddy 2.11: multipart POST 403, normal pages 200. The Ubuntu and Docker details come from my own queries on the afternoon of 21 October. I have not tested the Apache rules, hence the section on testing.
Read the PHP team’s security advisory
Links to the original source and the Web Archive open in a new tab.