Broken authentication leads to total site takeover in combination with read-only SQLi – (WP 2FA <= 2.2.1)

| in


Affected pluginWP 2FA
Active installs30.000+
Vulnerable version<= 2.2.1
Audited version2.0.0
Fully patched version2.3.0
Recommended remediationUpgrade to version 2.3.0 or higher

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 only precondition is that any plugin, theme, or WordPress core has one of the endless read-only SQL-injection vulnerabilities.

Proof of concept

This vulnerability is possible through a combination of several security neglects:

  • Insufficient cryptography
  • Blindly trusting user input

Foremost, the plugin failed to follow rule number one of cryptography:

Don’t roll your own crypto.

Instead of relying on a battle-tested cryptography library, the plugin uses a homegrown encryption class based on OpenSSL.

The relevant code is in the Open_SSL::encrypt method.

public static function encrypt( string $text ): string {
    Debugging::log( 'Encrypting a text: ' . $text );
    if ( self::is_ssl_available() ) {
        $iv   = self::secure_random( self::BLOCK_BYTE_SIZE );
        $key  = \openssl_digest( \base64_decode( WP2FA::get_secret_key() ), self::DIGEST_ALGORITHM, true ); //phpcs:ignore
        $text = \openssl_encrypt(
            $text,
            self::CIPHER_METHOD,
            $key,
            OPENSSL_RAW_DATA,
            $iv
        );
        
        $text = \base64_encode( $iv . $text ); //phpcs:ignore
    }
    Debugging::log( 'Encrypted text: ' . $text );
    
    return $text;
}

Several things come to notice:

The class derives the encryption key for openssl_encrypt from here:

\base64_decode( WP2FA::get_secret_key() )

Which calls:

// EDITOR: This will ultimately call get_option()
Settings_Utils::get_option( 'secret_key' );

The encryption secret is stored as plaintext in the “wp_options” table
with a key of “wp_2fa_secret_key“.

The plugin stores the encryption key and the encrypted 2FA secrets in the same database. Consequently, an attacker accessing the encrypted secrets can decrypt them locally using the available encryption key.

Storing encryption keys next to ciphertexts offers no protection. So the plugin might as well store secrets in plaintext.

Understanding what happens once a user tries to log in with his username and password is essential.
If the provided credentials are valid, the plugin intercepts the request and redirects the user to the 2FA form.

Before that happens, the plugin generates a “login nonce,” which will later be used to assert the authenticity of the incoming 2FA validation request.

This nonce has 32 bytes of entropy, so brute-forcing it is impossible. However, the plugin stores it as plaintext in the database.

public static function create_login_nonce($user_id)
{
  $login_nonce = [];
  try {
    $login_nonce['key'] = bin2hex(random_bytes(32));
  } catch (\Exception $ex) {
    $login_nonce['key'] = wp_hash($user_id.mt_rand().microtime(), 'nonce'); //phpcs:ignore
  }
  $login_nonce['expiration'] = time() + HOUR_IN_SECONDS;
  
  if ( ! update_user_meta($user_id, self::USER_META_NONCE_KEY, $login_nonce)) {
    return false;
  }
  
  return $login_nonce;
}

The generated nonce and the user’s ID are then included as hidden input fields in the 2FA form.

<input type="hidden" name="wp-auth-id"    id="wp-auth-id"    value="<?php echo esc_attr( $user->ID ); ?>" />
<input type="hidden" name="wp-auth-nonce" id="wp-auth-nonce" value="<?php echo esc_attr( $login_nonce ); ?>" />

An attacker can use his read-only SQL injection to continuously poll the database for inserts with “wp_2fa_nonce” key in the “wp_usermeta” table.

Once a legitimate user initiates his login flow, there will be a 10 to 15 seconds window in which the user enters his generated TOTP from his authentication app.

This window is sufficient to pull off the attack explained below in an automated fashion.

1. Grap the user’s generated nonce, TOTP secret, and encryption key:

SELECT meta_value from wp_usermeta where meta_key = 'wp_2fa_nonce' OR meta_key = 'wp_2fa_totp_key'

SELECT option_value from wp_options where option_name = 'wp_2fa_secret_key'

