# Grav: Incomplete callable validation in blueprint dynamic fields allows arbitrary static method invocation and file disclosure

Grav's blueprint dynamic-field guard skips any callable containing '::', letting an admin.pages editor invoke arbitrary static PHP methods to read /etc/passwd and account hashes anonymously.

- Payout: Research
- Severity: high
- Program: Grav
- Found by: adamyordan
- Disclosed: 2026-09-17
- CVE: CVE-2026-69088
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-7pgq-cr25-xvc8
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-7pgq-cr25-xvc8
- Tags: area:web domain:web impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:php technique:incomplete-fix technique:source-review vuln:access-control vuln:info-disclosure

**Target:** Grav CMS blueprint dynamic-field directive handling (Blueprint::isSafeDynamicCall / dynamicData)
**Root cause:** isSafeDynamicCall() only consults Utils::isDangerousFunction() when the callable string lacks '::', so every Class::method static call bypasses the denylist and reaches call_user_func_array() with no class/method allowlist.
**Steps:**
1. Log in to /admin as an account with only admin.login + admin.pages (GET /admin for nonce, POST task=login).
2. POST /admin/pages/<route> with session cookie, admin nonce, and data[frontmatter] containing a field with `data-opts@: ['Grav\Common\Utils::download', '/etc/passwd', false, 0, 1024, mime: 'text/plain']`; server returns 200.
3. Request GET /<route> unauthenticated (fresh query string to avoid cache; retry if 404 while index catches up) — response body contains raw /etc/passwd.
4. Point path at `user/accounts/<name>.yaml` to disclose bcrypt hashed_password.
5. For write: use `Grav\Common\Filesystem\Folder::copy` with source and attacker-chosen destination under user/pages/ to create a new rendered page.
**How found:** Code review of the incomplete fix: noticed isSafeDynamicCall() gates the dangerous-function denylist behind `!str_contains($function, '::')`, meaning qualified static calls never reach Utils::isDangerousFunction() (which would flag colons/backslashes), then tested with built-in static gadgets like Utils::download and Folder::copy.
**Exploitation:** A page editor plants a data-*@ directive in page frontmatter; the directive's Class::method string passes the guard and is executed via call_user_func_array() when the page renders. Anonymous visitors trigger it by requesting the page, receiving file contents (e.g. /etc/passwd, account YAML with bcrypt hashes) in the HTTP response; Folder::copy/move/delete give file and directory write/delete under the web-server account.
**Escalation:** Disclosed user/accounts/*.yaml bcrypt hashes can be cracked offline; recovery of a weak or reused admin password leads to full admin-panel compromise. Any public static method in loaded code is reachable, so impact is bounded only by the gadget surface.
**Insight:** When a security fix adds a denylist check, look for early-exit conditions that skip the check for whole syntax classes (here '::' static calls); then enumerate public static methods in the codebase as ready-made gadgets for file read/write.

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

**Frontmatter YAML with download gadget directive**

```
forms:
      x:
        fields:
          y:
            type: text
            data-opts@:
              - 'Grav\Common\Utils::download'
              - '/etc/passwd'
              - false
              - 0
              - 1024
              - mime: 'text/plain'
```

