Affected plugin | WP 2FA |
Active installs | 30.000+ |
Vulnerable version | <= 2.2.1 |
Audited version | 2.0.0 |
Fully patched version | 2.3.0 |
Recommended remediation | Upgrade 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 encryption is missing the message authentication part.
- There seems to be some logging of plaintexts going on.
- If the OpenSSL extension is not present, encryption is skipped silently, instead of failing with a warning that the environment is not suitable for encryption.
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 contacted | May 30, 2022 (through WPScan) |
First Response | – |
Fully patched at | September 01, 2022 |
Publicly disclosed | April 24, 2023 |
Miscellaneous
- WPScan and the vendor considered implementing proper encryption as a security enhancement instead of a critical security issue.
- The vendor has been aware of this for at least 6 months.
- The vendor fixed this issue in version 2.3.0 using the wp_salt() function which means nobody will ever be able to rotate their wp-config salts again.
- It seems that this vulnerability was introduced because the vendor copied parts of the 2FA code from the plugin contributors’ Two-Factor plugin, which contained the exact same issue.
Leave a Reply