# Grav: detectXss() misses an event-handler attribute after an unpaired quote in an unquoted attribute value, giving stored XSS

Grav's detectXss() regex treats any quote as a string delimiter, so an unpaired quote in an unquoted attribute value hides a following onerror handler, enabling stored XSS by non-super editors.

- Payout: Research
- Severity: critical
- Program: Grav
- Found by: arpitjain099
- Disclosed: 2026-09-17
- CVE: CVE-2026-75828
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-vfmf-q6x9-cw96
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-vfmf-q6x9-cw96
- Tags: area:web domain:web impact:ato impact:integrity kind:research platform:ghsa sev:critical tech:php technique:incomplete-fix technique:parser-differential technique:source-review vuln:xss

**Target:** PHP XSS denylist regex in Security::detectXss / Validation::checkSafety
**Root cause:** The on_events regex treats any " or ' as a string delimiter, but HTML only enters quoted-value state when a quote follows '='. An unpaired quote inside an unquoted attribute value is unconsumable by the lazy tag-body scan, so it cannot advance to the following on...= handler.
**Steps:**
1. Read the on_events pattern at system/src/Grav/Common/Security.php:290 and patternMatches() at :315-330.
2. Replicate the regex and patternMatches() with shipped system/config/security.yaml defaults under PHP.
3. Confirm baseline `<img src=x onerror=alert(1)>` and GHSA-269c payload `<img src=x title=">" onerror=alert(1)>` are blocked.
4. Submit `<img src=x" onerror=alert(1)>` — detectXss() returns null (bypass).
5. Variants also pass: `<img title=x" onerror=alert(1)>`, `<a href=x" onmouseover=alert(1)>x</a>`, `<img src=x' onerror=alert(1)>`, `<div id=x" onmouseover=alert(1)>hover</div>`.
6. Store payload via pages blueprint header.title or content (xss_check on) as non-super editor; content renders through `{{ page.content|raw }}` with markdown.escape_markup: false.
**How found:** Reasoned about the mismatch between the HTML tokenizer's quoted-value state (quote only after '=') and the regex's blanket quote handling; noted GHSA-269c's quote-awareness fix created a gap for unpaired quotes in unquoted values, then replicated the exact detector and executed payloads to confirm bypass while verifying the prior fix still blocks.
**Exploitation:** An authenticated editor without admin.super stores the payload in page title or markdown content. The save-time checkSafety() screen passes it, and the page emits it raw to every visitor; the browser parses src as unquoted value `x"`, treats onerror as a live attribute, the image 404s and the handler fires in site origin, including for admins viewing the page.
**Escalation:** Script executes in site origin against all visitors including super-admins, enabling session/CSRF-style actions beyond the editor's privileges.
**Insight:** When a denylist regex emulates an HTML tokenizer, diff its state machine against the spec: quote-awareness added for one bug (paired quotes) often breaks on unpaired quotes in unquoted attribute values, letting a following event handler slip past the lazy scan.

## Proof of concept (copied exactly from the original report)

**Bypass payloads that evade detectXss**

```
BYPASS A  <img src=x" onerror=alert(1)>           => PASSES (no XSS detected)
BYPASS B  <img title=x" onerror=alert(1)>         => PASSES (no XSS detected)
BYPASS C  <a href=x" onmouseover=alert(1)>x</a>   => PASSES (no XSS detected)
BYPASS D  <img src=x' onerror=alert(1)>           => PASSES (no XSS detected)
BYPASS E  <div id=x" onmouseover=alert(1)>hover</div> => PASSES (no XSS detected)
```

