On 21 October 2026 the PHP project shipped fixes for two critical flaws in the engine itself, CVE-2026-109618 and CVE-2026-109619, which researchers and the press call PHP2Shell. The next morning we asked a simple question of our own tool: if a customer had pointed Darkmoon at a vulnerable PHP site that day, would they have been told? The answer was no. The agents read the exact vulnerable version off the response headers and filed it as an informational disclosure. This article documents that negative result, what we changed in the agents, how we validated it against a patched control, and what is still not good enough.
Scope, stated up front. This is about detecting a runtime CVE (a flaw below the application, in the interpreter that serves it) and proving a patch level over HTTP. We did not exploit PHP2Shell, we publish no exploit material, and nothing here says Darkmoon detects every PHP vulnerability. Each validation below is a single run on a lab we built.
PHP2Shell in one page
Both issues are public and patched. Everything in this section comes from the PHP advisories, NVD and CERT-EU, which we read directly [1][3][4][7]. The php.net advisory does not use the nickname; Wiz, Huntress and Help Net Security do [8][9][10].
| Item | CVE-2026-109618 | CVE-2026-109619 |
|---|---|---|
| php-src title | RCE via OOM corruption | RCE via stale global state |
| Root cause (advisory) | longjmp() on out-of-memory leaves request data partially initialised; cleanup treats it as valid | global state keeps pointing into freed request memory; a later request on the same worker uses it before reset |
| Affected | < 8.2.35, < 8.3.36, < 8.4.27, < 8.5.12 | same |
| Fixed releases | 8.2.35, 8.3.36, 8.4.27, 8.5.12 | same |
| CVSS v4.0 (PHP, CNA) | 9.2, AV:N/AC:H/AT:P/PR:N/UI:N | 9.2, same vector |
| CVSS 3.1 (NVD) | 9.8 | 8.1 |
| CWE (NVD) | CWE-416 | CWE-416 |
The mechanism, at the level a defender needs. PHP aborts a request that runs out of memory by jumping out of the current context. In some places this can happen while request structures are only half built, and the cleanup that follows reads them as if they were complete[3]. The second bug is about state that outlives its request: a worker keeps a global pointer into memory that belonged to the previous request, and code that runs very early in the next request (the advisory names early INI diagnostics and session upload progress handling) can read or free it [4]. The fix for the first removes the long jump for out-of-memory conditions. Both are memory-safety bugs in the engine, so the application code on top is irrelevant: Wiz notes that even a “Hello World” application on an affected version can be vulnerable[8].
Two scoring details matter for anyone triaging. The CNA score is CVSS v4.0 9.2 for both; NVD’s own CVSS 3.1 assessment gives 9.8 and 8.1 [5][6], so tools that read NVD will show different numbers for what is the same advisory. And at the time of writing neither CVE is in the CISA Known Exploited Vulnerabilities catalog (version 2026.10.23)[12]. Absence from KEV is not evidence of no exploitation: CERT-EU reports that initial proofs of concept “have begun to circulate” [7], and Help Net Security relays an Amazon Threat Intelligence report of one exploitation attempt recorded by its honeypots on 17 October, before disclosure [10]; we have not verified that report ourselves. On 23 October php.net published a separate, lower-risk issue in the same area (CVE-2026-115560, session upload progress under an unusual configuration) [2]; it is not covered by the work below.
Exploitation conditions, at defender level
- No authentication, no application bug needed. The attacker only needs to reach a PHP endpoint over HTTP.
- Every SAPI that serves web requests is in scope: PHP-FPM, Apache mod_php, php-cgi, FrankenPHP, LiteSpeed. php.net rates mod_php the highest risk, with a demonstrated exploit triggered before any application code runs [1].
- Worker reuse matters for CVE-2026-109619: it needs a producer request and a consumer request handled by the same worker [4].
- Reliability varies. Wiz describes exploitation as needing adaptation to each target’s memory layout, and notes that version, configuration and application differences can break an exploit’s assumptions [8]. That is no reason to wait: the same post reports unauthenticated RCE against stock WordPress, Drupal and Joomla images.
- Attack traffic is unusual multipart traffic. Wiz’s detection hints are unusual multipart requests followed by memory-limit errors or bursts of HTTP 500, oversized or malformed session cookies, and unexplained worker churn; Huntress adds PHP workers spawning shells or download tools [8][9].
Disclosure policy for this article
Why version strings mislead
Since our agents must not exploit a memory-corruption bug, the proof has to be the version. That sounds trivial until you look at where versions come from.
- The CLI is not the web runtime. On the same day we checked one of our own internet-facing PHP applications. The
phpcommand-line binaries available on the host reported versions that had nothing to do with what served the site. Only a short-lived probe executed by the web SAPI itself gave the real answer (already patched, as it happened). Our deployment now runs that probe on every release and fails below the patched floor. - Proxies and CDNs can lie in both directions. A stale
X-Powered-Byheader can survive an upgrade behind a cache, so a single header is not enough. - Distributions backport. A string such as
8.1.2-1ubuntu2.xor+debNuMmay carry the fix without the upstream number changing. - Hidden versions are common. With
expose_php=Offthere is nothing to read, and guessing from the OS or the CMS version is not evidence.
Research approach: one lab, one variable
To measure detection rather than anecdote, we built an A/B lab: two containers from the official images, the last vulnerable 8.3 release and the first fixed one, serving the same 20-line application (a session and a file upload form, the request paths the advisories discuss). The patched container is the negative control: if Darkmoon flags it too, the detection is worthless.
# labs/php2shell/docker-compose.yml (our internal lab; excerpt: header comments,
# container names and restart policy omitted)
name: darkmoon-labs-php2shell
services:
php2shell-vulnerable:
image: php:8.3.35-apache # last 8.3 release before the fix
ports: ["127.0.0.1:19110:80"]
volumes: ["./app:/var/www/html:ro"]
php2shell-patched:
image: php:8.3.36-apache # negative control: must NOT be reported
ports: ["127.0.0.1:19111:80"]
volumes: ["./app:/var/www/html:ro"]
# app/index.php: 20 lines, session_start() + one multipart upload formBoth campaigns used the same prompt, word for word, which mentions neither PHP nor any CVE; the same model (claude-opus-4-6); the same toolbox container. The only change between them was the files of the seven PHP-family agents.
TARGET: http://127.0.0.1:19110 and http://127.0.0.1:19111. Two internet-facing
web applications of a customer (authorized lab, full scope, black-box). Run a
complete security assessment of both, dispatch the right specialists, report
every vulnerability with evidence, and finalize the campaign.A negative result first
Campaign 1 (22 October, 08:17 UTC) ran with the agent files in service since July. It returned 39 findings: 0 critical, 0 high, 14 medium, 20 low, 5 informational. The PHP agent did readPHP/8.3.35, and filed it like this:
// campaign 1 (agents in service since July), excerpt of the stored finding
{ "title": "Server Version and Technology Stack Disclosure via HTTP Headers",
"severity": "info", "cvss_score": 0.0, "cve": null, "status": "confirmed",
"endpoint": "http://127.0.0.1:19110/", "discovered_by_agent": "php" }
evidence.raw_response: "Server: Apache/2.4.69 (Debian) X-Powered-By: PHP/8.3.35"Three causes, all visible in the session logs:
- The model’s knowledge. The model was trained before 21 October; it cannot know that 8.3.35 became vulnerable yesterday.
- The scanner’s knowledge. The local Nuclei templates dated from 26 September and ran with update checks disabled. A replay on 23 October found no template mentioning either CVE in that directory. Upstream, a template had already been published (vulnx said so on 22 October); a toolbox that updates templates would have had it, ours did not, like many real installations.
- An available tool, never called. vulnx, which queries a live vulnerability database, was installed and allowed, and the two PHP agent sessions of campaign 1 called it zero times. The agents ran the generic Nuclei pass every agent ran,
-tags exposure,misconfig,tech-detect.
The data was there. Nothing in the chain connected it to a CVE published the day before.
What we changed in the agents
Darkmoon is an orchestrator (pentest) that fingerprints a target and dispatches specialist agents; an agent is a Markdown prompt file executed by the engine against a governed tool allowlist (see how technology fingerprints dispatch the PHP agents and the specialist-agent architecture). We changed agent files only, in two commits that are both on the public repository[13][14]:
| Commit (ASCIT31/Dark-Moon) | Date | What | Status |
|---|---|---|---|
| 9dbb9c4 | 22 Oct 2026 | PHP RUNTIME CVE CHECK block in php, wordpress, drupal, joomla, magento, moodle, prestashop; the orchestrator’s PHP signal now passes the raw X-Powered-By / Server line; one generator script; regression guard | implemented, tested in the lab, published |
| 2b20a2c | 25 Oct 2026 | TARGETED VULN SWEEP block in 36 web and service agents, one TECH PROFILE line per agent (Nuclei tags + vulnx query); generator script; regression guard | implemented, tested on one agent run, published |
Block 1: the PHP runtime CVE check (fast path)
A byte-identical block, generated by tools/agent-build/php_runtime_cves.py and injected between markers into the seven PHP-family agents. Runtime CVEs sit below the application, so a WordPress or Drupal agent that studies the CMS and its plugins would otherwise never look at the interpreter. The block runs once per PHP host and port, before the application modules, in six steps: fingerprint the runtime passively on two PHP-served responses; compare per branch with the fixed releases; never exploit; qualify; push one finding with the raw evidence and the php.net remediation; then run one bounded vulnx lookup for runtime CVEs newer than the table.
Block 2: the targeted vulnerability sweep (generalisation)
The hand-written table works for one CVE and ages the day it is written. Three days later we generalised the method rather than the table: every web or service agent that fingerprints a product and a version now carries the same sweep, scoped by a single profile line (for the PHP agent, nuclei tags: php,apache,fpm and vulnx query: php). It fires only once a product is confirmed, never on a bare target; queries vulnx first, because it is cheap and says whether a Nuclei template exists; runs Nuclei only on the matched CVE IDs; falls back to one bounded tag pass at critical and high severity only if no version could be read; and has a 90-second wall-clock budget. A product owned by another agent is reported to the orchestrator, not scanned in place. The 15 agents that do not fingerprint a web product and version (cloud providers, IaC and configuration management, Docker and Kubernetes, GitHub, identity and Active Directory, firmware, mobile, LLM, the browser agent and the orchestrator itself) are deliberately excluded.
Why vulnx first: single measurements taken while building the sweep gave about 2 s for a vulnx search, about 4.6 s for Nuclei limited to one CVE ID, and 45 to 48 s for the heaviest product tag pass we tried (1,283 WordPress templates against a non-matching target). Ordering by cost keeps the sweep inside its budget.
Open source boundary
Detection and qualification
The most consequential line in the block is a qualification rule. Darkmoon normally caps any finding it cannot demonstrate at low severity, a rule we keep because a version match alone is usually not proof. Applied here, that cap would have buried a critical RCE under the noise of header disclosures. So the block makes an explicit, narrow exception: when the vendor advisory names the exact observed version, the advisory is the demonstrated impact. The status is confirmed (version-matched), never exploited; the severity is the advisory’s. The exception has three guards: the version must be read on two PHP responses (otherwise the finding stays unconfirmed), a distribution suffix downgrades it to “potentially vulnerable, high” with an instruction to check the vendor advisory, and a hidden version produces no finding at all.
Validation results
| Run | Date (UTC) | What ran | Outcome |
|---|---|---|---|
| Campaign 1, baseline | 22 Oct, 08:17 | full campaign, July agent files | 39 findings, 0 critical; PHP 8.3.35 filed as info on 19110 |
| Campaign 2, after block 1 | 22 Oct, 09:08 | same prompt, model, toolbox; 7 PHP-family agents replaced | 37 findings, 1 critical: PHP2Shell on 19110 only; nothing for PHP2Shell on 19111 |
| Replay | 23 Oct, 07:36 | the agent’s curl commands, by hand | 19110 PHP/8.3.35, 19111 PHP/8.3.36; 0 local Nuclei templates for either CVE |
| Sweep, PHP agent alone | 25 Oct | block 2, php agent run directly against the vulnerable container | vulnx search and vulnx id on both CVEs, Nuclei matched, critical finding pushed, no blind scan |
| Sweep, full campaign | 25 Oct | orchestrator on the vulnerable container | orchestrator did not dispatch the PHP agent; it found CVE-2026-109618 itself through broad Nuclei passes and filed it as denial of service; see limits |
| Regression gate | 25 Oct, 18:35 | tools/check-no-regression.sh on the public checkout | PASS: block 1 in 7 agents, block 2 present in all profiled agents and absent elsewhere |
The critical finding of campaign 2, as stored:
// campaign 2 (7 PHP-family agents replaced, nothing else), excerpt of the stored finding
{ "title": "PHP runtime vulnerable to PHP2Shell unauthenticated RCE
(CVE-2026-109618, CVE-2026-109619): PHP 8.3.35",
"severity": "critical", "cvss_score": 9.2, "status": "confirmed",
"plugin_or_component": "php 8.3.35 (mod_php / Apache 2.4.69)",
"endpoint": "http://127.0.0.1:19110/", "mitre_attack_id": "T1190" }
evidence.commands: curl -sk -o /dev/null -D - --max-time 15 http://127.0.0.1:19110/
curl -sk -o /dev/null -D - --max-time 15 http://127.0.0.1:19110/index.php
evidence.explanation: "... consistently identified via the X-Powered-By header on two
independent PHP-served routes ... Not exploited by design ..."On the patched control the agent wrote, at 09:17:34, that 8.3.36 is the fixed release and that there was no runtime CVE finding to push, and the executive summary of the campaign states that port 19111 is patched. The bounded vulnx lookup listed both CVEs (1.9 s), so the agent concluded there was no other runtime CVE to add.
We publish the imperfections visible in the logs, because they are part of the result. The block’s own commands use timeout, which is not on Darkmoon’s tool allowlist; the first call failed and the agent retried with curl --max-time. The first push of the critical finding was rejected for a missing description and corrected 25 seconds later. The model first wrote “This is the fixed version”, then “wait, let me re-check”, and concluded correctly. And in campaign 2 the orchestrator, which reads the agent file before dispatching, added on its own that 19111 runs the fixed version to the context it passed for that target; the context for the vulnerable target contained facts only. In both cases the knowledge came from the new block, not from the prompt.
Limits and false positives
What this detection cannot see
- Hidden versions. With
expose_php=Offand no version elsewhere, block 1 reports nothing, by design. Active probing would answer, at a cost we do not accept on production targets. - Backports. A patched distribution build can carry an old upstream number. The block flags it as “potentially vulnerable”, which is a false positive whenever the vendor did backport the fix; only the vendor advisory settles it.
- Stale headers. The two-response check catches a proxy injecting an inconsistent header, not one that rewrites every response the same way.
- One run per condition. These are reproducible demonstrations on one lab, not statistics. Model outputs vary between runs.
What we found wrong in our own work
- The non-destructive rule is not enforced by the command line. The sweep forbids adding
intrusiveordostags, but the upstream Nuclei template for CVE-2026-109618 is an active crash-detection template that itself carries theintrusivetag, and a product tag pass (-tags php,...) selects it. In the 25 October run it did exactly that against our lab. The precise path (nuclei -id <CVE>) would select the same template, since the block relies on the agent to judge whether a template is non-destructive. The fix is to exclude those tags explicitly; it is designed, not yet implemented. - The finding text repeated unverified third-party claims. In the same 25 October run the pushed finding used NVD’s 9.8 instead of the advisory’s 9.2 written in the block, and its description stated “active exploitation in the wild” on the strength of the vulnx output (VulnCheck KEV data), which we did not verify and which CISA KEV does not list. The severity was right; the narrative was stronger than our evidence.
- The agent took the fallback path. In the same run the version was known, so the doctrine says “Nuclei on the matched CVE IDs only”; the agent ran the bounded tag pass instead. The result was right, the path was not the one written.
- No dispatch, no block. On a single simple target the orchestrator sometimes does the work itself instead of dispatching a specialist. In the 25 October full campaign it ran broad Nuclei passes (one with a ten-minute timeout), found the CVE through the template and filed it as a denial-of-service issue. The blocks only help when the specialist agent actually runs.
- Unrelated findings stay noisy. Both campaigns report an “unrestricted file upload” at medium on both containers. The application never stores the file; campaign 2’s summary says so, but the finding remains.
- Scanners have false positives too. The Assetnote scanner’s README notes that a patched PHP can return the same empty HTTP 500 as a vulnerable one when a probe exceeds
memory_limit[11]. Crash-based and version-based detection fail in different places, which is why the version evidence and the advisory travel together in our findings.
Proving the patch level and remediating
What we do on our own systems, and what the finding’s remediation text says. First, prove the version of the runtime that serves requests, not of a binary on the host:
# 1. what the web SAPI says (headers), on two PHP-served routes
curl -sk -o /dev/null -D - https://app.example/ | grep -i -E 'x-powered-by|server'
curl -sk -o /dev/null -D - https://app.example/index.php | grep -i -E 'x-powered-by|server'
# 2. if expose_php=Off hides it: a short-lived, random-named probe served by the SAME vhost
# (deploy it, read it once, delete it; never leave phpinfo() online)
<?php echo PHP_VERSION, ' ', PHP_SAPI, ' upload_progress=', ini_get('session.upload_progress.enabled');
# 3. compare per branch: patched if >= 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12
# (distribution builds: check the vendor advisory, the fix may be backported)- Upgrade to 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later, or the vendor backport for end-of-life branches (PHP published minimal source backports for 7.4, 8.0 and 8.1), and restart every worker. Only the update is a complete fix [1].
- Until then, reduce exposure (php.net, all partial):
memory_limit=-1set where applications cannot override it; a lowpost_max_size(8M, or 32M if uploads are needed) withupload_max_filesizebelow it;session.upload_progress.enabled=0in php.ini or the pool configuration, not inini_set()or.user.ini; on 8.5,fatal_error_backtraces=0. Do not substituteRLIMIT_ASorRLIMIT_DATA. CERT-EU adds request body limits at the web server or proxy and container or cgroup memory limits [7]. - Make it a release gate. Our own deployment probes the web SAPI after each release and fails below the patched floor, and we checked by hand that an oversized request body is refused by the web server (HTTP 413) before PHP parses it. Hiding the version (
expose_php=Off) is good hygiene and fixes nothing.
Note one discrepancy we found while writing: php.net lists memory_limit=-1 among the mitigations, and the remediation text in our published block does not mention it. It will be added with the next revision of the block.
Perspectives
Each item is labelled with its real status.
- Envisaged: exclude
intrusive,dos,fuzztemplates in every sweep command; replacetimeoutwith the allowlisted wrapper in block 1; addmemory_limit=-1to the remediation text. - Envisaged: make the orchestrator dispatch the PHP agent whenever a PHP runtime version is seen, so that the blocks run on simple targets too.
- Envisaged: publish the lab. Today it lives in an internal repository; the compose excerpt above, plus any small page that starts a session and shows an upload form, is enough to rebuild it.
- Open question: how to retire block 1. Once the sweep has proven itself on more CVEs, a hand-written table is a maintenance liability; until then it is the only path validated against a negative control.
Further resources
- github.com/ASCIT31/Dark-Moon: the open-source engine and agents. The blocks described here are in
conf/agents/php.md(markersdarkmoon:php-runtime-cvesanddarkmoon:targeted-vuln-sweep) and their generators intools/agent-build/. - github.com/ASCIT31/Darkmoon-Benchmarks: our benchmark runs of autonomous LLM pentest agents on vulnerable labs, run by the Darkmoon team. Its scope is LLM pentest benchmarking; it does not contain this PHP lab and does not validate any claim in this article. The method behind those runs is described in our LLM pentesting benchmark write-up.
- Related reading: PHP CMS targets such as WordPress, and what a clean result looks like on a hardened site.
References
- PHP Group, “Security advisory: Critical remote code execution vulnerabilities, update now”, php.net news archive, 21 October 2026 (entry 2026-10-21-5), and “Upcoming Critical PHP Security Release”, 20 October 2026. www.php.net/archive/2026.php#2026-10-21-5
- PHP Group, “Informational advisory: Session upload progress” (CVE-2026-115560), php.net, 23 October 2026. www.php.net/archive/2026.php#2026-10-23-1
- php-src security advisory GHSA-w8hw-w9fx-j79w, “RCE via OOM corruption” (CVE-2026-109618). github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
- php-src security advisory GHSA-6wmf-4rv5-p3rp, “RCE via stale global state” (CVE-2026-109619). github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
- NIST NVD, CVE-2026-109618 (record read through the NVD API 2.0 on 25 October 2026). nvd.nist.gov/vuln/detail/CVE-2026-109618
- NIST NVD, CVE-2026-109619 (record read through the NVD API 2.0 on 25 October 2026). nvd.nist.gov/vuln/detail/CVE-2026-109619
- CERT-EU, Security Advisory 2026-018, “Critical Vulnerabilities in PHP”, v1.2, 23 October 2026. cert.europa.eu/publications/security-advisories/2026-018
- Wiz Research, “PHP2Shell” research post (discovery, timeline, detection hints). www.wiz.io/blog/php2shell-php-rce
- Huntress (J. Hammond), “Rapid Response: php2shell, Leaked Critical PHP Vulnerabilities”, 20–21 October 2026. www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities
- Help Net Security (M. Zorz), “PHP2Shell: Critical PHP vulnerabilities patched, update now!”, 21–22 October 2026. www.helpnetsecurity.com/2026/10/21/php2shell-php-vulnerabilities-patched-cve-2026-109618-cve-2026-109619/
- Assetnote, php2shell-scanner (README: probe design, network impact and false-positive notes). github.com/assetnote/php2shell-scanner
- CISA, Known Exploited Vulnerabilities catalog, JSON feed (catalog version 2026.10.23, checked 25 October 2026). www.cisa.gov/known-exploited-vulnerabilities-catalog
- Darkmoon, commit 9dbb9c4, “feat(agents): PHP runtime CVE check — detect PHP2Shell”, ASCIT31/Dark-Moon, 22 October 2026. github.com/ASCIT31/Dark-Moon/commit/9dbb9c4
- Darkmoon, commit 2b20a2c, “feat(agents): signal-driven targeted vuln sweep across the web/service agents”, ASCIT31/Dark-Moon, 25 October 2026. github.com/ASCIT31/Dark-Moon/commit/2b20a2c
See the proof, not just the write-up: the Pro remediation benchmark (fixes retested against the exploit) · how Darkmoon compares to other AI pentest tools.
← All articles