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.
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.
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:
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 );
}
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": "[email protected]",
"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 );
}
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.
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
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.
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();
}
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.
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.