15 min read

PHP2Shell vs the Knowledge Cutoff: CVE-2026-109618, Tested Oct 2026

By Kartey R

Founder of Serpex

Technology

At 04:45 UTC on Oct 23, 2026, we sent "CVE-2026-109618 fixed version" to our search API from a server in Germany. Seven results came back in 1.3 seconds. The third listed the right versions: 8.5.12, 8.4.27, 8.3.36 and 8.2.35. None of the seven came from the PHP project.

That is where an agent stands this week when someone asks it "are we affected by the PHP bug?" The model alone cannot know: every training cutoff its vendor documents predates the Oct 21 disclosure. With search it can know, but the answer arrives through pages of mixed provenance. We ran 12 agent-style questions twice, classified every result, and built a 91-line patch-check agent that only believes primary sources.

Serpex is a search API for AI agents, not a security product. We had no part in finding or fixing these bugs.

How we tested

  • When: Oct 23, 2026, 04:45 to 05:11 UTC, about 39 hours after the PHP release.
  • Where: one server in Germany, Python 3.12.15 in a python:3.12-alpine container.
  • Queries: 12 questions an agent would plausibly send, each run twice, 14 minutes apart. 24 searches, 207 results.
  • Primary source: the PHP project's own sites (php.net, news-web.php.net, php-src on GitHub, the PHP Foundation), NVD and CVE.org. Other results went into six buckets: vendor, cloud or government advisory; security vendor; press; third-party explainer; forum; exploit or PoC.
  • Answer key: taken from php.net and both GitHub advisories before the runs.
  • Models: we did not query any. The cutoff dates below come from each vendor's documentation.

What changed on Oct 21, and why can't a model trained earlier answer?

On Oct 21 the PHP project released 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix two unauthenticated remote code execution bugs, CVE-2026-109618 and CVE-2026-109619, both rated CVSS v4.0 9.2. php.net says every installation that serves web requests is at risk, mod_php the most, and that "there are no complete mitigations." The 8.1 branch and older get no release; 8.2 reaches end of life on Dec 31, 2026. CISA urged immediate patching, and Al Jazeera reported a rare public warning from the NSA.

None of this is in a current model's training data, going by the cutoffs the vendors publish:

ModelDocumented cutoffSourceDays before Oct 21, at least
Gemini 4March 2026Google DeepMind model card204
GPT-6 Astra, GPT-6.1 SolApr 30, 2026OpenAI model docs174
GPT-6 LunaMay 18, 2026OpenAI model docs156
Claude Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5Jun 2026Anthropic models overview113

The CVE IDs and fixed versions were not public before Oct 20, and even the private reports date from August. Without tools, the best a model can do is say it does not know. The failure to design against is a model that answers from the last PHP releases it remembers. We did not measure how often that happens; we only note that the facts did not exist at training time. Anthropic and Google both document search tools for these models, so the real question is which search tool your agent has and what it returns.

What does live search return for these questions?

Live search returned the right fixed versions, mostly from pages that are not the PHP project's. In both passes, 3 of 36 top-3 slots went to primary sources, and primary sources appeared at all for only 3 of 12 questions: the one naming a release, the one naming the GHSA ID, and the end-of-life question.

First pass, Oct 23, 04:45 UTC:

