Site-Wide Reflected XSS in Freemius WordPress SDK Affecting Millions of Sites

Published 18 July 2023
Updated 29 November 2023
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

There is a Site-Wide Reflected XSS in the Freemius WordPress SDK - the vulnerability is in versions <= 2.5.9 and it affects millions of sites.

Patchstack Developer and Business users are protected from the vulnerability. You can also sign up for the Patchstack Community plan to be notified about vulnerabilities as soon as they become disclosed.

This blog post is about the Freemius WordPress SDK vulnerability. If you're a vendor of a plugin or theme that utilizes the Freemius SDK library, please update the SDK library to at least version 2.5.10.

For plugin developers, we have security audit services and Threat Intelligence Feed API for hosting companies.

About the Freemius WordPress SDK

The Freemius WordPress SDK (versions 2.5.9 and below), which is used in over a thousand plugins and dozens of themes, is estimated to be installed on over 7+ million sites and is known as the library that integrates Freemius services to WordPress plugins and themes products.

Freemius itself is the most popular managed eCommerce platform for selling WordPress plugins and themes, allowing developers to quickly get started selling and licensing their products.

Site-Wide Reflected XSS in the Freemius WordPress SDK - the vulnerability is in versions <= 2.5.9 and it affects millions of sites.

The Site-Wide Reflected XSS in Freemius WordPress SDK

This Site-Wide Reflected XSS in Freemius WordPress SDK vulnerability could allow any unauthenticated user to steal sensitive information to, in this case, privilege escalation on the WordPress site by tricking privileged or administrator users into visiting a certain crafted URL.

This vulnerability occurs because the code that handles input from the user doesn't implement sanitization and output escaping. The described vulnerability was fixed in version 2.5.10 and assigned CVE-2023-33999.

The vulnerability exists in the function fs_request_get:

function fs_request_get( $key, $def = false, $type = false ) {
    if ( is_string( $type ) ) {
        $type = strtolower( $type );
    }

    /**
     * Note to WordPress.org Reviewers:
     *  This is a helper method to fetch GET/POST user input with an optional default value when the input is not set. The actual sanitization is done in the scope of the function's usage.
     */
    switch ( $type ) {
        case 'post':
            $value = isset( $_POST[ $key ] ) ? $_POST[ $key ] : $def;
            break;
        case 'get':
            $value = isset( $_GET[ $key ] ) ? $_GET[ $key ] : $def;
            break;
        default:
            $value = isset( $_REQUEST[ $key ] ) ? $_REQUEST[ $key ] : $def;
            break;
    }

    return $value;
}

Notice that the function itself is just a normal function to fetch a request parameter from the request. If we look closely at the library code, this function is called many times in different areas or processes. The function dynamic_init is one of the functions that calls the fs_request_get function:

-------------------------- CUT HERE --------------------------
if ( $this->is_user_in_admin() ) {
    if ( $this->is_registered() && fs_request_has( 'purchase_completed' ) ) {
        $this->_admin_notices->add_sticky(
            sprintf(
            /* translators: %s: License type (e.g. you have a professional license) */
                $this->get_text_inline( 'You have purchased a %s license.', 'you-have-x-license' ),
                fs_request_get( 'purchased_plan' )
            ) .
            sprintf(
                $this->get_text_inline(" The %s's %sdownload link%s, license key, and installation instructions have been sent to %s. If you can't find the email after 5 min, please check your spam box.", 'post-purchase-email-sent-message' ),
                $this->get_module_label( true ),
                ( FS_Plugin::is_valid_id( $this->get_bundle_id() ) ? "products' " : '' ),
                ( FS_Plugin::is_valid_id( $this->get_bundle_id() ) ? 's' : '' ),
                sprintf(
                    '<strong>%s</strong>',
                    fs_request_get( 'purchase_email' )
                )
            ),
            'plan_purchased',
            $this->get_text_x_inline( 'Yee-haw', 'interjection expressing joy or exuberance', 'yee-haw' ) . '!'
        );
    }
-------------------------- CUT HERE --------------------------

Notice fs_request_get( 'purchased_plan' ) and fs_request_get( 'purchase_email' ) will be directly constructed as input to this->_admin_notices->add_sticky() function. In short, the add_sticky() function will display the input message as admin notices without additional message escaping or sanitization. The notice also will always be displayed in the WP Admin area until the user dismisses the notice.

Most of the plugins and themes that utilize Freemius will have a popup modal message after activation for opt-in and opt-out choices to get email notifications for security & feature updates :

Site-Wide Reflected XSS in Freemius WordPress SDK

If the user chooses to opt-in, he/she will get a confirmation email for the opt-in process. For the case that user confirms the opt-in from the link sent to their email, we could utilize the dynamic_init function call to trigger the XSS.

In another case if the user chooses not to opt in, we still could trigger the XSS from the function _install_with_new_user:

-------------------------- CUTTED HERE --------------------------
} else if ( $has_pending_activation_confirmation_param ) {
    $this->set_pending_confirmation(
        fs_request_get( 'user_email' ),
        true,
        false,
        false,
        fs_request_get_bool( 'is_suspicious_email' ),
        fs_request_get_bool( 'has_upgrade_context' ),
        fs_request_get( 'support_email_address' )
    );
}
-------------------------- CUTTED HERE --------------------------