2. Decrypt the user’s TOTP secret key using the stolen encryption key

3. Use any programming language to generate a valid TOTP using the decrypted secret.

3. Submit the following POST request

curl -X POST https://target.com/wp-login.php?action=validate_2fa -d "wp-auth-id=TARGET_USER_ID" -d "wp-auth-nonce=STOLEN_NONCE" -d "authcode=VALID_TOTP"

The plugin will respond with a valid WordPress auth cookie which can be used to impersonate the target user fully.

The Login::login_form_validate_2fa method allows this to happen because it will blindly trust the client to supply an honest value for “wp-auth-id,” which the plugin then uses to fetch the user that should be logged in.

public static function login_form_validate_2fa()
{
  if ( ! isset($_POST['wp-auth-id'], $_POST['wp-auth-nonce'])) { // phpcs:ignore
    return;
  }
  
  $auth_id = (int)$_POST['wp-auth-id']; // phpcs:ignore
  $user = get_userdata($auth_id);
  if ( ! $user) {
    return;
  }

  // EDITOR: MORE CODE HERE. 
}

Proposed patch


Two patches are needed to fix this vulnerability.

Implementing a secure way to persist which user logged in.

A sample implementation could look like this. Note that the generated login challenge is hashed using a MAC before saving it in the database. Thus not even an attacker with a WRITE SQL-Injection is able to forge 2FA challenges (assuming he can’t escalate to the filesystem).
Note that we are also including the user’s ID in the challenge so that an attacker can’t utilize the challenge token of one user to authenticate as a different one.

// DON'T COPY PAST THIS CODE, IT'S NOT PRODUCTION READY

function challengeTokenForHTMLForm(int $user_that_just_logged_in) :string {
    $random_token = bin2hex(random_bytes(32));
    
    $salt = wp_salt('nonce');
    
    $store_me = hash_hmac('sha256', $user_that_just_logged_in . $random_token, $salt);
    
    /* STORE HERE IN USER META OR THROW EXCEPTION ON FAILURE */
    
    return $random_token;
}

function getUserThatTriedToLogin() :int {
    
    // These can be retrieved from hidden form fields.
    $user_id = $_POST['challenged-user'];
    $token_plaintext = $_POST['challenge-token'];
    
    $salt = wp_salt('nonce');
    
    $provided_signature = hash_hmac('sha256', $user_id . $token_plaintext, $salt);
    
    $hashed_validator = get_user_meta($user_id, 'PLUGIN_META_KEY', true);
    
    if (! hash_equals($hashed_validator, $provided_signature)){
        throw new Exception('Attack!');
    }
    
    return $user_id;
}

Secure encryption with safe storage of encryption keys.

Use a battle-tested encryption library like

to generate unique encryption keys per plugin installation. Alternatively, libsodium (ext-sodium) can be used to implement secret-key cryptography.
WordPress Core includes a pure PHP polyfill sodium-compat for PHP < 7.2 so that compatibility is not an issue here.

NOTE: the “wp_salt()” method MUST not be used as an encryption key.

This would mean that a user of the plugin will never be able to rotate his salts again, which should be strictly avoided.

Furthermore, encryption keys must NOT be stored in the same place as the encrypted ciphertexts.

In WordPress the options are, from most to least preferred:

  • Reading the encryption key from a file whose name is passed as an environment variable. (plays great with docker secrets)
  • Reading the encryption key from an environment variable
  • Reading the encryption key from a constant defined in the wp-config.php file

A sample implementation could look like this:

final class Secrets {

  public static function get(string $secret_name) :string
  {
    $contents = @file_get_contents($secret_name);
    if(is_string($contents)){
      return $contents;
    }
    if(isset($_SERVER[$secret_name])) {
      return $_SERVER[$secret_name];
    }
    $value = @constant($secret_name);

	if(!$value) {
		throw BrokenEnvironmentException::forMissingSecret($secret_name);
   }
    return $value;
  }

}
}

Timeline


Vendor contactedMay 30, 2022 (through WPScan)
First Response
Fully patched atSeptember 01, 2022
Publicly disclosedApril 24, 2023

Miscellaneous


Leave a Reply

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