#Question sentResultsLatencyTop-3 source typesFirst primaryAll four fixed versions first seen in a snippet
1CVE-2026-109618 fixed version71.3 sthird-party, vendor, third-partynone#3, third-party
2CVE-2026-109619 patched versions74.2 sthird-party, vendor, third-partynonenever (two of four at #7)
3php2shell patched php 8.374.8 sthird-party, press, third-partynone#1, Wikipedia
4is php 8.1 affected CVE-2026-109619101.4 sgovernment, third-party, pressnone#4, third-party
5php 8.4.27 security release107.7 sprimary, primary, third-party#1#7, security vendor
6php2shell vulnerability102.2 sgovernment, third-party, security vendornonenever
7PHP critical vulnerability October 2026 am I affected102.4 sthird-party, third-party, governmentnonenever
8GHSA-w8hw-w9fx-j79w101.8 sprimary, vendor, security vendor#1never
9php2shell mitigation if I can't upgrade76.6 sthird-party, third-party, vendornone#1, third-party
10ubuntu php CVE-2026-109618102.0 svendor, vendor, third-partynone#2, hosting vendor
11php2shell public exploit102.8 sexploit/PoC, press, third-partynonenever
12php 8.2 end of life php2shell73.8 sthird-party, third-party, third-party#5#2, third-party

All four fixed versions appeared in a title or snippet for 7 of 12 questions in each pass, never from a primary source. None was wrong: the 102 second-pass snippets held no 8.x.y version other than the four. The top domain held for 11 of 12 questions between passes. Over the 23 uncached searches, latency was 2.0 s at the median and 6.3 s at p90.

"Live" does not guarantee fresh. While fact-checking this post, a general-purpose web search tool we use returned nothing about php2shell for four queries. Test your agent's search tool on this week's CVE.

Which sources should an agent trust when coverage disagrees?

Take version numbers only from the project's own advisory; use everything else for context. Coverage disagreed on three points, none of them the fixed versions:

  • Severity. php.net and both GitHub advisories say CVSS v4.0 9.2. NVD's entry adds 9.8 under CVSS v3.1. The PHP Foundation says that when the two CVE IDs began circulating on forums and social media on Oct 20, "[t]hey came with a wrong release time and a wrong CVSS score of 10.0 (the actual score is 9.2)." An agent that reports a score should name its version and source.
  • Discovery dates. Fortune says Calif found the first issue on Aug 1 and Wiz found it independently on Aug 19. Al Jazeera says Calif reported the first flaw on Aug 11 and Wiz reported "the second vulnerability" on Aug 24. Wiz's timeline has Aug 19 found, Aug 24 reported. These may be find dates against report dates; the articles do not say, so we do not pick one.
  • Reach. Wiz says PHP powers "70% of all websites", with no source. Palo Alto Networks, per Fortune, counted 22.6 million potentially vulnerable instances, and Cloudflare's CEO told Fortune of millions of attempts by the evening of Oct 21. We could not check the method behind any of them.

Provenance was the bigger problem. A third-party site at php-net.pro ranked #3 for "php 8.4.27 security release". Its article was accurate, but the regex php.net with an unescaped dot matches that domain. php2shell.app, a site named after the bug, ranked #1 for the mitigation question in both passes. And github.com is not a trust boundary: site:github.com CVE-2026-109618 php-src returned third-party repositories, gists and a PoC repository, but not the php-src advisory.

The ranking our agent uses:

RankSourceUse it for
0Project advisory (php-src GHSA)Fixed versions, affected ranges
1Project release notes (php.net /archive/, /releases/)Fixed versions, end of life, mitigations
2NVD, CVE.orgStructured ranges and scores, once analyzed
3Your OS or platform vendor's advisoryPackage versions for distro builds; outranks upstream for those
4Government CERTs (CISA, national CSIRTs)Urgency and operational guidance
5Security vendors, press, WikipediaContext only, never the version verdict
neverForums, lookalike domains, exploit or PoC repositoriesNothing

How do you build an agent that checks a PHP version against the live advisory?

The agent asks the question the way a user would, falls back to the primary domain, fetches only allow-listed pages, and refuses to answer if no primary source states the fixed versions. It is 91 lines of Python 3.12 with requests 2.32.3; the serpex SDK (2.11.0 on PyPI) wraps the same endpoints. For setup, see our Python workflow guide and the search API reference.

  1. Two fixed queries. The natural question first. It returned no primary source in the audit, so the agent then sends site:php.net CVE-2026-109618, which returned only php.net pages.
  2. Fetch only what passes the trust rule. Extract (/api/crawl) runs on at most three trusted URLs per query, plus the linked advisory. The agent never downloads an untrusted page.
  3. Follow the link. The php.net advisory links its GHSA, so the agent builds the php-src URL from that ID instead of trusting a result that claims to be the advisory.
  4. One parser per source, scoped to the CVE. php.net text is read only in sections that name the CVE; the GitHub page only in "Patched versions".
  5. Require agreement. If primary sources disagree within a branch, the higher version wins and the output says DISAGREE.
python
"""patch_check.py - check PHP versions against the live advisory for a CVE.
Python 3.12 + requests 2.32. Usage: SERPEX_API_KEY=sk_... python patch_check.py 8.3.20 8.4.27"""
import os, re, sys, time
import requests
API = "https://api.serpex.dev/api"
HEADERS = {"Authorization": f"Bearer {os.environ['SERPEX_API_KEY']}"}
CVE = "CVE-2026-109618"
# Trust rule: only these URL prefixes may feed the verdict. Lower rank wins.
TRUST = [("github.com/php/php-src/security/advisories/", 0),
("www.php.net/archive/", 1), ("www.php.net/releases/", 1), # not /manual/: it has user notes
("news-web.php.net/php.announce/", 2)]
credits = 0
def call(path, body):
global credits
r = requests.post(f"{API}/{path}", json=body, headers=HEADERS, timeout=60)
r.raise_for_status()
data = r.json()
credits += data.get("metadata", {}).get("credits_used", 0)
if os.environ.get("VERBOSE"):
print(f" > {path} {body.get('q') or body.get('urls')}: {r.elapsed.total_seconds():.1f}s, "
f"{data['metadata'].get('credits_used')} credit(s)", file=sys.stderr)
return data
def rank(url):
u = url.split("://", 1)[-1].lower()
return next((r for prefix, r in TRUST if u.startswith(prefix)), None)
def trusted(query):
hits = [(rank(x["url"]), x["url"]) for x in call("search", {"q": query})["results"]]
return sorted({h for h in hits if h[0] is not None})
def fetch(urls):
if not urls:
return {}
pages = call("crawl", {"urls": urls, "format": "markdown"})["results"]
return {p["url"]: p.get("markdown") or "" for p in pages if p.get("success")}
VERSION = r"\d+\.\d+\.\d+\b(?!\s?(?:RC|alpha|beta))" # stable releases only, never 8.6.0 RC4
def fixed_versions(url, md):
if "/security/advisories/" in url: # GitHub advisory: a structured "Patched versions" section
m = re.search(r"## Patched versions\s+(.*?)\n## ", md, re.S)
return set(re.findall(VERSION, m.group(1))) if m else set()
found = set() # php.net: "PHP 8.5.12, 8.4.27 and 8.3.36 fix ...",
for section in md.split("\n## "): # read only in sections that name the CVE
if CVE in section:
for m in re.finditer(rf"PHP ((?:{VERSION}(?:, | and ))*{VERSION})", section, re.I):
found |= set(re.findall(VERSION, m.group(1), re.I))
return found
def V(s): # "8.3.20" -> (8, 3, 20); "8.6.0RC3" -> (8, 6, 0)
return tuple(int(x) for x in re.match(r"(\d+)\.(\d+)\.(\d+)", s).groups())
def verdict(version, fixed):
branches = {}
for f in map(V, fixed): # if sources disagree inside a branch, keep the higher (stricter) version
branches[f[:2]] = max(branches.get(f[:2], f), f)
v = V(version)
if v[:2] in branches:
fx = ".".join(map(str, branches[v[:2]]))
return ("PATCHED" if v >= branches[v[:2]] else "AFFECTED") + f" (fixed in {fx})"
if v[:2] < min(branches):
return "AFFECTED (no upstream fix for this branch: upgrade, or use a vendor backport)"
return "UNKNOWN (branch not listed in the advisory: read it yourself)"
t0 = time.perf_counter()
pages, found = {}, {}
for query in (f"{CVE} fixed version", f"site:php.net {CVE}"): # as an agent would ask; then the primary domain
hits = trusted(query)
pages.update(fetch([u for _, u in hits[:3] if u not in pages]))
for md in list(pages.values()): # follow GHSA links on primary pages
for ghsa in set(re.findall(CVE + r"\D[^\n]{0,80}?(GHSA(?:-[a-z0-9]{4}){3})", md)):
url = f"https://github.com/php/php-src/security/advisories/{ghsa}"
if url not in pages:
pages.update(fetch([url]))
found = {u: v for u, md in pages.items() if (v := fixed_versions(u, md))}
if found:
break
if not found:
sys.exit(f"No primary source with fixed versions for {CVE}. Do not answer from memory.")
sets = {frozenset(v) for v in found.values()}
print(f"{CVE}: {len(found)} primary source(s), {'agree' if len(sets) == 1 else 'DISAGREE'}")
for u, v in sorted(found.items(), key=lambda kv: rank(kv[0])):
print(f" [rank {rank(u)}] {u}\n fixed: {', '.join(sorted(v, key=V))}")
fixed = set().union(*found.values())
for version in sys.argv[1:]:
print(f" PHP {version:<8} {verdict(version, fixed)}")
print(f"credits used: {credits}, wall time: {time.perf_counter() - t0:.1f}s")
print("Note: distro packages backport fixes without changing the upstream version; check your vendor.")

We ran it at 05:10 UTC in a throwaway python:3.12-alpine container, passing the key by name with -e SERPEX_API_KEY. The call trace (stderr, VERBOSE=1):

text
> search CVE-2026-109618 fixed version: 2.0s, 1 credit(s)
> search site:php.net CVE-2026-109618: 2.6s, 1 credit(s)
> crawl ['https://www.php.net/archive/2026.php', 'https://www.php.net/releases/8_3_36.php', 'https://news-web.php.net/php.announce/509']: 1.4s, 3 credit(s)
> crawl ['https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w']: 2.5s, 1 credit(s)

And the verdict, from stdout:

text
CVE-2026-109618: 2 primary source(s), agree
[rank 0] https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
fixed: 8.2.35, 8.3.36, 8.4.27, 8.5.12
[rank 1] https://www.php.net/archive/2026.php
fixed: 8.2.35, 8.3.36, 8.4.27, 8.5.12
PHP 8.3.20 AFFECTED (fixed in 8.3.36)
PHP 8.3.36 PATCHED (fixed in 8.3.36)
PHP 8.4.27 PATCHED (fixed in 8.4.27)
PHP 8.1.30 AFFECTED (no upstream fix for this branch: upgrade, or use a vendor backport)
credits used: 6, wall time: 8.4s
Note: distro packages backport fixes without changing the upstream version; check your vendor.

As in the audit, the natural question returned no trusted URL, so the site: query did the work. Two of the three php.net pages gave nothing, because neither names the CVE: the 8.3.36 release page says only "This is a security release", and the Oct 20 pre-announcement came before the details. The section rule ignored both, at one credit each. The archive and the GHSA it linked agreed on all four versions.

Where does the agent get it wrong?

The agent compares upstream version numbers, so it is wrong for distribution packages. Ubuntu lists the fix for 24.04 as php8.3 8.3.6-0ubuntu0.24.04.13, still 8.3.6 by upstream numbering. We reran the agent right after the first run with that version and two others:

text
PHP 8.3.6-0ubuntu0.24.04.13 AFFECTED (fixed in 8.3.36)
PHP 8.6.0RC3 UNKNOWN (branch not listed in the advisory: read it yourself)
PHP 8.2.34 AFFECTED (fixed in 8.2.35)
credits used: 0, wall time: 0.3s

The first line is a false positive: that package carries the fix. The repeat cost 0 credits, because our organization sent identical requests within five minutes.

Other limits:

  • 8.1 and older. Right for upstream builds, wrong for vendor backports: Ubuntu 22.04's php8.1 8.1.2-1ubuntu2.28 carries the fix. CISA says organizations that cannot update, or still run 8.1 or earlier, "should take PHP-based systems offline until they can be patched or updated."
  • 8.6 pre-releases. 8.6.0 RC4 contains the fixes, but the parser skips RC versions on purpose, so 8.6.0RC3 gets UNKNOWN instead of a guess.
  • Exposure. The agent cannot tell mod_php from FPM or see mitigations, which php.net says "do not make an installation safe" anyway.
  • Brittleness. The CVE, trust list and php.net phrasing are hard-coded. If php.net rewords its notes, the agent exits without a verdict. That is the failure we want.

How do you keep exploit pages and injected text out of the agent?

Keep page text away from anything that can act. An exploit repository was the #1 result for "php2shell public exploit" in both passes and #10 for "ubuntu php CVE-2026-109618" in the first. A triage agent will ask questions like these.

Text written for the agent is the larger risk. The Cloud Security Alliance reported in April 2026 that indirect prompt injection "has crossed the line from proof-of-concept to live exploitation", citing Google's measurement of a 32% relative rise in malicious injected content from November 2025 to February 2026. In Unit 42's cases, cited in the same note, the most common trick was visible text hidden from humans (37.8%). Pages about a fresh CVE are read by DevOps and patch agents, the agents most likely to hold shell access.

Our agent's defenses are structural. No model reads page text; regexes over allow-listed pages produce the verdict. The allow-list is a URL prefix, not a keyword. The agent has no action tools: it prints a verdict, and a person runs the upgrade.

If an LLM must read pages, say to summarize mitigations, apply the CSA note's controls: tag inputs by origin, keep retrieved text apart from instructions, strip zero-width text and HTML comments, and put high-impact tool calls behind human approval. That applies to page content inside search, which puts untrusted Markdown in your context window, and to an MCP search tool in Claude or Cursor. In a LangChain agent, expose the check as a tool that returns a verdict, not pages.

What does each check cost, and how often should it run?

A cold check cost 6 credits (2 searches, 4 page fetches) and took 8.4 seconds. The repeat cost 0 credits and 0.3 seconds. At our list prices of $0.50 to $0.80 per 1,000 credits, 6 credits is $0.003 to $0.005. The whole audit, 24 searches, cost 23 credits; one was a cache hit from our own earlier run.

Run the lookup once per CVE, not once per host, and compare each host's version locally: 500 hosts still cost 6 credits per check. The sources change during the first week as vendor packages and feed records arrive, and php.net plans 8.6.0 RC5 for Nov 5. Check hourly that week, then daily until your vendor's feed has the fix, then stop.

When is a structured advisory feed the better tool than search?

Once a feed has the record, use the feed. On Oct 23, NVD's API returned CVE-2026-109618 as "Analyzed", with CPE ranges matching the GitHub advisory. Structured data needs no parser and leaves no room for injected text. The same morning, OSV returned 404 for CVE-2026-109618 and GHSA-w8hw-w9fx-j79w while serving the 2024 PHP-CGI bug, CVE-2024-4577. Coverage varies by feed and by hour.

Search covers the gap: the first hours after disclosure, and questions feeds do not answer, such as whether your vendor's package is out or which mitigations apply. Query the feed first, fall back to search with the same trust rule, and log which one answered.

Recommendations

  1. Assume the model has not seen this week's CVE. Every documented cutoff we found is at least 113 days before Oct 21.
  2. Take version verdicts only from allow-listed URL prefixes for the project's advisory and release notes.
  3. When the natural query finds no primary source, ask the primary domain with site:.
  4. Fetch only allow-listed pages, and keep page text away from any model that holds tools.
  5. Fail closed. No primary source, no verdict.
  6. For distro packages, compare against the vendor's advisory, not upstream numbers.
  7. Prefer NVD or your vendor's feed once it has the record, and keep search for the gap.

We will update this post once, when PHP 8.6.0 reaches general availability or the exploitation data changes.

Sources

All accessed Oct 23, 2026.

Serpex Blog

Related Articles

More content you might be interested in

Oct 19, 2026
19 min read

Which Search API Is Cheapest at 100k, 1M and 10M Searches a Month? (Prices Checked October 2026)

Search API costs at 100k, 1M and 10M searches a month, checked Oct 23, 2026. Lowest list: $0.20/1k. Serpex: $0.50-$0.80, from $0.20 at 10M+.

Technology
Read More
Oct 19, 2026
8 min read

How to Add Web Search to an AI Workflow or RAG Pipeline (Python, 2026)

How to add web search to an AI workflow or RAG pipeline in Python (2026): one call returns pages as Markdown to chunk, rank and cite, with real monthly costs.

Technology
Read More
Oct 19, 2026
8 min read

Tavily Pricing in 2026: What 10k, 100k and 1M Searches Cost

Tavily pricing in 2026: $8 per 1,000 credits pay as you go, $5 on Growth. Monthly cost at 10k, 100k and 1M searches, advanced depth, Extract and alternatives.

Technology
Read More
Oct 19, 2026
8 min read

Serper vs Tavily (2026): Pricing, Output and Which to Use

Serper vs Tavily in 2026: Serper costs $0.30 to $1.00 per 1,000 searches, Tavily $5 to $8. Free tiers, output, page content, LangChain support and which to use.

Technology
Read More
Oct 19, 2026
8 min read

Google Custom Search API Is Closing: What to Use by January 1, 2027

The Google Custom Search JSON API is closed to new users and existing ones must move by January 1, 2027. Replacements and 2026 costs at 10k and 100k searches.

Technology
Read More
Oct 3, 2026
7 min read

Enrich Company Records with a Web Search API

Use a web search API to fill company records: official website, social profiles, news and funding mentions, with query patterns, checks and cost per record.

Technology
Read More
Oct 3, 2026
7 min read

Web Search in n8n: HTTP Request, AI Agent or Community Node

Add web search to an n8n workflow with the HTTP Request node, use it as an AI Agent tool with $fromAI(), or install the n8n-nodes-serpex community node.

Technology
Read More
Oct 3, 2026
9 min read

SerpApi vs Serper vs DataForSEO (2026): Pricing and Limits

SerpApi vs Serper vs DataForSEO in 2026: price per 1,000 searches at every tier, free tiers, rate limits, output and who each fits. Checked 3 Oct 2026.

Technology
Read More