AlpacaX

Incident

How we patched php2shell in six minutes with an AI agent

What our agent ran, why each privileged command was allowed, and the fixed builds, restart checks and origin lockdown to do it yourself.

Eunyoung Jeong
Eunyoung JeongFounder & CEO · October 21, 2026

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

  1. A command-only session. Claude Code first requested a Work Session with only the command scope: 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.

  2. A declared sudo list. The first sudo apt-get update was refused before it ran (WORK_SESSION_SCOPE_NOT_ALLOWED): the session had no sudo scope. Claude Code asked to widen the same session to command and sudo, 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
    
  3. The upgrade touched 18 packages, from libapache2-mod-php8.1 and php8.1-common to -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 commandResultWhy it was allowed
apt-get update (first try)RefusedThe session didn't have sudo yet
apt-get updateRanIt was on the approved list
apt-get install --only-upgrade -y php8.1* …RanA 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 80RanRequested as a wildcard pattern (from *) and run while that request awaited approval; Alpacon assessed its risk and approved it
ufw delete allow 80/tcpRanIt 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.

PlatformFixed build or actionSource
Ubuntu 22.04php8.1 8.1.2-1ubuntu2.28USN-8948-1
Ubuntu 24.04php8.3 8.3.6-0ubuntu0.24.04.13USN-8948-1
Ubuntu 26.04php8.5 8.5.4-0ubuntu1.5USN-8948-1
Ubuntu 26.10php8.5 8.5.9-0ubuntu2.1USN-8948-1
Ubuntu 20.04 and olderUbuntu Pro ESM buildsUSN-8948-2
Debiantrixie 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 archiveDebian tracker
RHEL8, 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.4RHSB-2026-013, Red Hat VEX
AlmaLinuxsudo dnf clean metadata && sudo dnf upgrade 'php*'AlmaLinux
RockyErrata out for Rocky 10 php and php8.4, Rocky 9 php, php:8.2 and php:8.3, and Rocky 8 php:7.4Rocky advisories
Amazon LinuxFixed: 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 appliesALAS
Docker phpOfficial tags rebuilt October 21 for every architecture but riscv64; no 8.1 images. Re-pull, check the digest, redeploydocker-library/php
From sourceSigned tarballs; EOL branches build from PHP-x.y-CVE-2026-109618-109619php.net downloads, php-src
WindowsFixed zips for 8.2 through 8.5downloads.php.net
cPanel, HerokuEasyApache 4 25.91; on Heroku, redeploycPanel, 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 root apt-get update.
  • .27 is not the fix. jammy .27, noble .12 and 26.10 8.5.9-0ubuntu2 went out on October 19 for other CVEs; any host still on .27 is 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=0 in php.ini, the FPM pool or the server config (not ini_set() or .user.ini), then restart every worker.
  • CVE-2026-109618: set memory_limit=-1 via php_admin_value, a low post_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.

  1. Check the build. On 22.04 the fix shows as the -1ubuntu2.28 suffix (on 24.04, 0ubuntu0.24.04.13), not a new 8.1 release. Don't go by php -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
    
  2. Restart the SAPI. mod_php runs inside Apache, so Apache has to restart. Test the config first:

    sudo apache2ctl configtest && sudo systemctl restart apache2
    

    For 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.

  3. 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/
    
  4. For certainty, ask the SAPI with a randomly named phpinfo() page in the default site's DocumentRoot (/var/www/html on 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 verbose shows Status: active and Default: deny (incoming). If ufw is inactive, these rules change nothing: allow your SSH port, run sudo ufw enable, and confirm Status: active before 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-run passes.

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:

  1. 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.

  2. Install the Alpacon CLI where Claude Code runs, and sign in:

    brew install alpacax/alpacon/alpacon-cli
    alpacon login
    
  3. 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.
    
  4. 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.

Eunyoung Jeong
About the authorEunyoung JeongFounder & CEO

Eunyoung Jeong is the founder and CEO of AlpacaX, where he's building Alpacon—AI-native PAM with runtime execution control for AI agents. He spent over a decade in national-scale network security research and created mTCP, a scalable user-level TCP stack published at USENIX NSDI '14 (USENIX Community Award, 2K+ GitHub stars). He writes on AI agent security and the gap between access control and execution control.


How we patched php2shell in six minutes with an AI agent | AlpacaX