Blog··15 min read

Detecting PHP2Shell: why our agents missed a PHP runtime CVE, and what we changed

PHP2Shell (CVE-2026-109618/109619): why our agents first filed it as a mere disclosure, how they now prove the PHP patch level, and what still fails.

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

ItemCVE-2026-109618CVE-2026-109619
php-src titleRCE via OOM corruptionRCE via stale global state
Root cause (advisory)longjmp() on out-of-memory leaves request data partially initialised; cleanup treats it as validglobal 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.12same
Fixed releases8.2.35, 8.3.36, 8.4.27, 8.5.12same
CVSS v4.0 (PHP, CNA)9.2, AV:N/AC:H/AT:P/PR:N/UI:N9.2, same vector
CVSS 3.1 (NVD)9.88.1
CWE (NVD)CWE-416CWE-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

Both CVEs are public and fixed upstream, so we explain the mechanism. We stop at the level of the official advisories: no request shapes, sizes or sequences beyond what they publish. Public active scanners exist and they work by pushing the target to its memory limit[11]. That is one more reason our agents do not use them against production targets (see the limits section, where we found that our own guardrail had a hole).

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 php command-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-By header can survive an upgrade behind a cache, so a single header is not enough.
  • Distributions backport. A string such as 8.1.2-1ubuntu2.x or +debNuM may carry the fix without the upstream number changing.
  • Hidden versions are common. With expose_php=Off there 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 form

Both 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)DateWhatStatus
9dbb9c422 Oct 2026PHP 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 guardimplemented, tested in the lab, published
2b20a2c25 Oct 2026TARGETED VULN SWEEP block in 36 web and service agents, one TECH PROFILE line per agent (Nuclei tags + vulnx query); generator script; regression guardimplemented, tested on one agent run, published
Before and after comparison of the Darkmoon PHP agent architecture. Before: the orchestrator dispatches the PHP-family agents, which run a generic Nuclei pass and file the PHP version as an informational finding. After: two blocks are added inside the agents, a PHP runtime CVE check and a targeted vulnerability sweep, both calling vulnx and, for the sweep, targeted Nuclei; the runtime check pushes a version-matched critical finding.
Figure 1 · Architecture delta of the PHP-family agents between the July files and 25 October 2026. Only agent prompt files and their generators changed; the engine, the tool allowlist and the scanners are the same binaries.Source: commits 9dbb9c4 and 2b20a2c on github.com/ASCIT31/Dark-Moon; agent files conf/agents/*.md.

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

Both blocks, their generator scripts and the regression gate are in the open-source edition, github.com/ASCIT31/Dark-Moon (GPL-3.0, CLI and agents). The same agent files are mirrored byte for byte into our release and Pro repositories. The web dashboard in which Pro customers browse these findings is not part of the open-source edition, and nothing in this work depends on it.

Detection and qualification

Flowchart of the PHP runtime CVE check. A PHP surface is fingerprinted; if no version is visible nothing is pushed; if two PHP responses disagree the finding stays unconfirmed; otherwise the version is compared per branch with the fixed releases; at or above the fix nothing is pushed; a distribution build suffix gives potentially vulnerable, high; below the fix the agent pushes a confirmed, version-matched, critical finding that is never exploited, then runs one bounded vulnx lookup for newer runtime CVEs.
Figure 2 · Decision path from a fingerprint to a qualified finding, as written in the PHP RUNTIME CVE CHECK block. The patched control in our lab exits at “nothing pushed”. The bounded vulnx lookup for newer runtime CVEs runs after the comparison on every branch; it is drawn once.Source: conf/agents/php.md, block darkmoon:php-runtime-cves (commit 9dbb9c4).

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

RunDate (UTC)What ranOutcome
Campaign 1, baseline22 Oct, 08:17full campaign, July agent files39 findings, 0 critical; PHP 8.3.35 filed as info on 19110
Campaign 2, after block 122 Oct, 09:08same prompt, model, toolbox; 7 PHP-family agents replaced37 findings, 1 critical: PHP2Shell on 19110 only; nothing for PHP2Shell on 19111
Replay23 Oct, 07:36the agent’s curl commands, by hand19110 PHP/8.3.35, 19111 PHP/8.3.36; 0 local Nuclei templates for either CVE
Sweep, PHP agent alone25 Octblock 2, php agent run directly against the vulnerable containervulnx search and vulnx id on both CVEs, Nuclei matched, critical finding pushed, no blind scan
Sweep, full campaign25 Octorchestrator on the vulnerable containerorchestrator 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 gate25 Oct, 18:35tools/check-no-regression.sh on the public checkoutPASS: 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=Off and 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 intrusive or dos tags, but the upstream Nuclei template for CVE-2026-109618 is an active crash-detection template that itself carries the intrusive tag, 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=-1 set where applications cannot override it; a low post_max_size (8M, or 32M if uploads are needed) with upload_max_filesize below it; session.upload_progress.enabled=0 in php.ini or the pool configuration, not in ini_set() or .user.ini; on 8.5, fatal_error_backtraces=0. Do not substitute RLIMIT_AS or RLIMIT_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,fuzz templates in every sweep command; replace timeout with the allowlisted wrapper in block 1; add memory_limit=-1 to 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

References

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. CERT-EU, Security Advisory 2026-018, “Critical Vulnerabilities in PHP”, v1.2, 23 October 2026. cert.europa.eu/publications/security-advisories/2026-018
  8. Wiz Research, “PHP2Shell” research post (discovery, timeline, detection hints). www.wiz.io/blog/php2shell-php-rce
  9. Huntress (J. Hammond), “Rapid Response: php2shell, Leaked Critical PHP Vulnerabilities”, 20–21 October 2026. www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities
  10. 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/
  11. Assetnote, php2shell-scanner (README: probe design, network impact and false-positive notes). github.com/assetnote/php2shell-scanner
  12. CISA, Known Exploited Vulnerabilities catalog, JSON feed (catalog version 2026.10.23, checked 25 October 2026). www.cisa.gov/known-exploited-vulnerabilities-catalog
  13. Darkmoon, commit 9dbb9c4, “feat(agents): PHP runtime CVE check — detect PHP2Shell”, ASCIT31/Dark-Moon, 22 October 2026. github.com/ASCIT31/Dark-Moon/commit/9dbb9c4
  14. 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

Next
Run it against your own lab

Darkmoon is open source (GPL-3.0) and self hosted. Clone it, point it at a target you own, and read every line.