On 21 July 2026 the CVE numbering authority VulnCheck published CVE-2026-65008 — a critical flaw in the PHP CMS Grav in all versions before 2.0.7. It allows arbitrary shell commands to run on the server (CWE-94, code injection). It is rated CVSS v4.0 9.3 and CVSS v3.1 9.8, both critical. The bug is fixed in Grav 2.0.7.
CVE ID: CVE-2026-65008
GHSA: GHSA-fj2p-qj2f-74v5 (getgrav)
CVSS v4.0: 9.3 (critical)
CVSS v3.1: 9.8 (critical)
CWE: CWE-94 (code injection)
Affected: Grav CMS in all versions before 2.0.7
Fixed: Grav 2.0.7
Published: 21 July 2026 (VulnCheck)
We reproduced the case in an isolated lab using the real Grav releases — from the exploit through the detection firing in the SIEM to the fix. This article explains where the flaw sits, how an attack unfolds, how it becomes visible at the endpoint and which measure stops it; backed by the material from that run.
The problem: a callable string with no allowlist
Grav builds forms and fields from so-called blueprints. A blueprint can produce field values dynamically by calling a function — a directive of the form data-*@. To do so, the method Blueprint::dynamicData() takes a callable string written as Class::method together with its arguments and hands it without any allowlist straight to call_user_func_array(). Nothing checks which function ends up there.
The second building block is a public helper, Grav\Common\Utils::arrayFilterRecursive($source, $fn). It calls the supplied function $fn on every element of an array. Wire it into the dynamic directive as the callable and give it system as the function to call, and it runs a shell command that sits in the array. Through that detour, the missing check in dynamicData() turns into full code execution.
What makes it serious: the Form pluginprocesses a page's frontmatter through exactly this path. The malicious directive only has to sit in a page's frontmatter.
Scenarios: who plants it and who triggers it
The attack has two separate roles, and that is what makes it treacherous.
Planting. An account with the admin.pages or api.pages.writepermission writes the directive into a page's frontmatter. That is an editor or API write permission, not administrative access to the server.
Triggering. The command runs as soon as anyone opens the prepared page — including an unauthenticated visitor. The commands run with the privileges of the web-server user.
In the lab we reproduced exactly this core: real Grav 2.0.4 (the upstream release, installed via composer), invoked through the documented data-*@ directive. The original code ran the command as www-data:

We triggered the vulnerable code path (dynamicData() → call_user_func_array() → the system trampoline) directly through the blueprint interface of the real Grav release — the route a malicious frontmatter directive takes. We did not stand up the preceding HTTP and form submission against a running website; the executed code is the same.
The fix: detect and patch — both shown in the lab
Detection. An attack like this shows at the endpoint: the web-server user suddenly starts a shell — anomalous behaviour for a PHP web server. For that we wrote a dedicated detection rule and connected the Grav host to a Wazuh instance. As soon as the www-data account starts a shell, the rule (ID 100710) fires. After the exploit runs, the alert is visible in the Wazuh dashboard — with the triggering process (/usr/bin/dash), the user (uid 33, www-data) and the mapping to MITRE ATT&CK T1059.004:

That makes the detection demonstrated: the rule is written, fired in the lab and shown here as a dashboard screenshot. A raw log alone would not stand in for that proof.
Patching. The primary and effective measure remains the upgrade to Grav 2.0.7. The patched version checks before the call whether a dynamic directive is dangerous: a directly dangerous function (system, exec and the like) is refused, and every argument is scanned so a harmless helper cannot serve as a trampoline for a dangerous function. That removes exactly the system trampoline via arrayFilterRecursive. We ran the same payload against real Grav 2.0.7 — the call is refused, no command runs:

If an upgrade is not immediately possible, the mechanism suggests two useful interim steps: keep the number of accounts with admin.pages and api.pages.write small and make their activity auditable, and switch on the detection shown above (web-server user starts a shell). Neither replaces the patch, but both shorten the window.
As of this research, CVE-2026-65008 is not listed in the CISA KEV catalog, and active exploitation in the wild is not publicly documented. For a critical, well-documented code execution that is more a question of timing than of likelihood — patch prioritisation should not depend on it.
What it means for operations
A single critical flaw in widely deployed web software is a classic prioritisation case: where is a vulnerable Grav instance running, is it reachable, and how fast does the patch reach it? That is the view a Threat Exposure Filter provides — surface exposed instances and order patching by exploitability. Detecting vulnerable versions in the estate and rolling out the fix in an orderly way is the job of vulnerability and patch management; a vulnerability scan finds the affected systems.
