PHP2Shell: A Patch Playbook for the Two Critical PHP RCEs

·13 min read·Evergreen Tools Team

On October 21, 2026 at 14:00 UTC, the PHP project shipped 8.5.12, 8.4.27, 8.3.36 and 8.2.35, fixing two vulnerabilities of equal severity: CVE-2026-109618 and CVE-2026-109619, both rated CVSS v4.0 9.2. Neither is a framework bug or a plugin misconfiguration. Both are remote code execution in the PHP engine itself, and under the right conditions an unauthenticated attacker can run code on the server. Wiz's research team named the pair PHP2Shell and noted that PHP powers roughly 70% of websites, and that nearly every PHP version released in the last decade, back to 5.4, is affected by at least one of them. This post is not about panic. It is about order: confirm what you are running, inventory how many boxes run it, patch in batches, look for evidence it was already exercised, and put the version floor into your pipeline.

Server patching and security audit

This one lives in the PHP engine, not in your application

1. Two CVEs, One Common Thread

Per the PHP Foundation, CVE-2026-109618 is RCE via OOM corruption: if a request hit memory_limit at exactly the wrong moment, PHP could leave partially initialized internal data behind and later treat it as valid, corrupting memory. On Apache with mod_php an unauthenticated request can trigger it before any application code runs, and full remote code execution was demonstrated with default settings; RCE was also confirmed on PHP-FPM through application-level code paths. CVE-2026-109619 is RCE via stale global state: a fatal error at the wrong point leaves internal state pointing at memory that belonged to a previous request, and a later request handled by the same worker can use that freed memory. A use-after-free and worker crash were confirmed, and the reporter demonstrated RCE against a real-world application on Apache with mod_php. The common thread matters: both sit in memory management and error handling, code paths nearly every request touches.

# 1) Find out exactly what you are running, per branch.
#    Patched releases: 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35.
php -v | head -1
php -r 'echo PHP_VERSION, PHP_EOL;'
php -i | grep -E '^Server API|^Loaded Configuration File'

# Debian / Ubuntu: list every installed PHP package and its version
dpkg -l | grep -E '^ii  php[0-9.]+-(cli|fpm|common|apache2)' | awk '{print $2, $3}'

# RHEL / Rocky / AlmaLinux
rpm -qa | grep -E '^php' | sort

2. Why There Is No Clever Mitigation This Time

The most common comfort in a security incident is that the WAF will cover it. Add the WAF rules, but understand what they are: mitigation, not a fix. With mod_php, CVE-2026-109618 can fire before your application code runs, which means your framework firewall, input validation and parameterized ORM queries have not entered the room yet. Moving from mod_php to PHP-FPM genuinely raises the bar, since the confirmed FPM path runs through application-level code, but it is not immunity: the broken logic is still in the engine. Only one action removes the risk, and that is upgrading to a fixed release. So the right shape is this: patching is the main task, and WAF rules, memory limits and least privilege are ways to shrink the blast radius during the patch window, not substitutes for it.

# 2) Inventory every host, not just the box you remember.
import json, subprocess

# The floor for each supported branch, from the October 21 advisory.
PATCHED = {"8.2": (8, 2, 35), "8.3": (8, 3, 36), "8.4": (8, 4, 27), "8.5": (8, 5, 12)}

def parse(v):
    return tuple(int(x) for x in v.split("-")[0].split(".")[:3])

def status(v):
    branch = ".".join(str(x) for x in parse(v)[:2])
    floor = PATCHED.get(branch)
    if floor is None:
        return "UNSUPPORTED"          # older branches have no fix: migrate
    return "ok" if parse(v) >= floor else "PATCH NOW"

hosts = json.load(open("hosts.json"))  # [{"name": "web-01", "cmd": "ssh web-01"}]
for h in hosts:
    v = subprocess.run(h["cmd"] + " php -r 'echo PHP_VERSION;'",
                       shell=True, capture_output=True, text=True).stdout.strip()
    print(h["name"].ljust(20), v.ljust(10), status(v) if v else "unreachable")

3. Confirm What You Are Actually Running

The first move is not apt upgrade. It is reading version numbers clearly, because that determines everything downstream. If you run 8.1, a fix does not exist for you and the work is a migration, not an upgrade. If a box carries three PHP branches and you upgrade 8.4 while missing 8.3, you have not reduced your attack surface at all. If you assume production is 8.4.27 while the image tag is still 8.4.20 because the Dockerfile uses a floating tag, you are patching a belief. The first snippet does one thing: it prints php -v, the Server API, the loaded configuration file and every installed PHP package, so a human can look at it. The second turns that into an inventory: collect the version from each host, compare it against a branch-to-fixed-release table, and emit ok, PATCH NOW or UNSUPPORTED. The first is confirmation. The second is scale. Once the list exists, the number of machines that genuinely need emergency work is usually smaller than you feared and larger than your manager expects.

# 3) Patch in batches and prove each batch before the next one.
sudo apt-get update
sudo apt-get install -y --only-upgrade php8.4-fpm php8.4-cli php8.4-common
sudo systemctl restart php8.4-fpm
php -v | head -1                      # must print 8.4.27 or newer

# Verify the app, not just the binary
curl -fsS -o /dev/null -w '%{http_code}\n' https://canary.example.com/healthz

# Watch error rate and worker restarts for 15 minutes. Only then move on.
journalctl -u php8.4-fpm --since "15 min ago" | grep -ci "restart"

