Skip to main content

PHP security stopgap (October 2026)

On 2026-10-21 the PHP project released PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35 to fix two critical vulnerabilities, CVE-2026-109618 and CVE-2026-109619 (together nicknamed "PHP2Shell"). Both allow unauthenticated remote code execution, both are rated CVSS 9.2, and exploit code was circulating before the release. nutritionistpro.com runs PHP 7.4.24, which gets no fix. This page records what was done to reduce exposure on the old instance while the move to a patched instance happens, and what was deliberately not done. The work is tracked in issue 746 (the PHP update) and issue 713 (the move).

These measures are partial

PHP's own advisory says that configuration changes "reduce exposure to specific attack paths but do not make an installation safe". Nothing on this page is a substitute for running a patched PHP. The fix is Moving the site to the new AWS tenant, and the measures here exist only to shorten the window before that happens.

Why the site cannot simply be updated​

  • The two bugs are memory-safety flaws in the PHP engine's request handling, triggered when a request hits a fatal error (typically running out of memory) during the engine's own parsing of a multipart/form-data body. This happens before WordPress or any plugin runs, so no WordPress update, security plugin, or application-level check can block it. WordPress.org confirmed that no WordPress release is planned for it.
  • PHP 8.1 and older receive no release. The PHP security team published a source-only backport branch for PHP 7.4 for people who compile PHP themselves. The Bitnami stack on the instance is a self-contained build under /opt/bitnami, and Bitnami's upgrade path for any component is a new instance plus a migration, so the site's PHP cannot be replaced in place.
  • The Bitnami WordPress blueprint on Lightsail is itself retired: AWS stopped shipping new versions on 2026-05-19 and removes it from the console on 2026-11-19. A fresh Bitnami instance launched today would carry an unpatched PHP 8.2.x with the same no-upgrade problem, which is why the migration runbook now targets the Lightsail-packaged WordPress blueprint instead. See Moving the site to the new AWS tenant.

How the site is exposed​

  • PHP runs under PHP-FPM behind Apache. That is the less exposed server API: PHP's advisory rates mod_php as the highest risk because a startup-stage fatal error leaves a reusable worker holding stale state, while PHP-FPM exits that worker. PHP-FPM is still affected through fatal errors reached after startup, for example in upload or import handling, so PHP-FPM lowers the exposure without removing it.
  • The origin answered HTTP and HTTPS directly on its public IP (54.162.107.22) and EC2 hostname, bypassing Cloudflare. Cloudflare deployed WAF rules for the known exploits on every plan including Free, but they protect nothing against an attacker who sends requests straight to the origin. This was already the open hardening item on Cloudflare.
  • The site needs multipart/form-data requests to work. Gravity Forms hard-codes enctype="multipart/form-data" on every form, so the desktop trial forms that feed Freshsales (see Trial sources) submit as multipart even though they upload no files, and so does every Media Library upload. A blanket multipart block would therefore cut off the site's lead source.

Step 1: confirm the Cloudflare protections​

In the Cloudflare dashboard, zone nutritionistpro.com (free plan):

  1. Security > WAF > Managed rules: the Cloudflare Managed Free Ruleset is enabled. Its "Generic Rules - Anomalous Execution" rules 1 to 7 are the emergency rules Cloudflare added for these CVEs, and on the Free plan they are on by default. The Cloudflare Managed Response Firewall Default ruleset carries a second rule ("Default block for High confidence Signatures") that blocks a matching origin response.
  2. Security > Events (or Security Analytics): filter by rule description "Anomalous Execution" to see whether attempts are already arriving and being blocked.
  3. DNS > Records: the apex and www records are still proxied. An unproxied record exposes the origin IP and skips the WAF.

Cloudflare's CTO described the rules as a stopgap, and Cloudflare's own post says to keep them enabled while patching. Nothing needs changing here unless a check fails.

Step 2: restrict the origin firewall to Cloudflare​

This closes the WAF bypass. Afterward the origin accepts HTTP and HTTPS only from Cloudflare's published address ranges, and SSH only from the address running the commands plus the Lightsail browser SSH client.

Three Lightsail constraints shape the commands. An instance may have at most 60 firewall rules, and each source range counts as a rule, so Cloudflare's published ranges (15 IPv4 and 7 IPv6 on 2026-10-21; the lists change occasionally) on two ports take 44 of them. The console accepts at most 30 sources per rule, so the AWS CLI is used instead. put-instance-public-ports replaces the entire rule set, so the SSH rule has to be in the same call or the instance becomes unreachable except through the browser client.

Run this from a local machine with the AWS CLI signed in to the old Axxya tenant (account 898347667108) and jq installed. The instance is Npro2 in us-east-1 (confirm with aws lightsail get-instances --query 'instances[].name' if in doubt).

INSTANCE=Npro2
MY_IP=$(curl -s https://checkip.amazonaws.com)
V4=$(curl -s https://www.cloudflare.com/ips-v4 | jq -R . | jq -s -c .)
V6=$(curl -s https://www.cloudflare.com/ips-v6 | jq -R . | jq -s -c .)
PORT_INFOS=$(jq -n -c --argjson v4 "$V4" --argjson v6 "$V6" --arg me "$MY_IP/32" '[
{fromPort: 22, toPort: 22, protocol: "tcp", cidrs: [$me], cidrListAliases: ["lightsail-connect"]},
{fromPort: 80, toPort: 80, protocol: "tcp", cidrs: $v4, ipv6Cidrs: $v6},
{fromPort: 443, toPort: 443, protocol: "tcp", cidrs: $v4, ipv6Cidrs: $v6}
]')
aws lightsail get-instance-port-states --instance-name "$INSTANCE" > lightsail-ports-before.json
aws lightsail put-instance-public-ports --instance-name "$INSTANCE" --port-infos "$PORT_INFOS"
aws lightsail get-instance-port-states --instance-name "$INSTANCE"

lightsail-connect is the only alias Lightsail supports; it stands for the browser-based SSH client in the console, so that route keeps working from anywhere. lightsail-ports-before.json is the record of the previous rules. To roll back, open the instance's Networking tab in the console and set the HTTP and HTTPS rules back to any IPv4 and IPv6 address.

Verify from the same machine:

curl -sk --max-time 5 -o /dev/null -w "%{http_code}\n" https://54.162.107.22/
curl -sk --max-time 5 -o /dev/null -w "%{http_code}\n" "https://[2600:1f18:1bb4:4500:af44:a308:37a7:ee7c]/"
curl -sI https://nutritionistpro.com/ | head -1
curl -sI https://nutritionistpro.com/wp-login.php | head -1

The first two commands must time out (exit code 28) rather than answer; the instance is dual-stack, so its public IPv6 address is a second way to reach the origin directly (the IPv6 check needs a machine with IPv6 connectivity, otherwise it fails for that reason instead). The last two must return 200 through Cloudflare. Then confirm an SSH session still opens from the local machine and from the console's Connect using SSH.

Two things keep working after the change. Let's Encrypt renewal, if the origin certificate uses it, validates over HTTP through the proxied hostname, and those requests arrive from Cloudflare's ranges. Cloudflare's own health and WAF traffic arrives the same way. Anything that reached the origin by IP, such as an uptime monitor pointed at 54.162.107.22, stops working; Hosting and deployment lists nothing of the kind, but check the monitors you know about.

The firewall was restricted this way on 2026-10-21 at 08:01 Pacific. The resulting rule set: 22 from the operator's IPv4 address plus lightsail-connect; 80 and 443 from the 15 IPv4 and 7 IPv6 Cloudflare ranges. Verification passed: both direct requests to the origin got no answer and the site returned 200 through Cloudflare. The previous rules (22, 80, and 443 open to 0.0.0.0/0 and ::/0) are in the operator's ~/lightsail-ports-before.json. Note that the SSH rule admits IPv4 only, so SSH from an IPv6-only network needs the browser client.

Take a manual snapshot of the instance in the Lightsail console first and wait for it to finish. Then connect as described in SSH access and locate the configuration files. The Bitnami layout is expected to be /opt/bitnami/php/etc/php.ini for the main file and a pool file under /opt/bitnami/php/etc/php-fpm.d/; confirm before editing:

php --ini | head -3
ls /opt/bitnami/php/etc/php-fpm.d/
grep -nE "^(session.upload_progress.enabled|memory_limit|post_max_size|upload_max_filesize)" /opt/bitnami/php/etc/php.ini

Confirmed on 2026-10-21: the loaded php.ini is /opt/bitnami/php/lib/php.ini (check with ls -la whether /opt/bitnami/php/etc/php.ini is the same file through a symlink before editing either path), extra .ini files are scanned from /opt/bitnami/php/etc/conf.d/, and the only pool is www.conf (pm=dynamic, socket /opt/bitnami/php/var/run/www.sock, no php_admin lines). Values before the change: memory_limit = 512M, post_max_size = 80M, upload_max_filesize = 80M, and session.upload_progress.enabled unset, which means on.

Back up both files, then add the following to the pool file. Pool-level php_admin_* directives cannot be overridden by ini_set(), .user.ini, or a plugin, which is what PHP's advisory asks for:

php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[memory_limit] = -1

In php.ini, set post_max_size = 32M and keep upload_max_filesize below it (PHP suggests 32M for applications that still need uploads, which this site does). The browser SSH client does not pass nano's keystrokes through reliably from a Mac, so the edits are done with tee and sed; php.ini is a regular file, so sed -i is safe on it:

sudo cp /opt/bitnami/php/etc/php-fpm.d/www.conf /opt/bitnami/php/etc/php-fpm.d/www.conf.bak
sudo cp /opt/bitnami/php/etc/php.ini /opt/bitnami/php/etc/php.ini.bak
printf '\n; Temporary mitigation for CVE-2026-109618 and CVE-2026-109619 (October 2026). Not needed on a patched PHP.\nphp_admin_flag[session.upload_progress.enabled] = off\nphp_admin_value[memory_limit] = -1\n' | sudo tee -a /opt/bitnami/php/etc/php-fpm.d/www.conf > /dev/null
sudo sed -i -E 's/^post_max_size = 80M$/post_max_size = 32M/; s/^upload_max_filesize = 80M$/upload_max_filesize = 24M/' /opt/bitnami/php/etc/php.ini
diff /opt/bitnami/php/etc/php.ini.bak /opt/bitnami/php/etc/php.ini
sudo /opt/bitnami/ctlscript.sh restart php-fpm
sudo /opt/bitnami/ctlscript.sh restart apache

Then prove the values inside a request served by PHP-FPM. The command-line php -i and php-fpm -i read php.ini without the pool overrides, and php-fpm -tt writes its dump to /opt/bitnami/php/logs/php-fpm.log rather than the terminal, so neither is a proof. This creates a check file with a random name, fetches it through Apache on the server, and deletes it:

F=/opt/bitnami/wordpress/stopgap-check-$RANDOM$RANDOM.php
echo '<?php header("Content-Type: text/plain"); echo "sapi=", php_sapi_name(), "\nupload_progress=", var_export(ini_get("session.upload_progress.enabled"), true), "\nmemory_limit=", ini_get("memory_limit"), "\npost_max_size=", ini_get("post_max_size"), "\nupload_max_filesize=", ini_get("upload_max_filesize"), "\n";' | sudo tee "$F" > /dev/null
curl -sk -H "Host: nutritionistpro.com" "https://127.0.0.1/$(basename "$F")"
sudo rm -f "$F"; ls "$F"

Expected: sapi=fpm-fcgi, upload_progress='0', memory_limit=-1, post_max_size=32M, upload_max_filesize=24M, and then "No such file" from ls, which confirms the check file is gone.

Applied and verified this way on 2026-10-21, and the trial form test below passed the same day. Then load https://nutritionistpro.com/wp-login.php, sign in, open Tools > Site Health > Info > Server, and submit a desktop trial form with a test entry to prove multipart submissions still work. The Diet Analysis Module form is on the public page https://nutritionistpro.com/demo-for-diet-analysis/ (submit it there as a visitor, in a private window; wp-admin never sends a multipart request). Then confirm the entry and its note in Forms > Entries, trash the entry, and delete the test contact in Freshsales, as Trial sources describes.

What each setting does, and its cost:

  • session.upload_progress.enabled = off removes the request path behind CVE-2026-109619's confirmed exploit. Upload progress bars in wp-admin stop updating. There is no other effect.
  • memory_limit = -1 removes the out-of-memory condition that CVE-2026-109618 needs. It is PHP's own recommendation for unpatched servers, and it means one runaway request can consume all of the instance's RAM. On a cached marketing site for a week or two this is an acceptable trade; watch memory with free -m if the site slows down.
  • post_max_size = 32M shrinks the request body an attacker can use to force an allocation failure. Imports larger than that (for example an All-in-One WP Migration upload) fail until it is raised again.
  • Optional: pm.max_requests = 1 in the pool makes every worker exit after one request, so stale state cannot survive into a second request. It costs throughput. WP Rocket serves anonymous visitors from static HTML without PHP, so the cost falls mainly on wp-admin. Not applied by default; add it if the Cloudflare events show sustained attempts.

To roll back, copy each .bak file back over the original and run the same two restart commands.

Step 4: look for signs of an attempt​

The attack leaves little behind in the common case, but a failed or noisy attempt does. Run these on the server, and repeat them before the content export for the migration so that a compromised site is not copied to the new instance:

sudo grep -iE "allowed memory|segfault|fatal error|exited on signal" /opt/bitnami/apache/logs/error_log | tail -40
sudo awk '$9 == 500' /opt/bitnami/apache/logs/access_log | tail -40
sudo find /opt/bitnami/wordpress/wp-content/uploads -name "*.php" -newermt "2026-10-15"
sudo wp --path=/opt/bitnami/wordpress user list --role=administrator
sudo crontab -l; crontab -l

What to look for: PHP-FPM workers crashing or restarting, a burst of 500 responses to POST requests, .php files anywhere under wp-content/uploads, an administrator account nobody created, or a cron entry nobody added.

Results on 2026-10-21, after the firewall and PHP changes:

  • Error log: the only fatal errors since the log began on 2026-10-18 are scanners requesting theme files directly (wp-content/themes/Divi/index.php and the bundled twentytwenty theme, several with referer sucuri.net), about one an hour, failing on get_header(). No out-of-memory errors, no worker crashes.
  • 500 responses: the same scanner requests plus one admin action by the operator. Nothing else.
  • No .php files under wp-content/uploads. Five administrators, all accounted for (see Hosting and deployment). The only cron entry is the certificate renewal.
  • The PHP-FPM log's "seems busy" warnings (168 since 2026-10-18) and the 110-worker peak trace to an XML-RPC flood on 2026-10-20 between 15:23 and 16:19 UTC: about 28,500 POST requests to /xmlrpc.php (and //xmlrpc.php), roughly six per second, every one answered 200, with an empty user agent. That is a brute-force or pingback attack, common against WordPress and unrelated to the two PHP bugs (XML-RPC bodies are XML, not multipart). It was still arriving at about 100 posts a minute at times on 2026-10-21. The follow-up is below.

If any of the warning signs turn up, treat the instance as compromised: isolate it (the firewall change in step 2 plus stopping Apache), preserve the logs, and rotate every secret the server held, including the database password, the WordPress salts, the WP Mail SMTP credentials, the Freshsales snippet's backend token under Code Snippets, and the license keys. Moving to a patched PHP does not remove a foothold that is already there.

Follow-ups found during the checks​

  • Block xmlrpc.php at Cloudflare. The XML-RPC flood above costs PHP capacity and is a password-guessing channel. Jetpack is the one installed plugin that needs XML-RPC, and only from Automattic's servers. A Cloudflare custom WAF rule (Security > WAF > Custom rules, free plan allows five) that blocks http.request.uri.path contains "xmlrpc.php" unless the source is in Jetpack's published address ranges stops the flood at the edge without reaching the origin. Applied on 2026-10-21 as the custom rule Block xmlrpc except Jetpack (action Block) on the nutritionistpro.com zone, with the expression (http.request.uri.path contains "xmlrpc.php") and not (ip.src in {192.0.64.0/18 122.248.245.244/32 54.217.201.243/32 54.232.116.4/32}). Check the Jetpack ranges against Jetpack's published list when Jetpack reports a connection problem. Note that the same rule was first created on the axxya.com zone by mistake, where it protects nothing, and was deleted; the zone picker at the top left of the Cloudflare dashboard is where that goes wrong.
  • Deny direct requests to theme and wp-includes PHP files. The hourly scanner fatals are "attacker-reachable fatal errors", which PHP's advisory asks to remove on an unpatched server. A .htaccess or vhost rule returning 403 for direct requests to wp-content/themes/*/*.php and wp-includes/*.php would silence them, but it needs a test pass of the Divi builder and the AIOVG player afterward, so it is optional and not applied.

What was deliberately not done​

  • Blocking multipart/form-data at Apache. It is the strongest partial mitigation available, and it would stop every trial form submission and every Media Library upload, because Gravity Forms always submits as multipart. Rejected while the forms are the site's lead source. If the Cloudflare events show the WAF being bypassed, this is the next step, and the cost is accepted until the move.
  • Read-only mode (rejecting every method except GET and HEAD). Stops logins, the contact form, and the trial forms. Same reasoning.
  • Building PHP 7.4 from the backport branch (PHP-7.4-CVE-2026-109618-109619 in php-src). The Bitnami build is not reproducible from the instance, and the result would still be an end-of-life PHP on an end-of-life stack.
  • Launching a new Bitnami instance. The blueprint has had no updates since 2026-05-19, so it would ship an unpatched and unpatchable PHP 8.2.
  • Exporting the snapshot to EC2 (the fallback in the migration runbook). It carries PHP 7.4 across unchanged.

When the move is done​

The move was done the same day: the site cut over to the new instance nutritionistpro-web on PHP 8.2.35 at about 19:05 UTC on 2026-10-21, so this page is now history, kept because the old instance still exists for rollback and because the firewall script and the log checks are reusable. Every setting on this page lives on the old instance and ends with it. Do not carry memory_limit = -1, the lowered post_max_size, or pm.max_requests onto the new instance, whose patched PHP does not need them. Keeping session.upload_progress.enabled = off on the new instance is harmless and reasonable. The Cloudflare-only firewall is part of the new instance's build in Moving the site to the new AWS tenant, so it stays.