GHSA-VFMF-Q6X9-CW96
Vulnerability from github – Published: 2026-09-17 20:43 – Updated: 2026-09-17 20:43Affected versions and vulnerable location
- Confirmed on grav core at
78ebfc1(tag 2.0.13). - Detector:
system/src/Grav/Common/Security.php:290, theon_eventsregex, run viapatternMatches()(:315-330). - The
on_eventspattern at HEAD:#<(?:"[^"]*"|'[^']*'|[^>"'])*?(?:[\s\x00-\x20"'/]|"[^"]*"|'[^']*')on\s*[a-z]+\s*=#iu - Sole save-time guard for non-super content:
Validation::checkSafety()(system/src/Grav/Common/Data/Validation.php:160scalars,:165arrays), invoked per field fromBlueprintSchema::validate->Validation::checkSafety(system/src/Grav/Common/Data/BlueprintSchema.php:248).security.xss_whitelist: [admin.super]exempts only super-admins (Validation.php:148).
Root cause (distinct from GHSA-269c)
GHSA-269c hardened the tag-body scan to be quote-aware so a > inside a paired quoted attribute value is treated as data, not a tag close. That same quote-awareness opened a new gap: the regex treats ANY " or ' as a string delimiter, but HTML only enters a quoted-value state when a quote appears immediately after =. A single unpaired quote sitting inside an unquoted attribute value is, to the browser, just a value character; to the regex it is an unterminated string that neither [^>"'] nor "[^"]*" can consume, so the lazy tag-body scan cannot advance past it to reach the following on...= handler. No alignment matches and detectXss() returns null.
Proof (executed)
The detector was replicated verbatim (the on_events regex plus patternMatches) with the shipped system/config/security.yaml defaults and run under PHP. Observed:
baseline <img src=x onerror=alert(1)> => blocked (on_events)
GHSA-269c <img src=x title=">" onerror=alert(1)> => blocked (on_events) # prior fix works
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)
Browser tokenization of <img src=x" onerror=alert(1)>: src takes the unquoted value x" (space ends it), onerror is parsed as a separate live attribute, src 404s and onerror fires. None of the other rules cover it: img/a/div are not in xss_dangerous_tags, there is no javascript:/data: scheme and no style/url/expression.
Reachability
checkSafety() is the only save-time XSS screen for a non-super editor. The pages blueprint validates header.title (type: text) and page content (markdown/textarea) with xss_check on, so a bare onerror= is rejected but the payload above is stored verbatim. Page content is emitted through {{ page.content|raw }} and raw inline HTML passes Parsedown by default (markdown.escape_markup: false), so the handler runs for every visitor. The same detector core also backs Security::detectXssInEditorContent() (the GHSA-2c4f render-time-Twig save gate; callers Page.php:1359, Flex/Types/Pages/PageObject.php:194), the detectXssFromPages() admin scanner (Admin.php:2096), and the xss() Twig function, so all of them report the payload clean.
Auth required: an authenticated content editor with page/form edit rights but WITHOUT admin.super.
Suggested fix
Make the tag-body scan treat a quote as a delimiter only in the after-= position, or normalize unquoted attribute values before the handler scan, so an unpaired quote inside an unquoted value cannot mask a following on...=. A targeted addition: also flag on<name>= sequences that appear after a lone unbalanced quote within the same tag. Because detectXss() is a denylist, consider additionally encoding "/' in stored non-super content, or defaulting markdown.escape_markup: true for non-super authors.
Severity and CVSS reasoning
Suggested severity: High (matches GHSA-269c and the other stored-XSS advisories in this codebase).
Suggested CVSS:3.1 vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N (8.7).
PR:L: a non-super editor account.UI:R: a visitor renders the page (foronerrorthe image simply loads/fails automatically).S:C/C:H/I:H: script in the site origin against every visitor, including admins viewing the content.
How I found it and a note on tooling
I read detectXss() and reasoned about the difference between the HTML tokenizer's quoted-value state and the regex's string handling, then replicated the exact on_events pattern and patternMatches() with the shipped default config and ran the payloads to confirm the bypass and that the GHSA-269c payload is still blocked. I used AI assistance for the analysis and drafting and verified the detector behavior by execution. This is executed against a faithful replica of the detector with production config, not against a full running Grav site.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.0.14"
},
"package": {
"ecosystem": "Packagist",
"name": "getgrav/grav"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.0.15"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-75828"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T20:43:43Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "## Affected versions and vulnerable location\n\n- Confirmed on grav core at `78ebfc1` (tag 2.0.13).\n- Detector: `system/src/Grav/Common/Security.php:290`, the `on_events` regex, run via `patternMatches()` (`:315-330`).\n- The `on_events` pattern at HEAD:\n `#\u003c(?:\"[^\"]*\"|\u0027[^\u0027]*\u0027|[^\u003e\"\u0027])*?(?:[\\s\\x00-\\x20\"\u0027/]|\"[^\"]*\"|\u0027[^\u0027]*\u0027)on\\s*[a-z]+\\s*=#iu`\n- Sole save-time guard for non-super content: `Validation::checkSafety()` (`system/src/Grav/Common/Data/Validation.php:160` scalars, `:165` arrays), invoked per field from `BlueprintSchema::validate` -\u003e `Validation::checkSafety` (`system/src/Grav/Common/Data/BlueprintSchema.php:248`). `security.xss_whitelist: [admin.super]` exempts only super-admins (`Validation.php:148`).\n\n## Root cause (distinct from GHSA-269c)\n\nGHSA-269c hardened the tag-body scan to be quote-aware so a `\u003e` inside a paired quoted attribute value is treated as data, not a tag close. That same quote-awareness opened a new gap: the regex treats ANY `\"` or `\u0027` as a string delimiter, but HTML only enters a quoted-value state when a quote appears immediately after `=`. A single unpaired quote sitting inside an unquoted attribute value is, to the browser, just a value character; to the regex it is an unterminated string that neither `[^\u003e\"\u0027]` nor `\"[^\"]*\"` can consume, so the lazy tag-body scan cannot advance past it to reach the following ` on...=` handler. No alignment matches and `detectXss()` returns null.\n\n## Proof (executed)\n\nThe detector was replicated verbatim (the `on_events` regex plus `patternMatches`) with the shipped `system/config/security.yaml` defaults and run under PHP. Observed:\n\n```text\nbaseline \u003cimg src=x onerror=alert(1)\u003e =\u003e blocked (on_events)\nGHSA-269c \u003cimg src=x title=\"\u003e\" onerror=alert(1)\u003e =\u003e blocked (on_events) # prior fix works\nBYPASS A \u003cimg src=x\" onerror=alert(1)\u003e =\u003e PASSES (no XSS detected)\nBYPASS B \u003cimg title=x\" onerror=alert(1)\u003e =\u003e PASSES (no XSS detected)\nBYPASS C \u003ca href=x\" onmouseover=alert(1)\u003ex\u003c/a\u003e =\u003e PASSES (no XSS detected)\nBYPASS D \u003cimg src=x\u0027 onerror=alert(1)\u003e =\u003e PASSES (no XSS detected)\nBYPASS E \u003cdiv id=x\" onmouseover=alert(1)\u003ehover\u003c/div\u003e =\u003e PASSES (no XSS detected)\n```\n\nBrowser tokenization of `\u003cimg src=x\" onerror=alert(1)\u003e`: `src` takes the unquoted value `x\"` (space ends it), `onerror` is parsed as a separate live attribute, `src` 404s and `onerror` fires. None of the other rules cover it: `img`/`a`/`div` are not in `xss_dangerous_tags`, there is no `javascript:`/`data:` scheme and no `style`/`url`/`expression`.\n\n## Reachability\n\n`checkSafety()` is the only save-time XSS screen for a non-super editor. The pages blueprint validates `header.title` (`type: text`) and page `content` (markdown/textarea) with `xss_check` on, so a bare `onerror=` is rejected but the payload above is stored verbatim. Page content is emitted through `{{ page.content|raw }}` and raw inline HTML passes Parsedown by default (`markdown.escape_markup: false`), so the handler runs for every visitor. The same detector core also backs `Security::detectXssInEditorContent()` (the GHSA-2c4f render-time-Twig save gate; callers `Page.php:1359`, `Flex/Types/Pages/PageObject.php:194`), the `detectXssFromPages()` admin scanner (`Admin.php:2096`), and the `xss()` Twig function, so all of them report the payload clean.\n\nAuth required: an authenticated content editor with page/form edit rights but WITHOUT `admin.super`.\n\n## Suggested fix\n\nMake the tag-body scan treat a quote as a delimiter only in the after-`=` position, or normalize unquoted attribute values before the handler scan, so an unpaired quote inside an unquoted value cannot mask a following `on...=`. A targeted addition: also flag ` on\u003cname\u003e=` sequences that appear after a lone unbalanced quote within the same tag. Because `detectXss()` is a denylist, consider additionally encoding `\"`/`\u0027` in stored non-super content, or defaulting `markdown.escape_markup: true` for non-super authors.\n\n## Severity and CVSS reasoning\n\nSuggested severity: High (matches GHSA-269c and the other stored-XSS advisories in this codebase).\n\nSuggested CVSS:3.1 vector: `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N` (8.7).\n\n- `PR:L`: a non-super editor account.\n- `UI:R`: a visitor renders the page (for `onerror` the image simply loads/fails automatically).\n- `S:C/C:H/I:H`: script in the site origin against every visitor, including admins viewing the content.\n\n## How I found it and a note on tooling\n\nI read `detectXss()` and reasoned about the difference between the HTML tokenizer\u0027s quoted-value state and the regex\u0027s string handling, then replicated the exact `on_events` pattern and `patternMatches()` with the shipped default config and ran the payloads to confirm the bypass and that the GHSA-269c payload is still blocked. I used AI assistance for the analysis and drafting and verified the detector behavior by execution. This is executed against a faithful replica of the detector with production config, not against a full running Grav site.",
"id": "GHSA-vfmf-q6x9-cw96",
"modified": "2026-09-17T20:43:43Z",
"published": "2026-09-17T20:43:43Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/security/advisories/GHSA-vfmf-q6x9-cw96"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-75828"
},
{
"type": "PACKAGE",
"url": "https://github.com/getgrav/grav"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/grav-before-stored-xss-via-detectxss-quote-bypass"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Grav: detectXss() misses an event-handler attribute after an unpaired quote in an unquoted attribute value, giving stored XSS"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.