# Never upgrade the whole fleet in one window. A rollback plan you can
# execute in one command beats a fast rollout you cannot undo.

4. Patch in Batches, Not in One Window

The thing to fear during a patch window is not slowness, it is disorder. Work in batches: upgrade one canary, restart FPM, confirm php -v prints the fixed release, hit the health endpoint, then watch worker restarts and error rate for fifteen minutes. One detail gets skipped constantly: a successful binary upgrade is not a successful application upgrade. These fixes touch memory management and error handling, which should be compatible, but whether your third-party extensions were built against them and still behave the same is only knowable from real traffic. So the version number is necessary, not sufficient. Move the next batch to one availability zone or a low-weight set of instances, then go wide. Write the rollback plan and test it once. A slow path you can reverse with one command is always cheaper than a fast path you cannot.

# 4) Assume nothing: look for evidence the flaw was already exercised.
#    Both CVEs end in memory corruption, so the trail is in crashes.
grep -riE "segmentation fault|zend_mm_heap corrupted|out of memory" \
  /var/log/php*.log /var/log/nginx/error.log 2>/dev/null | tail -20

ls -lt /var/crash/*.crash 2>/dev/null | head
journalctl -u php8.4-fpm --since "30 days ago" | grep -ci segfault

# A crash is not proof of compromise, but it is a reason to rotate every
# secret that worker could read: database passwords, API keys, session keys,
# and any certificate mounted into the process. Wiz notes the disclosed
# memory could contain credentials belonging to other requests.
git log -1 --format=%cd -- .env.example 2>/dev/null

5. Go Back and Check Whether It Was Already Used

Patching is not the end of the incident. Both CVEs land in memory corruption, so their fingerprints differ from ordinary injection: worker crashes, segfaults, zend_mm_heap corrupted, unusual stacks in core dumps. The third snippet hunts for exactly those. Be precise about the boundary: a crash is not proof of compromise, and it may have another cause entirely. But it is a reason to start forensics, especially when the crashes cluster before the patch date. More importantly, even with no trace at all, if the box ran an affected version you should consider rotating what it could read. Wiz states plainly that the disclosed memory could contain passwords, authentication tokens, API keys and certificates belonging to other requests or the server itself. Rotating a key is a bounded cost. A stolen database password is not.

# 5) Make the next emergency boring: assert the floor in the pipeline.
#    .github/workflows/php-version.yml (the idea ports to any CI)
- name: Assert patched PHP
  run: php -r 'exit(version_compare(PHP_VERSION, "8.4.27", ">=") ? 0 : 1);'

- name: Fail on unpinned PHP base images
  run: |
    if grep -Rn "FROM php:" Dockerfile* | grep -vE "8\.4\.27|8\.5\.12"; then
      echo "base image is not on a patched tag"; exit 1
    fi
    echo "base images pinned"

# Add a weekly dependency-freshness job that opens a PR when a supported
# branch publishes a security release. Patch nights should be scheduled,
# not improvised at 2am.

6. Put the Floor Into the Pipeline

After every incident, the most common deliverable is a postmortem, and then the same incident again next year. What compounds is fixing the floor: a CI assertion that fails when the PHP version is below the patched release, Dockerfiles that pin base images to patched tags instead of floating ones, and a weekly freshness job that opens a pull request when a supported branch ships a security release. One more thing deserves saying. The PHP Foundation itself called the delay a structural failure: a consensus-driven security team struggled to choose between fix strategies, each with its own trade-off, and a small group of core developers with the necessary engine knowledge was stretched thin, so work stalled and restarted over months. They also pointed at the 2024 external audit funded by the Sovereign Tech Agency, organized through OSTIF and carried out by Quarkslab: while fixing one of the audited issues, a foundation-contracted core developer found the first signs of the OOM problem. Maintenance capacity for open infrastructure can be bought. Sponsoring a foundation is cheaper than handling an RCE at three in the morning.

Crash logs as forensic evidence

Both CVEs land in memory handling, so the trail is in crash logs

📌 Frequently Asked Questions

Which version should I upgrade to?

The advisory names fixed releases for four branches: PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Move to that release or newer on your branch. Older branches are no longer maintained and have no official fix, so the work there is a migration, not an upgrade.

What is the difference between the two CVEs?

CVE-2026-109618 is OOM corruption: a request hitting memory_limit at the wrong moment leaves partially initialized internal data that PHP later treats as valid. CVE-2026-109619 is stale global state: a fatal error leaves internal state pointing at a previous request's memory, which a later request on the same worker can reuse. Both live in memory management and error handling.

I have a WAF and a CDN in front. Do I still need to patch?

Yes. A WAF is a mitigation, not a fix. Per the PHP Foundation, on Apache with mod_php an unauthenticated request can trigger CVE-2026-109618 before any application code runs, with full RCE demonstrated under default settings, at which point application-layer defenses have had no chance to act. The patch fixes the engine itself.

How do I tell whether this was already exploited?

Look for memory-corruption fingerprints: worker crashes, segfaults, zend_mm_heap corrupted, core dumps, and clusters of abnormal errors in FPM and web server logs. No trace does not mean safe, so for machines that ran an affected version, rotating the credentials they could read is the safer default.

Why did the fix take so long?

The PHP Foundation explains it directly: the OOM fix required choosing among competing approaches, each with a cost in backward compatibility, new denial-of-service risk, or coordination across many third-party extensions. The consensus-driven security team struggled to choose, and the small set of core developers with deep engine knowledge was already stretched, so work stalled repeatedly. They call the delay a structural failure.