In this case, the fs_request_get( 'user_email' ) and fs_request_get( 'support_email_address' ) input is also not escaped or sanitized and directly passed to the $this->set_pending_confirmation() function here:

-------------------------- CUTTED HERE --------------------------
private function set_pending_confirmation(
            $email = false,
            $redirect = true,
            $license_key = false,
            $is_pending_trial = false,
            $is_suspicious_email = false,
            $has_upgrade_context = false,
            $support_email_address = false
        ) {
            $is_network_admin = fs_is_network_admin();

            if ( $this->_ignore_pending_mode && ! $has_upgrade_context ) {
                /**
                 * If explicitly asked to ignore pending mode, set to anonymous mode
                 * if require confirmation before finalizing the opt-in except after completing a purchase (otherwise, in this case, they wouldn't see any notice telling them that they should receive their license key via email).
                 *
                 * @author Vova Feldman
                 * @since  1.2.1.6
                 */
                $this->skip_connection( $is_network_admin );
            } else {
                // Install must be activated via email since
                // user with the same email already exist.
                $this->_storage->is_pending_activation = true;
                $this->_add_pending_activation_notice(
                    $email,
                    $is_pending_trial,
                    $is_suspicious_email,
                    $has_upgrade_context,
                    $support_email_address
                );
            }
-------------------------- CUTTED HERE --------------------------

The function above will call another function _add_pending_activation_notice() with $email and $support_email_address parameter. Let's look at how those two input parameters are processed:

-------------------------- CUTTED HERE --------------------------
$formatted_message_args = array(
    "<b>{$this->get_plugin_name()}</b>",
    "<b>{$email_address}</b>",
);

-------------------------- CUTTED HERE --------------------------
$formatted_message_args[] = ( ! empty( $support_email_address ) ) ?
    ( "<b>{$support_email_address}</b>" ) :
    $this->get_text_x_inline(
        "the product's support email address",
        'Part of the message that tells the user to check their spam folder for a specific email.',
        'product-support-email-address-phrase'
    );
-------------------------- CUTTED HERE --------------------------

The $email and $support_email_address variables will be constructed to $formatted_message_args variable without proper escaping or sanitization. Same as the first case, the $formatted_message_args will be displayed as sticky admin notices:

$this->_admin_notices->add_sticky(
    vsprintf( $formatted_message, $formatted_message_args ),
    'activation_pending',
    $notice_title
);

We also discovered other occurrences of the fs_request_get function in other areas of the library code that could also potentially trigger XSS. Keep in mind that the functions dynamic_init and _install_with_new_user could be triggered from any of the WP Admin endpoints thus making this a site-wide reflected XSS.

The patch of the Site-Wide Reflected XSS in Freemius WordPress SDK

This vulnerability exists because the code directly constructs an HTML value directly from the fs_request_get function, sanitizing user input using sanitize_text_field directly on the fs_request_get should be enough to fix the issue. The patch can be seen below:

Conclusion

For plugin or theme developer, most of the time, they will use a custom function that acts as a wrapper function to get any form of request input from the user.

We recommend directly applying object escaping and sanitization on the wrapper function, so it could really minimize the potential of a vulnerability from existing form input validation.

Please also make sure to check the processed data from the wrapper function that will be used across the plugin or theme code. Depending on the context of the data, we recommend using sanitize_text_field to sanitize value for HTML output (outside of HTML attribute) or esc_html. For escaping values inside of attributes, you can use the esc_attr function.

Coordinated issue management

Sometimes it's a massive challenge for us to contact the vendor and get an adequate response once we report vulnerabilities. Freemius was a bright exception. Once we reported the vulnerability, the whole process started smoothly, and we coordinated all necessary steps to manage patching and disclosure.

Everything was done with great attention to detail and keeping up with the planned timeline, as we wanted to disclose vulnerability on the safest weekday to make disclosures - Tuesday, but simultaneously to avoid any movement right before or on July 4th.

The biggest challenge was coordinating the patching process across hundreds of independent developers. Freemius team executed this task surgically, which led to getting patched versions available for hundreds of affected plugins and themes.

Disclosure Note

We have discovered that this vulnerability is possibly affecting thousands of plugins and themes. The moment this article was published, we detected 1k+ plugins and 50+ themes are affected by this vulnerability and this number will increase while we continue to detect affected components. This number primarily contains plugins and themes that are available through wordpress.org.

If you or your company happen to utilize the Freemius WordPress SDK library, please make sure to update to at least the patched version 2.5.10. We will disclose vulnerability entries for all of the affected plugins and themes as more information becomes available.

Timeline

19 June, 2023We found the vulnerability and reached out to the plugin vendor via email.
22 June, 2023Freemius team sent the final proposed patched code and we help verified.
05 July, 2023Coordinated patched version update by Freemius for the listed plugins and themes
18 July, 2023Public disclosure.

Help us make the Internet a safer place

Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.

  • If you're a plugin developer, join our mVDP program that makes it easier to report, manage, and address vulnerabilities in your software.
  • If you're a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.

The latest in Security Advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu