What our agent ran, why each privileged command was allowed, and the fixed builds, restart checks and origin lockdown to do it yourself.
PHP shipped fixes on October 21 for two critical bugs, known together as php2shell, that can let an unauthenticated request run code on a PHP web server. Every way PHP serves web requests is affected, and php.net puts the risk highest on Apache with mod_php, where a demonstrated exploit can trigger before any application code runs.
If you run PHP in production, an upgraded package alone doesn't close this. The fix is PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35 or your distribution's backport, then a restart of whatever serves PHP. Below is what we did on October 21 on an Ubuntu 22.04 mod_php host behind Cloudflare: the fixed build for each platform, how to prove the process serving your sites runs it, and an origin lockdown with its limits. We handed the patch to Claude Code, working through Alpacon. Six minutes after a person approved its session, Apache was serving patched PHP, and nobody handed the agent root. The section on our run shows how.
What php2shell is and who's affected
TL;DR: php2shell is two critical PHP bugs, CVE-2026-109618 and CVE-2026-109619, that can allow unauthenticated remote code execution through any server API (SAPI) PHP uses to serve web requests. Upgrade to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or your distribution's backport, then restart PHP.
php.net: "PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 fix two critical vulnerabilities that can allow unauthenticated remote code execution."
- CVE-2026-109618 (GHSA-w8hw-w9fx-j79w): when PHP bails out on an out-of-memory error, request data can be left half-initialized, and shutdown reads it anyway.
- CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp): request-owned state survives after its memory is freed, and a later request on the same worker uses it.
PHP's own CVE Numbering Authority (CNA) scores both 9.2 Critical (CVSS 4.0); Red Hat rates them Important. The scope is "all PHP installations serving web requests, including PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed." On PHP-FPM, CVE-2026-109619's session-lifecycle path is validated as RCE; for CVE-2026-109618, no complete exploit was demonstrated.
PHP 8.1 and older get no upstream release, only minimal source backports for 7.4, 8.0 and 8.1. On Ubuntu 22.04, php8.1 8.1.2-1ubuntu2.28 carries the fix (USN-8948-1, CVE-2026-109618, CVE-2026-109619). CISA: "Organizations that cannot update immediately, or that continue to use PHP 8.1 or earlier unsupported versions, should take PHP-based systems offline until they can be patched or updated." On Ubuntu 22.04 the Canonical package is the patch, so that advice applies until 8.1.2-1ubuntu2.28 is installed and Apache restarted.
Exploitation is where the primary sources disagree. CISA: "CISA has not observed exploitation of these vulnerabilities as of the time of publication," and neither CVE is in its Known Exploited Vulnerabilities (KEV) catalog. Cloudflare: "we observed attack traffic targeting our customers."
How we ran it: Claude Code on production, without root
TL;DR: Claude Code patched our production server in about six minutes without root. Sudo stayed off until a person approved a named command list, and Alpacon's timeline shows why each sudo command was allowed: a person's pre-approved list, or Alpacon's own risk assessment of the exact command.
Claude Code worked through the CLI of Alpacon, which runs commands on servers inside approved Work Sessions and records them on the session's timeline. The question this run tests is whether an agent can do privileged work on production without being handed root.
The patch
-
A command-only session. Claude Code first requested a Work Session with only the
commandscope: one server, a one-hour expiry, a purpose line. By default, a session an AI agent requests needs a person's approval before it becomes active; a person approved this one in the Alpacon console. -
A declared sudo list. The first
sudo apt-get updatewas refused before it ran (WORK_SESSION_SCOPE_NOT_ALLOWED): the session had nosudoscope. Claude Code asked to widen the same session tocommandandsudo, with a short list of sudo command patterns, and a person approved the change:apt-get update apt-get install --only-upgrade -y php8.1* libapache2-mod-php8.1 apache2ctl configtest systemctl restart apache2 ufw status verbose iptables -S ip6tables -S nft list ruleset ss -tlnpu -
The upgrade touched 18 packages, from
libapache2-mod-php8.1andphp8.1-commonto-phpdbg.
From the session's first approval to Apache serving patched PHP took about six minutes, including the second approval. The cost was an approval for each widening of the session, and a person had to be there to give it.
The firewall
For the origin lockdown (steps below), Claude Code requested these patterns for the same session and ran the first ufw allow before the person's approval came through:
ufw allow proto tcp from * to any port 80
ufw allow proto tcp from * to any port 443
ufw delete allow 80/tcp
ufw delete allow 443/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw status numbered
certbot renew --dry-run
The from * pattern reads as any source, so the list approved more than Cloudflare's 15 ranges. Listing the 15 ranges, one pattern per range and port, would have kept every approved allow to Cloudflare. The rollback lines are the deliberate exception: they reopen both ports to anyone.
What the record shows
This is what makes an agent's run auditable: every sudo command it ran is on Alpacon's session timeline with the reason it was allowed, so a person can check the whole run afterward. Before any of these ran, Claude Code had asked to add sudo to its session, listing the commands it planned to run, and a person approved that list. Our run's timeline:
| Sudo command | Result | Why it was allowed |
|---|---|---|
apt-get update (first try) | Refused | The session didn't have sudo yet |
apt-get update | Ran | It was on the approved list |
apt-get install --only-upgrade -y php8.1* … | Ran | A person had pre-approved it as a wildcard pattern (php8.1*); Alpacon assessed the exact command's risk and approved it |
ufw allow proto tcp from 173.245.48.0/20 to any port 80 | Ran | Requested as a wildcard pattern (from *) and run while that request awaited approval; Alpacon assessed its risk and approved it |
ufw delete allow 80/tcp | Ran | It was on the approved list |
Two commands were approved by Alpacon's own risk assessment rather than matched to the list. The install was pre-approved as a wildcard pattern, but Claude Code passed the glob php8.1* through as typed, so Alpacon judged the exact command itself. The first firewall rule ran while its pattern was still awaiting approval, and Alpacon judged that one too.
- Declare exact package names, and have the agent use those names, not a glob.
- Have the agent wait for approval before running anything newly declared.
- Pull the same view after a run with
alpacon work-session timeline <session-id>.
So could it? Yes, without root: sudo stayed refused until a person widened the session, and every sudo command Alpacon let through is on the record with the reason it was allowed, including the two Alpacon approved through its own risk assessment.
Fixed versions by platform
Status as of 2026-10-22, 03:00 UTC. Vendors were still publishing; recheck before you act.
| Platform | Fixed build or action | Source |
|---|---|---|
| Ubuntu 22.04 | php8.1 8.1.2-1ubuntu2.28 | USN-8948-1 |
| Ubuntu 24.04 | php8.3 8.3.6-0ubuntu0.24.04.13 | USN-8948-1 |
| Ubuntu 26.04 | php8.5 8.5.4-0ubuntu1.5 | USN-8948-1 |
| Ubuntu 26.10 | php8.5 8.5.9-0ubuntu2.1 | USN-8948-1 |
| Ubuntu 20.04 and older | Ubuntu Pro ESM builds | USN-8948-2 |
| Debian | trixie php8.4 8.4.27-1~deb13u1; bookworm php8.2 8.2.35-1~deb12u1; forky and sid vulnerable (8.4.26-1); fix 8.4.27-1 recorded for unstable, not yet in the archive | Debian tracker |
| RHEL | 8, 9, 10 affected, 7 not. Fixed: RHEL 10 php 8.3 and php8.4; RHEL 9 default php, php:8.2 and php:8.3; RHEL 8 php:7.4 and php:8.2. Pending: RHEL 9 php:8.4 | RHSB-2026-013, Red Hat VEX |
| AlmaLinux | sudo dnf clean metadata && sudo dnf upgrade 'php*' | AlmaLinux |
| Rocky | Errata out for Rocky 10 php and php8.4, Rocky 9 php, php:8.2 and php:8.3, and Rocky 8 php:7.4 | Rocky advisories |
| Amazon Linux | Fixed: AL2023 php8.2–8.5 and AL2 php8.2 Extra. "No Fix Planned": AL2023 php8.1 and AL2 core php, where CISA's offline advice applies | ALAS |
Docker php | Official tags rebuilt October 21 for every architecture but riscv64; no 8.1 images. Re-pull, check the digest, redeploy | docker-library/php |
| From source | Signed tarballs; EOL branches build from PHP-x.y-CVE-2026-109618-109619 | php.net downloads, php-src |
| Windows | Fixed zips for 8.2 through 8.5 | downloads.php.net |
| cPanel, Heroku | EasyApache 4 25.91; on Heroku, redeploy | cPanel, Heroku |
On Ubuntu 22.04
Versions are Ubuntu's archive, which our host used; on the ondrej/php PPA, read its changelog.
sudo apt-get update
apt-cache policy php8.1 libapache2-mod-php8.1
sudo apt-get install --only-upgrade 'php8.1*' libapache2-mod-php8.1
- Refresh the lists as root. Our non-root
apt list --upgradable, on stale lists, showed no fix until a rootapt-get update. .27is not the fix. jammy.27, noble.12and 26.108.5.9-0ubuntu2went out on October 19 for other CVEs; any host still on.27is exposed, even if it updated after October 19.
If you can't patch yet
CISA's advice for a host you can't update immediately is to take it offline. If that isn't possible, php.net lists measures that "reduce exposure to specific attack paths but do not make an installation safe":
- CVE-2026-109619: set
session.upload_progress.enabled=0in php.ini, the FPM pool or the server config (notini_set()or.user.ini), then restart every worker. - CVE-2026-109618: set
memory_limit=-1viaphp_admin_value, a lowpost_max_size(8M without uploads, 32M with) and a cgroup memory limit.
Prove the process serving requests is patched
TL;DR: An upgraded package doesn't patch a running process. Restart the SAPI that serves PHP—Apache for mod_php, php-fpm for FPM—then confirm the package build, the service start time and a response from every site.
-
Check the build. On 22.04 the fix shows as the
-1ubuntu2.28suffix (on 24.04,0ubuntu0.24.04.13), not a new 8.1 release. Don't go byphp -v: it reports the CLI binary, which can differ from what serves the web.dpkg -l | grep php8.1 # look for 8.1.2-1ubuntu2.28 -
Restart the SAPI. mod_php runs inside Apache, so Apache has to restart. Test the config first:
sudo apache2ctl configtest && sudo systemctl restart apache2For FPM, restart its service (
sudo systemctl restart php8.3-fpm); for containers, redeploy. needrestart didn't list apache2 on our host; don't rely on it. -
Confirm the start time is after the upgrade, then hit every virtual host on the origin.
systemctl show -p ActiveEnterTimestamp apache2 grep ' upgrade libapache2-mod-php8.1' /var/log/dpkg.log # compare with the start time curl -sS -o /dev/null -w '%{http_code}\n' -H 'Host: www.example.com' http://127.0.0.1/ -
For certainty, ask the SAPI with a randomly named
phpinfo()page in the default site's DocumentRoot (/var/www/htmlon stock Ubuntu), fetched over localhost, then deleted. No version line means the request missed the file, not that PHP is unpatched:f=$(openssl rand -hex 8).php echo '<?php phpinfo();' | sudo tee /var/www/html/$f >/dev/null curl -s http://127.0.0.1/$f | grep -m1 -o 'PHP Version [^<]*' || echo 'no phpinfo page: check the DocumentRoot' sudo rm /var/www/html/$f
Lock the origin to Cloudflare
TL;DR: The patch is the fix; this only narrows exposure. If you're behind Cloudflare, its WAF rules see only traffic that comes through it, so allow its IP ranges on 80 and 443, delete the open rules, and confirm direct requests to the origin time out.
Cloudflare shipped seven emergency WAF rules in its Managed Ruleset and Free Ruleset; on a paid plan, Cloudflare's blog says to confirm they're in block mode. Its Authenticated Origin Pulls (AOP) docs name the gap: "anyone who discovers your origin server's IP address can send requests directly, bypassing Cloudflare." Before the change, a request straight to our origin's IP returned 200.
Check first
sudo ufw status verboseshowsStatus: activeandDefault: deny (incoming). If ufw is inactive, these rules change nothing: allow your SSH port, runsudo ufw enable, and confirmStatus: activebefore you go on.- Every hostname on the origin is proxied; a DNS-only record stops working.
- Live connections on 80 and 443 come only from Cloudflare's ranges:
ss -tn state established '( sport = :80 or sport = :443 )'.
Allow, then delete
Allow first, so sites never drop.
Review the list before you loop over it (15 ranges as of 2026-10-21):
curl -fsS https://www.cloudflare.com/ips-v4 -o cf-ips-v4.txt && cat cf-ips-v4.txt
for r in $(cat cf-ips-v4.txt); do sudo ufw allow proto tcp from "$r" to any port 80; sudo ufw allow proto tcp from "$r" to any port 443; done
sudo ufw status numbered # expect 30 new allow rules
Only when the allows are in:
sudo ufw delete allow 80/tcp
sudo ufw delete allow 443/tcp
If your open rules weren't added as allow 80/tcp and 443/tcp, delete those 80 and 443 rules in the form sudo ufw show added lists them. If your host has public IPv6, add the seven IPv6 ranges before the deletes. Docker-published ports skip ufw; filter them in the DOCKER-USER chain.
Verify
-
A direct request times out on both ports, from outside the host:
curl -m 10 -sS -o /dev/null -H 'Host: www.example.com' http://<origin-ip>/ curl -m 10 -sS -o /dev/null --resolve www.example.com:443:<origin-ip> https://www.example.com/ -
Every site responds through Cloudflare.
-
sudo certbot renew --dry-runpasses.
Rollback is sudo ufw allow 80/tcp and sudo ufw allow 443/tcp.
What it doesn't close
The allowlist proves only that a request came through Cloudflare, "not from your account specifically," as the AOP page puts it, so another Cloudflare account can still reach your origin. AOP with your own certificate, or Cloudflare Tunnel, closes that gap. Neither stops an exploit sent through Cloudflare, and Cloudflare's blog says "Customers should patch their PHP repos even with WAF protections."
Before you close the ticket
- Every host serving PHP shows the fixed build, container images included.
- Every SAPI restarted after the upgrade, by its start time.
- Any host you couldn't patch is offline or, if it can't be, has php.net's measures at server level and a dated patch plan.
- Your origin doesn't answer a direct request.
- When we checked, RHEL 9 php:8.4 was still pending, Debian forky/sid were still vulnerable, and AL2023 php8.1 and AL2 core php had No Fix Planned—look again.
From one server to the whole fleet
Unless you already manage versions across your whole fleet, finding every exposed host, patching it and proving the fix before attackers get there is close to impossible by hand. This time, Cloudflare was already seeing attack traffic.
Connect your fleet to Alpacon once, and an agent runs the whole loop in a single Work Session that spans every server: it finds each host on a vulnerable build, patches them in minutes, then checks each one is healthy, verifying every step as it goes instead of following a fixed script. A person approves that session once, and every agent session is approved and governed through Alpacon.
To run it on your own servers:
-
Connect your servers to Alpacon: install its agent, Alpamon, from the Alpacon console, or connect your AWS account and roll it out across your fleet at once.
-
Install the Alpacon CLI where Claude Code runs, and sign in:
brew install alpacax/alpacon/alpacon-cli alpacon login -
Give Claude Code this prompt:
Patch php2shell on our web servers through Alpacon. Open a work session, check each server's PHP build, upgrade the ones that need it, restart what serves PHP, and prove the fix is live. -
Approve the session when it asks, then follow the run on its timeline.
FAQ
Is PHP 8.1 on Ubuntu 22.04 patched even though upstream ended 8.1?
Yes, once 8.1.2-1ubuntu2.28 is installed and Apache or php-fpm has restarted. Canonical backported the fix in USN-8948-1.
Should I let an AI agent run the patch? It can, with three habits: keep sudo off until a person grants it, have the agent list exact package names and firewall rules and wait for approval before running them, and read the basis on every sudo grant row afterward. In our run, Alpacon approved two sudo commands through its own risk assessment where they didn't match the approved list as typed; see what our run's record showed.
