28 February 2024 CVE Nicolas D.

From a warning to a CSRF impacting over 3 million sites

WordPress is a fascinating piece of technology. It's first version was released in 2003, more than 20 years ago, and it has made its way up to being the most used Content Management System (CMS). It is used by no more than around 43% of all websites in the world.

At Patrowl, we are doing automated pentesting at large scale with almost 18.000 assets being pentested and more than 200.000 assets in passive recon and what we see confirms these statistics: WordPress is used everywhere! With corporate websites, blogs or e-commerce platforms, WordPress is known for being usable in almost any circumstance. And this is only possible thanks to its famous plugin system.

WordPress is made so that any plugin can interact with absolutely everything using components such as actions, filters or blocks. Because of these, a plugin can change the way WordPress looks, how it behaves and, by the way, it can actually significantly reduce its security level.

Looking for targets

As pentesters, we love to look at WordPress sites and check the security of plugins installed. The list of installed plugins is easy to find thanks to our unique solution that scans all assets and returns us with vulnerabilities but also information about technologies being used.

Recently, we received an alert regarding a PHP warning on a WordPress site:

Having such warning is a good hint that there might be interesting things to find. So we have a target: UpdraftPlus with 3+ million active installations.

CVE-2023-5982 - UpdraftPlus <= 1.23.10 - Cross-Site Request Forgery to Google Drive Storage Update

Description

The UpdraftPlus plugin helps users to manage backups for WordPress. It is a widely used plugin with more than 3 million installations. To manage backups, it is possible to link the plugin with a Google account to automatically send backups to a Drive directory.

While reviewing the security of this plugin, Patrowl found that it is vulnerable to a CSRF attack that could enable an attacker to setup its own Google Drive account as remote storage. Any future WordPress backup would then be sent in the attacker's Google Drive directory, leading to a leak of sensitive data and a possible complete takeover of the WordPress instance. Indeed, a backup can contain all files of the WordPress installation, all settings and the database content.

Code source analysis

During the plugin initialisation, the handle_url_actions function is associated with the init action:

// class-updraftplus.php L.126
add_action('init', array($this, 'handle_url_actions'));

This function gets the value of the action query string and matches it against a regex to ensure it follows this schema:

updraftmethod-[method_name]-[call_method]

It ensures that the [method_name] method is defined and that the user is authorized to manage the UpdraftPlus plugin. This last check efficiently prevents an anonymous user from executing the next instructions of the function.

However, no protection against Cross-Site Request Forgery (CSRF) attacks is implemented in the handle_url_actions function. This kind of attack involves tricking a privileged authenticated user into visiting a malicious website. This website will send a request on behalf the user.

Well, being possible to exploit a CSRF attack on this endpoint is interesting but let's dive deeper and see what can be done with this endpoint.

After a few checks, the plugin will try to call the action_[call_method] method of the class located in /methods/[method_name].php. There are quite a few methods available, but one of them caught our attention: /methods/googledrive.php. It contains the following action_auth function:

// /methods/googledrive.php L.31
public function action_auth() {
    if (isset($_GET['state'])) {

        $parts = explode(':', $_GET['state']);
        $state = $parts[0];

        if ('success' == $state) {

            if (isset($_GET['user_id']) && isset($_GET['access_token'])) {
                $code = array(
                    'user_id' => $_GET['user_id'],
                    'access_token' => $_GET['access_token']
                );
            } else {
                $code = array();
            }

            $this->do_complete_authentication($state, $code);

        } elseif ('token' == $state) {
            $this->gdrive_auth_token();
        } elseif ('revoke' == $state) {
            $this->gdrive_auth_revoke();
        }
    } elseif (isset($_GET['updraftplus_googledriveauth'])) {
        if ('doit' == $_GET['updraftplus_googledriveauth']) {
            $this->action_authenticate_storage();
        } elseif ('deauth' == $_GET['updraftplus_googledriveauth']) {
            $this->action_deauthenticate_storage();
        }
    }
}

If the states, user_id and access_tokenGET parameters are defined, the do_complete_authentication method will be called with the following value:

  • state = "success:[instance_id]"
  • code = array('user_id': [user_id], 'access_token': [access_token])

