Grav CMS 1.7.10 - Code Execution Vulnerabilities

by thomas chauchefoin|

Code Execution Vulnerabilities in Grav CMS

In the lineage of most recent flat-file PHP CMS, Grav CMS is a modern web platform to build fast, safe and extensible websites. It uses a modern technology stack with Twig, Symfony and Doctrine, and offers an administration dashboard that allows managing the whole website (structure, pages, static resources, etc.).  It was voted as “Best Flat File CMS” in 2017 and 2019 and is rapidly gaining traction with over 12k GitHub stars.

As simplicity and security are often key arguments when choosing a flat-file CMS, we recently pursued some security research on Grav CMS 1.7.10. As a result, we discovered two interesting vulnerabilities in the core and the dashboard (respectively CVE-2021-29440 and CVE-2021-29439). These issues can be exploited by authenticated attackers with low privileges, and allow them to compromise the website and its server. In this blog post, we will look at the technical details of these code vulnerabilities and how to patch them.

Impact

The vulnerabilities were confirmed on the last released version of Grav CMS (1.7.10) available at the time of our research and the associated admin module (1.10.10), a module often deployed with Grav and offered as part of a bundle on the official website. The two years old Grav 1.2.0 was also confirmed to be vulnerable.

Remote attackers can leverage the vulnerabilities in multiple attack scenarios:

  • Credentials stuffing, granting access on the administration interface even with low privileges;
  • Compromised or malicious content author;
  • Presence of a Cross-Site Scripting vulnerability on the same perimeter.

The Server-Side Template Injection and Code Execution vulnerability presented in this article are respectively CVE-2021-29440 (affecting the Grav core) and CVE-2021-29439 (affecting the Grav Admin plugin). Both allow to execute arbitrary PHP code and system commands on the underlying server. After our report, the maintainers promptly fixed both issues and released Grav CMS 1.7.11.

Here is a short demonstration of our exploit for CVE-2021-29439:

Technical details

CVE-2021-29440: Unsafe Twig processing of static pages

The Grav administration dashboard allows super-users to create new user accounts, and to grant them privileges in a very granular fashion. Depending on the user’s permissions, additional security mechanisms can be applied. A Cross-Site Scripting filter prevents non-super-users from pushing pages containing script tags or on* attributes. With this in mind, we thought it would be interesting to find a way to gain code execution from this level of privilege.

As for most flat-file content management systems focusing on Markdown, a header (usually named Front Matter) can add contextual information regarding this specific page. It is often used to organize pages in categories, publishing content at a given route, etc. 

After digging in Grav's code, we noticed that the front matter block supports a directive named process.twig, which will apply a Twig rendering pass on the content before serving the page. While this behavior is disabled by default, users with basic page creation privileges enable this feature in the front matter:

title: foo
process:
   twig: true

Recent Server-Side Template Injection research convinced us of one thing: code execution depends on the context but is never too far away!

Looking through the code surrounding Twig, we quickly noticed that the rendering step is not sandboxed: in the Twig ecosystem, it means that any tag, filter, method and properties can be invoked. As mentioned in James Kettle’s Server-Side Template Injection  article, PHP functions however are not mapped into Twig templates and must be explicitly declared. Grav worked around this limitation by registering a callback triggered on each unknown function call:

system/src/Grav/Common/Twig/Twig.php

if ($config->get('system.twig.undefined_functions')) {
    $this->twig->registerUndefinedFunctionCallback(function ($name) {
        if (function_exists($name)) {
            return new TwigFunction($name, $name);
        }
        return new TwigFunction($name, static function () {
        });
    });
}

From here, arbitrary code execution is basically obtained with the right front matter and a template like {{ system("id") }}

Proof-of-Concept of CVE-2021-29440

CVE-2021-29439: Arbitrary module installation

In the case of the admin plugin, most task handlers are implemented in classes/plugin/AdminController.php. To dispatch the incoming request to the right one, a new hook is associated with the event onPagesInitialized (in admin/admin.php), and will ultimately call AdminBaseController::execute(), which will perform an anti-CSRF check ([1]) and call the requested task ([2]):

classes/plugin/AdminController.php

