← Back to Blog

php2shell Mitigation: 7 Steps Before Patching

On October 21, 2026, two remote code execution vulnerabilities in PHP went public: php2shell, CVE-2026-109618 and CVE-2026-109619, both CVSS v4.0 9.2.

The real fix is to update. Some environments cannot update today, though, because of testing or migration work. A production server still on PHP 7.4, which gets no official fix at all, is the hardest case.

This post collects 7 mitigations you can apply before you can patch, using php.net’s advisory as the primary source. For each one it also lists what it does not cover.

A worker stacks sandbags and temporary barriers while a permanent steel door is still being built behind scaffolding. Mitigations hold the line until the real fix

What php2shell does

According to php.net, the issue affects “all PHP installations serving web requests”: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The risk is highest for mod_php, where a demonstrated exploit lets an unauthenticated request trigger the attack before any application code runs.

CVEWhat it is (summarized from php.net)
CVE-2026-109618Out-of-memory handling can interrupt PHP while internal request data is only partially initialized. Request cleanup may then treat the invalid data as valid
CVE-2026-109619Request-owned internal state can survive after its memory has been freed. A later request on the same worker may use that stale state before initialization resets it

Sansec describes two outcomes for an attacker: code execution without login, and a Heartbleed-like memory leak that can expose passwords, session tokens and API keys from other users’ requests. Sansec also notes that every known trigger is a multipart/form-data request.

The official bottom line: no complete mitigation

php.net puts it plainly:

There are no complete mitigations, updating is the only safe choice.

The seven steps below buy time until you update. Applying all of them does not make a server safe.

BranchStatus
8.2 / 8.3 / 8.4 / 8.5Fixed (8.2.35 / 8.3.36 / 8.4.27 / 8.5.12)
7.4 / 8.0 / 8.1No more releases from the PHP project. Minimal source backports only

7 mitigations before you can patch

1. Set session.upload_progress.enabled to 0 (CVE-2026-109619)

php.net lists this as the mitigation for the confirmed session paths of CVE-2026-109619.

; /etc/php.d/99-php2shell-mitigation.ini
session.upload_progress.enabled = 0

In an FPM pool:

; /etc/php-fpm.d/www.conf
php_admin_flag[session.upload_progress.enabled] = off

Two things to watch:

  • ini_set() and FPM .user.ini do not work. Use php.ini, a pool or server configuration that applies before request parsing
  • Restart every worker

php.net is clear that this blocks the confirmed paths only, not other variants.

2. Set memory_limit to -1 (CVE-2026-109618)

CVE-2026-109618 starts from the PHP memory limit failure. php.net suggests removing that limit in a way applications cannot override:

; FPM pool
php_admin_value[memory_limit] = -1

Check that no pool, directory or application setting puts a finite limit back. php.net adds that an underlying allocation failure can still reach the vulnerable path.

Without a limit, one request can eat all the memory. Pair this with step 4.

3. Lower post_max_size and limit size at the edge (CVE-2026-109618)

php.net’s numbers:

Applicationpost_max_size
No file uploads8M
Needs uploads32M

Keep upload_max_filesize below post_max_size.

php.net also recommends enforcing limits at the web server or proxy for decoded request size, input field count, multipart part count, header count and nesting depth, including requests without a usable Content-Length. In nginx, client_max_body_size at least caps the whole body.

4. Put a container or cgroup memory limit around PHP

Step 2 removes PHP’s own limit, so put an availability boundary outside it. php.net recommends a policy that kills only the affected worker.

With systemd:

# systemctl edit php-fpm
[Service]
MemoryMax=4G
OOMPolicy=continue

Without OOMPolicy, a single worker killed by the OOM killer can take the whole service down, because systemd’s default is stop.

Do not use RLIMIT_AS or RLIMIT_DATA instead. php.net warns they can cause the vulnerable allocation failure themselves.

5. Block multipart at the edge

Per Sansec, every known trigger is multipart/form-data. Pages that never take uploads have no reason to receive multipart. Blocking it in a WAF or reverse proxy removes paths before they reach PHP.

At a checkpoint, a guard stops a courier carrying a tied-up stack of boxes while another courier with a single envelope walks through the next lane

In AWS WAF, block requests whose Content-Type contains multipart and whose Host is not the one that needs uploads:

{
  "Name": "block-multipart-except-upload-host",
  "Priority": 0,
  "Action": { "Block": {} },
  "Statement": {
    "AndStatement": {
      "Statements": [
        { "ByteMatchStatement": {
            "SearchString": "multipart",
            "FieldToMatch": { "SingleHeader": { "Name": "content-type" } },
            "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
            "PositionalConstraint": "CONTAINS" } },
        { "NotStatement": { "Statement": { "ByteMatchStatement": {
            "SearchString": "admin.example.com",
            "FieldToMatch": { "SingleHeader": { "Name": "host" } },
            "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
            "PositionalConstraint": "EXACTLY" } } } }
      ]
    }
  },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "block-multipart-except-upload-host"
  }
}

Check three things first:

  • Which features really use multipart. Look at FormData / $_FILES in the code and at POST paths in the access logs
  • Whether the exclusion can be bypassed. The Host header can be spoofed. Make sure the PHP paths on the excluded host are protected, for example by an IP allowlist. If several sites share one Web ACL, sending “the other site’s admin host” to a different ALB slips through. Use one ACL per ALB
  • Whether it actually blocks. After switching to Block, send a body-less GET with a multipart Content-Type to a static file. PHP does not parse GET bodies, so this is safe in production
curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'Content-Type: multipart/form-data; boundary=x' \
  https://www.example.com/robots.txt
# 403 means the rule works

Multipart to the excluded host still gets through, so steps 1 to 4 have to cover it.

6. Extra setting on PHP 8.5, and remove early diagnostics (CVE-2026-109619)

php.net lists more for CVE-2026-109619:

  • On PHP 8.5, set fatal_error_backtraces=0 and restart every worker. This blocks only the confirmed fatal backtrace path
  • Remove invalid or deprecated settings applied per request that emit diagnostics before initialization. Hiding them with error_reporting, display_errors or @ does not help
  • Fix fatal errors and execution timeouts an attacker can reach, including failures in callbacks, upload or import processing and cleanup

7. On PHP 7.4 / 8.0 / 8.1, apply the backports or Remi’s update

For branches without an official release, php.net published minimal source backports (7.4 / 8.0 / 8.1) for people building from source.

Many RPM-based PHP 7.4 servers use Remi’s repository (rpms.remirepo.net). The php.net advisory said “the repo will not have updates today”, but 7.4.33-31 was published on 10/21 anyway.

# module packages (for SCL use 'php74-php*')
sudo dnf update 'php*'

# confirm it from the changelog
rpm -q --changelog php-common | head -5

You are on the update if the changelog shows:

- Mitigation fix Avoid state corruption via bailout
  CVE-2026-109618, CVE-2026-109619

It is labeled a “Mitigation fix”. The changelog alone does not tell you whether it matches the 8.x fixes.

Roll it out one server at a time

Changing ini settings needs a php-fpm reload. With process_control_timeout at its default of 0, in-flight requests return 502 at the moment of the reload.

Behind a load balancer, take servers out one by one:

aws elbv2 deregister-targets --target-group-arn "$TG_ARN" --targets Id="$ID"
aws elbv2 wait target-deregistered --target-group-arn "$TG_ARN" --targets Id="$ID"

# on the server: drop in the config, reload, check the value
#   systemctl reload php-fpm
#   php-fpm -i | grep session.upload_progress.enabled

aws elbv2 register-targets --target-group-arn "$TG_ARN" --targets Id="$ID"
aws elbv2 wait target-in-service --target-group-arn "$TG_ARN" --targets Id="$ID"

php-fpm -i shows php.ini values. For values set in a pool with php_admin_value, check the output of php-fpm -tt.

With Auto Scaling, settings applied by hand do not reach new instances. Update the launch template or AMI too.

What I saw on my own production system

On the night of the disclosure, I applied step 1 (session.upload_progress.enabled=0) and step 5 (multipart block) to a PHP 7.4 production system I maintain, one server at a time with no downtime.

Right after the WAF went in, multipart POSTs arrived: 6 IPs in 5 countries, each sending the same two payload sizes. Every User-Agent was Python aiohttp.

AttackOutcome
The first one (to the apex domain)The apex to www 301 redirect turned the POST into a GET, so the payload never arrived
The next 5 IPsBlocked by the multipart WAF rule

A robot carrying red parcels is detoured by an arrow sign and drops them on the way. The 301 turned the POST into a GET, so the payload never arrived

Within what I checked, there were no signs of compromise. The full timeline is in my Qiita write-up (Japanese).

Where the docs and reality differed

These are things you only learn by stepping on them.

ExpectationWhat happened
php.net: “Remi will not have updates today”7.4.33-31 shipped the same day. It was in the repository before any post on Remi’s blog
Switching the WAF rule from Count to Block makes it blockFeeding get-web-acl output straight back into update-web-acl double-encoded the search string, so the rule looked for bXVsdGlwYXJ0 instead of multipart. No error
Adding managed rules is a safe extraCommonRuleSet’s NoUserAgent_Header matched large amounts of legitimate traffic from a native app and WebSockets. Blocking right away would break the app

After any WAF change, decode the search string and confirm the curl test returns 403:

aws wafv2 get-web-acl --scope REGIONAL --name "$ACL_NAME" --id "$ACL_ID" \
  | jq -r '.WebACL.Rules[0].Statement.AndStatement.Statements[0]
           .ByteMatchStatement.SearchString' | base64 -d
# should print: multipart

What stays open, and checking for compromise

What remains even with all seven in place:

Still openWhy
Variants beyond the confirmed pathsphp.net says steps 1 and 6 cover confirmed paths only
Pages that legitimately accept multipartThe host excluded in step 5 still receives it
Allocation failures outside PHP’s limitCan still reach the vulnerable path with step 2
A server that was already compromisedMitigations close the door. A planted web shell or cron job stays

For that last row, check for compromise:

An engineer with a flashlight and clipboard checks behind server racks one by one

  • Are php-fpm worker crashes (signal 11 and similar) up?
  • Any memory limit or execution timeout errors?
  • Any shells or unexpected child processes under php-fpm?
  • Are recently modified PHP files and binaries all from your own releases?
  • Any cron changes or unfamiliar outbound connections?

When counting log lines per day, do not sort 21-Oct-2026 style dates as plain strings. They do not sort by month, and tail can drop the latest month. Filter by the month first.

Once you have updated, you can remove the mitigations. Before you do, confirm the fixed version from the version string or the changelog.

References