Логотип exploitDog
Консоль
Логотип exploitDog

exploitDog

github логотип

GHSA-4v9q-p283-qc2m

Опубликовано: 17 сент. 2026
Источник: github
Github: Прошло ревью
CVSS4: 8.2
CVSS3: 5.9

Описание

Grav: Unauthenticated Path Traversal via Missing Directory-Boundary Check in plugin-asset-map.php Static Asset Server (index.php)

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:

$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 (assetsassets-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.

Пакеты

Наименование

getgrav/grav

composer
Затронутые версииВерсия исправления

<= 2.0.14

2.0.15

EPSS

Процентиль: 27%
0.00333
Низкий

8.2 High

CVSS4

5.9 Medium

CVSS3

Дефекты

CWE-22

Связанные уязвимости

CVSS3: 5.9
nvd
около 1 месяца назад

Grav before 2.0.15 contains a path traversal vulnerability in the static asset server within index.php that uses string prefix matching instead of directory-boundary validation. Unauthenticated attackers can access files in sibling directories by exploiting directory names that extend the base path string, such as requesting assets-secret when assets is the configured base.

EPSS

Процентиль: 27%
0.00333
Низкий

8.2 High

CVSS4

5.9 Medium

CVSS3

Дефекты

CWE-22