public function execute()
{
    // [...]
    if (!$this->validateNonce()) {             // [1]
        return false;
    }
    $method = 'task' . ucfirst($this->task);
    if (method_exists($this, $method)) {
        try {
            $response = $this->{$method}();      // [2]
        } catch (RequestException $e) {
      // [...]
    return $response;
}

As we can see, no permission check is performed here, and it is not the role of the CSRF protection to do it.

Therefore, the usual implementation of a handler consists of a permission check with AdminController::authorizeTask() ([1]) and then of the actual action, i.e. as seen in AdminController::taskGetUpdates() ([2]):

classes/plugin/AdminController.php

protected function taskGetUpdates()
{
   // [1], permission check
   if (!$this->authorizeTask('dashboard', ['admin.login', 'admin.super'])) {
       return false;
   }
   // [...]
   // [2], implementation
   try {
       $gpm = new GravGPM($flush);
       $resources_updates = $gpm->getUpdatable();

We noticed that the permission check for AdminController::taskInstallPackage() is slightly different, in a way it intends to be generic by checking that the current user has either the permission admin.plugin or admin.theme. However, as $data['type'] is fully controlled by the user, thus having any admin.* permission (admin.posts, admin.login, etc.) is enough to pass the check and install an arbitrary package:

classes/plugin/AdminController.php

protected function taskInstallPackage($reinstall = false)
{
   $data    = $this->post;
   $package = $data['package'] ?? '';
   $type    = $data['type'] ?? '';
   if (!$this->authorizeTask('install ' . $type, ['admin.' . $type, 'admin.super'])) {
       $this->admin->json_response = [
           'status'  => 'error',
           'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
       ];
       return false;
   }

   try {
       $result = Gpm::install($package, ['theme' => $type === 'theme']);

This primitive only allows fetching official plugins listed on the website. While we do not plan to release the plugin’s name and the associated exploitation code (exercise left to the reader!), we were able to find an official plugin that let us obtain arbitrary code execution without requiring more privileges (see the video in the introduction).

Patches

CVE-2021-29440

The Twig rendering vulnerability is not easy to address while maintaining full backward compatibility for existing websites. The maintainers decided to improve the undefined functions resolver to prevent “unsafe” ones to be called. The nature of PHP makes it very hard to establish such a list, so an additional filter had to be implemented to prevent the use of functions that could be used to obtain an unserialize() primitive with the phar scheme wrapper. While an allow list would have been ideal, the risk of breakage of existing instances was too important.

The maintainers are aware of the limitations of this solution and intend to fully address it in the next major release of Grav. You can find the patch and the public advisory on GitHub. After the upgrade to 1.7.11, you should still take time to review the current accounts on your instance, remove the unused ones and assess the risk of credential stuffing.

CVE-2021-29439

This vulnerability was addressed by hardening the authorization checks before the dispatch to task handlers:

classes/plugin/AdminBaseController.php

public function execute()
{
    // Ignore blacklisted views.
    if (in_array($this->view, $this->blacklist_views, true)) {
            return false;
    }
    // Make sure that user is logged into admin.
    if (!$this->admin->authorize()) {
            return false;
    }

Stricter checks were also added in the implementation of existing handlers:

classes/plugin/AdminController.php

protected function taskInstallDependenciesOfPackages()
{
    $type = $this->view;
    if ($type !== 'plugins' && $type !== 'themes') {
            return false;
    }

    if (!$this->authorizeTask('install dependencies', ['admin.' . $type, 'admin.super'])) {
            $this->admin->json_response = [
                'status'  => 'error',
                'message' => [...]        
        ];
            return false;
    }

You can find the patch and the public advisory on GitHub. Meanwhile, if you can’t upgrade to the version that includes the aforementioned patches, you can still temporarily disable the plugin and perform manual edits of the content.

Timeline

DateAction
2021-04-09We report all issues to the official email address
2021-04-09The maintainers discuss and acknowledge our findings
2021-04-14Grav 1.7.11 is released, fixing CVE-2021-29440
2021-04-14Grav Admin 1.10.11 is released, fixing CVE-2021-29439

Summary

We were able to demonstrate the exploitation of two very distinct issues on the administration panel of Grav CMS 1.7.10, with only a reduced set of permissions. Both security issues can enable an attacker to execute arbitrary code on the targeted host server. Further, we analyzed how these severe vulnerabilities were patched.

We’ll be happy to discuss these bugs in our community forum thread.

It is also interesting to note that another very cool unauthenticated code execution vulnerability was discovered by Mehmet Ince in the same code area just before we started our research.

Finally, we would like to thank the maintainers of Grav for acknowledging our advisory and fixing these vulnerabilities super fast in only 5 days.