DEV Community

Anusha Mukka
Anusha Mukka

Posted on

Exploitation Started Four Days Before the Patch: PHP2Shell and the End of the Patch Window

Attackers were recorded trying PHP2Shell on October 17, 2026. The patch shipped on October 21. If your exposure clock starts at disclosure, your clock is wrong by four days before you have done anything else.

Your patch process probably has a tidy timeline somewhere. A CVE drops, the ticket opens, the SLA clock starts, and your team measures itself against a number like 14 days for criticals. The timeline looks disciplined because everyone agrees on when it begins. It begins at disclosure.

PHP2Shell broke that agreement in public. The first recorded exploitation attempt happened before there was a public CVE, before there was a patch, and before your ticket could have existed. You have two bad options for handling that fact. Option one: keep measuring from disclosure, and accept that your reports describe a window that is shorter than the one attackers actually had. Option two: rebuild the clock around a date you usually do not know, the first exploitation, and accept that some of your exposure numbers will be estimates with a floor instead of a clean start time. The second option is less comfortable. It is also the only honest one left.

What Actually Happened, With Dates

PHP2Shell is the name Wiz gave to two memory safety flaws in the PHP engine itself, CVE-2026-109618 and CVE-2026-109619, each rated 9.2 under CVSS v4.0. Exploited, they give an unauthenticated attacker remote code execution on a server handling web requests. The vulnerable code dates back to PHP 5.4 in 2012, and PHP runs roughly 70 percent of websites whose server side language is known, so a minimal hello world application on an affected version was in scope. Wiz demonstrated working attacks against current WordPress, Drupal, and Joomla releases. The flaws were in the engine underneath all three.

The discovery story matters as much as the bug. Wiz found its first flaw on August 19, 2026, using its Atlas tooling alongside frontier models, and says one researcher did in about a week what previously took an expert team months. A second firm, Calif, working inside OpenAI's Patch the Planet effort, had independently found the same root cause earlier in August. Three separate parties reported the same underlying weakness to the PHP security team between August 11 and September 18. Read that again: three independent finds, in about five weeks, for a bug that had sat in the code for fourteen years.

Then the coordinated part stopped being coordinated. Amazon's MadPot honeypot recorded a PHP2Shell exploitation attempt on October 17, four days before disclosure, and Amazon states plainly that it is unclear how the actor obtained the exploit information. The CVE identifiers leaked on October 20, which pulled the PHP release forward a day to October 21, in fixed versions 8.5.12, 8.4.27, 8.3.36, and 8.2.35. Within hours of the release, a proof of concept video was published by accident and a downstream distributor shipped patched binaries early. Attackers were confirmed running commands on honeypot servers by October 24, and CISA added both flaws to its Known Exploited Vulnerabilities catalog on October 26. Wikipedia, the Dutch DigiD government services, and payroll provider BambooHR took systems offline that week as a precaution while they patched.

The Gap Every Explainer Skips

Most coverage of PHP2Shell lands on one lesson: patch faster. That lesson is true and it is not enough, because it quietly assumes the race starts when the patch ships. The timeline above says the race started earlier, twice over. First, discovery is no longer a rare event you can count on happening once, to one careful researcher, under an embargo. When three parties find the same fourteen year old bug in five weeks, rediscovery is the base case, and at least one actor had a working exploit before anyone announced anything. Second, the patch itself is a map. Comparing the fixed code against the old code isolates the change, and the change points at the flaw. That technique, patch diffing, used to be slow specialist work. It is now work a model does well, which is why the interval between a fix landing and an exploit circulating is measured in hours in recent cases, not weeks.

Your exposure window does not start when you learn about the bug. It starts when the first attacker does, and you will usually find out about that date later, if ever.

The gap this piece fills is the accounting. If exploitation precedes disclosure, then two of your standard numbers are wrong in opposite directions. Your patch SLA, counted from disclosure, understates how long you were exposed. And your incident review window, which most teams open at the disclosure date, starts four days too late, so the log search that would find an early attempt never covers the days when it happened. Fixing the second number costs you one changed date in a query. Most teams have not changed it.

Count Exposure From the First Shot, Not the Announcement

