GHSA-4V9Q-P283-QC2M
Vulnerability from github – Published: 2026-09-17 20:43 – Updated: 2026-09-17 20:43Verified 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.phpto 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:
$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:
if ($realFile && $realBase && (
$realFile === $realBase ||
str_starts_with($realFile, $realBase . DIRECTORY_SEPARATOR)
) && is_file($realFile)) {
This is the standard fix for this exact bug class , ensuring the matched prefix ends exactly at a directory boundary, not partway through a longer sibling name.
{
"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-74907"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T20:43:16Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "**Verified against:** `getgrav/grav` devel branch, `GRAV_VERSION = \"2.0.15\"`, file `index.php\n\n## Title\nUnauthenticated Path Traversal via Missing Directory-Boundary Check in `plugin-asset-map.php` Static Asset Server (`index.php`)\n\n## Product / Affected Versions\n- Product: `getgrav/grav`\n- File: `index.php` (top-level front controller, runs before Grav itself boots)\n- Confirmed present in: devel branch, 2.0.15\n- **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\u0027s 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.\n\n## CWE\nCWE-22: Improper Limitation of a Pathname to a Restricted Directory (\u0027Path Traversal\u0027) , 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\u0027s name as a string.\n\n## Description\n\n`index.php` implements a fast-path static file server that runs *before* Grav\u0027s own routing/security stack, gated on the presence of an asset-map file:\n\n```php\n$assetMapFile = __DIR__ . \u0027/user/config/plugin-asset-map.php\u0027;\nif (is_file($assetMapFile)) {\n $assetMap = require $assetMapFile;\n foreach ($assetMap as $routePrefix =\u003e $diskPath) {\n if (str_starts_with($path, $routePrefix)) {\n $relPath = substr($path, strlen($routePrefix));\n $filePath = __DIR__ . \u0027/\u0027 . ltrim($diskPath, \u0027/\u0027) . $relPath;\n $realFile = realpath($filePath);\n $realBase = realpath(__DIR__ . \u0027/\u0027 . ltrim($diskPath, \u0027/\u0027));\n if ($realFile \u0026\u0026 $realBase \u0026\u0026 str_starts_with($realFile, $realBase) \u0026\u0026 is_file($realFile)) {\n // ... serves $realFile directly, with Content-Type inferred from extension\n readfile($realFile);\n exit;\n }\n }\n }\n}\n```\n\n`realpath()` correctly resolves `..` sequences, so a naive `../../etc/passwd`-style traversal that leaves the filesystem entirely is blocked (it wouldn\u0027t share the `$realBase` string prefix). **But the containment check itself, `str_starts_with($realFile, $realBase)`, has no directory-boundary awareness** it\u0027s 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\u0027s name (`assets` \u2192 `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).\n\n## Live Proof of Concept\n\n**Setup:** the exact code block above, extracted verbatim from `index.php`, executed with PHP 8.3.6 against a realistic directory layout (a plugin\u0027s active `assets/` dir sitting next to an unrelated `assets-secret/` dir containing a fake secret):\n\n```\nuser/plugins/myplugin/assets/app.js \u003c- intended, public\nuser/plugins/myplugin/assets-secret/config.php \u003c- NOT intended to be served\nuser/config/plugin-asset-map.php:\n return [\u0027/myplugin-assets\u0027 =\u003e \u0027user/plugins/myplugin/assets\u0027];\n```\n\n**Legitimate request** (`/myplugin-assets/app.js`):\n```\nrealFile: \u0027/home/claude/grav-poc/user/plugins/myplugin/assets/app.js\u0027\nrealBase: \u0027/home/claude/grav-poc/user/plugins/myplugin/assets\u0027\n\u003e\u003e\u003e WOULD SERVE FILE \u003c\u003c\u003c\n\u003e\u003e\u003e Content: public asset content\n```\n\n**Traversal request** (`/myplugin-assets/../assets-secret/config.php`):\n```\nrealFile: \u0027/home/claude/grav-poc/user/plugins/myplugin/assets-secret/config.php\u0027\nrealBase: \u0027/home/claude/grav-poc/user/plugins/myplugin/assets\u0027\n\u003e\u003e\u003e WOULD SERVE FILE \u003c\u003c\u003c\n\u003e\u003e\u003e Content: SECRET_API_KEY=sk_live_totally_secret_12345\n```\n\n`str_starts_with(\u0027.../assets-secret/config.php\u0027, \u0027.../assets\u0027)` evaluates `true` because `assets-secret` literally begins with the characters `assets` there is no separator-boundary check (e.g. requiring `$realBase . \u0027/\u0027` as the actual prefix) to prevent this.\n\n## Trust-boundary framing (per Grav\u0027s own SECURITY.md)\nThis code path requires **no Grav account** , it runs before Grav even initializes, directly off the raw request path. Per Grav\u0027s own stated criteria: *\"An unauthenticated attacker can achieve RCE, exfiltrate site data, or gain admin-equivalent control. No Grav account required\"* \u2192 this matches the **CRITICAL** bar exactly, for any deployment where the `plugin-asset-map.php` mechanism is in active use.\n\n## Suggested Fix\nAppend a trailing directory separator before the prefix comparison, or use a proper containment check:\n```php\nif ($realFile \u0026\u0026 $realBase \u0026\u0026 (\n $realFile === $realBase ||\n str_starts_with($realFile, $realBase . DIRECTORY_SEPARATOR)\n) \u0026\u0026 is_file($realFile)) {\n```\nThis is the standard fix for this exact bug class\n, ensuring the matched prefix ends exactly at a directory boundary, not partway through a longer sibling name.",
"id": "GHSA-4v9q-p283-qc2m",
"modified": "2026-09-17T20:43:16Z",
"published": "2026-09-17T20:43:16Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/security/advisories/GHSA-4v9q-p283-qc2m"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-74907"
},
{
"type": "PACKAGE",
"url": "https://github.com/getgrav/grav"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/grav-before-path-traversal-via-plugin-asset-map-php"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Grav: Unauthenticated Path Traversal via Missing Directory-Boundary Check in `plugin-asset-map.php` Static Asset Server (`index.php`)"
}
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.