Here is the code of this function:

// methods/googledrive.php L.448
public function do_complete_authentication($state, $code, $return_instead_of_echo = false) {
    
    // If these are set then this is a request from our master app and the auth server has returned these to be saved.
    if (isset($code['user_id']) && isset($code['access_token'])) {
        $opts = $this->get_options();
        $opts['user_id'] = base64_decode($code['user_id']);
        $opts['tmp_access_token'] = base64_decode($code['access_token']);
        // Unset this value if it is set as this is a fresh auth we will set this value in the next step
        if (isset($opts['expires_in'])) unset($opts['expires_in']);
        // remove our flag so we know this authentication is complete
        if (isset($opts['auth_in_progress'])) unset($opts['auth_in_progress']);
        $this->set_options($opts, true);
    }

    if ($return_instead_of_echo) {
        return $this->show_authed_admin_success($return_instead_of_echo);
    } else {
        add_action('all_admin_notices', array($this, 'show_authed_admin_success'));
    }
}

This function simply saves the user_id and access_token provided by the user as options for the plugin. These values will then be used each time a backup is performed to save data in the Drive associated with the provided credentials.

An important flaw in this function is that its first argument, the $state variable, is never used and no verification is performed on its value. Indeed, the state variable is expected to have the following format: success:[instance_id] where instance_id is a random value identifying the plugin instance. This value is not known by an anonymous attacker and if it were checked, an attacker wouldn't be able to perform a CSRF attack.

Exploitation

Well, being able to manipulate some settings of the plugin using a CSRF attack seems interesting, but how is it really exploitable and what can we do using this attack?

After the options are set by the do_complete_authentication function, the plugin uses this information to authenticate on the Google account and, if the plugin is configured to use Google Drive as a remote storage for the backups, the next backups will be saved on the associated Google Drive directory.

Therefore, to exploit this vulnerability, an attacker should first generate a valid Google Drive access token before sending it to the victim. The easiest way to generate a valid token is to deploy the UpraftPlus plugin on a local instance of WordPress, go to the settings of the plugin and activate the Google Drive remote storage, save settings and perform the authentication with a valid Google account. At the end of the process, a redirection is performed on auth.updraftplus.com containing a link to the local instance of WordPress: https://[domain]/wp-admin/options-general.php?action=updraftmethod-googledrive-auth&state=success%3A[instance_id]&access_token=[access_token]&user_id=[user_id]

To exploit the CSRF vulnerability, just change the domain name in this link and send it to an authenticated administrator on the targeted WordPress site.

If the attack is successful, the attacker will receive backups in its own Google Drive account, potentially including database and settings backups, depending on the plugin configuration.

Remediation

Here is the fix implemented in the handle_url_actions function by the vendor:

// If we don't have an instance_id but the state is set then we are coming back to finish the auth and should extract the instance_id from the state
if ('' == $instance_id && isset($state) && false !== strpos($state, ':')) {
    $parts = explode(':', $state);
    $instance_id = $parts[1];
}

if (!preg_match('/^[-A-Z0-9]+$/i', $instance_id)) die('Invalid input.');
if (empty($storage_objects_and_ids[$method]['instance_settings'][$instance_id])) {
    error_log("UpdraftPlus::handle_url_actions(): no such instance ID found in settings.");
    return;
}

If a state variable is provided by the user, the instance_id it contains is compared to the current plugin instance id. Because the instance_id value is not known by an attacker, it effectively prevent CSRF attacks.

Conclusion

Is the vulnerability easily exploitable? Not really, as for almost any CSRF attack, even if an attack on a plugin with 3+ million active installations may be doable. But this finding shows that developing a secured WordPress plugin is difficult. Even if permissions are checked, developers should ensure no CSRF attack is possible. And if such protection is implemented, maybe an attacker could leak the CSRF token through a verbose entry point?

At Patrowl, we love automated and continuous pentest. Our unique scanning and testing automation guides us into manual searching to ensure we only return real vulnerabilities to our customers. We will continue to look for new vulnerabilities based on findings we get on our clients' assets, so stay tuned for future posts!