Here is a small stdlib Python tool that does the accounting both ways for a toy fleet. It checks each host's PHP version against the fixed releases, then scores four common patch plans twice: once from disclosure, once from the first recorded exploitation attempt on October 17.

#!/usr/bin/env python3
from datetime import date

FIRST_EXPLOITATION = date(2026, 10, 17)  # Amazon MadPot honeypot attempt
PATCH_RELEASED = date(2026, 10, 21)      # PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35
CISA_KEV = date(2026, 10, 26)            # Known Exploited Vulnerabilities

FIXED = {"8.5": (8, 5, 12), "8.4": (8, 4, 27), "8.3": (8, 3, 36), "8.2": (8, 2, 35)}

def parse(version):
    return tuple(int(p) for p in version.split("."))

def status(version):
    branch = ".".join(version.split(".")[:2])
    if branch not in FIXED:
        return "AFFECTED, no fix (end of life branch)"
    if parse(version) < FIXED[branch]:
        return f"AFFECTED, patch to {'.'.join(map(str, FIXED[branch]))}"
    return "patched"

FLEET = [
    ("web-01", "8.4.26"),
    ("web-02", "8.4.27"),
    ("blog-01", "8.3.30"),
    ("legacy-01", "8.1.33"),
    ("api-01", "8.5.12"),
    ("shop-01", "8.2.28"),
]

PLANS = [
    ("same day as the patch", date(2026, 10, 21)),
    ("48 hour critical SLA", date(2026, 10, 23)),
    ("weekly patch cycle", date(2026, 10, 28)),
    ("monthly patch cycle", date(2026, 11, 4)),
]

for host, version in FLEET:
    print(host, version, status(version))

for name, patched in PLANS:
    print(name,
          (patched - PATCH_RELEASED).days, "days after disclosure,",
          (patched - FIRST_EXPLOITATION).days, "days after first exploitation")
Enter fullscreen mode Exit fullscreen mode

Run it against the full file (the version saved with this draft prints the fleet table and the KEV date as well) and you get this:

Fleet check (PHP2Shell, CVE-2026-109618 and CVE-2026-109619)
  web-01     php 8.4.26   AFFECTED, patch to 8.4.27
  web-02     php 8.4.27   patched
  blog-01    php 8.3.30   AFFECTED, patch to 8.3.36
  legacy-01  php 8.1.33   AFFECTED, no fix (end of life branch)
  api-01     php 8.5.12   patched
  shop-01    php 8.2.28   AFFECTED, patch to 8.2.35
  -> 4 of 6 hosts affected

Exposure per patch plan, counted two ways
  first exploitation attempt recorded: 2026-10-17
  patch released:                       2026-10-21
  CISA KEV listing:                     2026-10-26
  same day as the patch      patched 2026-10-21:  0 days after disclosure,  4 days after first exploitation
  48 hour critical SLA       patched 2026-10-23:  2 days after disclosure,  6 days after first exploitation
  weekly patch cycle         patched 2026-10-28:  7 days after disclosure, 11 days after first exploitation
  monthly patch cycle        patched 2026-11-04: 14 days after disclosure, 18 days after first exploitation

The disclosure clock understates exposure by 4 days on every plan.
Enter fullscreen mode Exit fullscreen mode

A few things are worth noting about this output. First, the monthly cycle that looks like a defensible 14 days in a compliance report is 18 days against the recorded exploitation date, and 18 is a floor, not the truth, because October 17 is merely the first attempt a honeypot happened to see. Second, the end of life host is the worst line in the table. It has no fixed version to patch to, so no SLA expressed in days to patch can ever close for it. It needs isolation, a WAF rule in front of it, or retirement, and those are different tickets with different owners. Third, notice that even the perfect plan, patching the same day, carries 4 days of exposure it did nothing to earn. That part of the window is not a performance failure. It is the cost of a disclosure process that attackers no longer reliably wait for.

For the real fleet, the version data is one command away on most estates:

ansible webservers -m shell -a 'php -v | head -1'
Enter fullscreen mode Exit fullscreen mode

And the KEV check that should gate your critical queue is a feed, not a web page someone remembers to visit:

curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
  | jq '.vulnerabilities[] | select(.cveID=="CVE-2026-109618") | .dateAdded'
Enter fullscreen mode Exit fullscreen mode

Run the Hunt Backwards From the Earliest Date You Can Defend

The pipeline, end to end, looks like this:

first exploitation (known or estimated)
        |
        v
disclosure / patch release ----> patch diffing by attackers (hours)
        |
        v
your deployment completes -----> exposure ends for that host
        |
        v
hunt window: [first exploitation - safety margin, deployment date]
Enter fullscreen mode Exit fullscreen mode

Stage one is a date, not a tool. When a critical flaw in a widely deployed runtime lands, set the hunt start to the earliest exploitation date in the reporting, and subtract a margin when reporting admits the true first attempt is unknown, as Amazon's does here. For PHP2Shell, a hunt that starts October 21 misses the recorded attempt by construction. A hunt that starts October 17 catches the recorded one and still probably starts late.

Stage two is the inventory join. Exposure is per host and per version, so the fleet check above has to run against configuration data you trust, including the PHP inside containers and the one legacy box nobody owns. The services that went offline in disclosure week were, in effect, choosing a known outage over an unknown exposure. That choice is only available to you if you know which hosts are affected before you need to decide.

Stage three is the bridge for hosts that cannot patch today. A virtual patch, a WAF rule or a reverse proxy filter matched to the published detection guidance, buys deployment time without pretending the host is fixed. Track it as a separate state in the same table. A host behind a virtual patch is mitigated. It is not patched, and your report should never let the two words trade places.

Where This Breaks

  • The first exploitation date is a floor you learn late. Amazon caught an attempt on October 17 because its honeypots were watching. A flaw with no honeypot coverage gives you no date at all, and your honest exposure number becomes an estimate you have to label as one.
  • Rediscovery cuts both ways. The same model assisted search that found PHP2Shell for defenders is available to anyone, and an embargo now has to survive not one discoverer staying quiet but every independent finder, plus leaks like the October 20 CVE identifier leak.
  • Patch diffing means a quiet patch is not a safe patch. Shipping the fix without an advisory delays your defenders' ticket while an attacker with the two binaries needs no advisory at all.
  • End of life branches end the whole model. No clock, however honest, patches a runtime nobody maintains. PHP 8.1 and older had no fixed release in this event.
  • Taking a service offline is sometimes the right call and it is still a cost. Wikipedia and DigiD could absorb a precautionary outage. A payment flow at quarter end may not, which is exactly why the decision needs the inventory numbers before the crisis, not during it.

Build It If, Skip It If

Build it if you run any internet facing runtime or library with a large install base, and your current reporting counts patch time from the disclosure date. The afternoon version is small: add a first exploitation field to your critical CVE template, default it to the disclosure date, overwrite it the moment credible reporting gives you an earlier one, and make your log hunt queries read that field instead of the ticket creation date. Add the KEV feed check to the job that builds your patch queue. That is it. Two fields and one feed.

Skip it if your estate is small enough that critical patches genuinely land the same day across every host, including containers and the legacy box. In that case your disclosure clock and your exploitation clock differ by days you have already closed, and the accounting change buys you reporting accuracy you do not need. Be sure the legacy box is actually in the inventory before you claim this exemption. It usually is not.

Start the Clock Where the Attacker Did

Pick your most recent critical patch, the one your dashboard calls closed on time. Recompute it against the earliest exploitation date you can find in the vendor advisory, the KEV entry, or the honeypot reporting, and move your hunt start to that date minus a margin. Then rerun the log search for the gap days. Measure what changes: the exposure number, and whether anything in those extra days needed a second look. Decide from that data whether the monthly cycle survives.

When your team reports patch performance, which date does the clock start on, and who in your process is responsible for moving it earlier when the reporting does?

Resources

  1. PHP2Shell: exploitation before disclosure for an AI found flaw, Cloud Security Alliance research note
  2. OffSec launches free PHP2Shell lab following critical vulnerability discovery, CIO
  3. Decade old PHP bug puts websites at risk: how AI uncovered it, Mint

Top comments (0)