Broken encryption allows 2FA bypass – Two Factor Authentication (Updraft) <= 1.14.5

Affected pluginTwo Factor Authentication
Active installs20.000+
Vulnerable version<= 1.14.5
Audited version1.14.3
Fully patched version1.14.15
Recommended remediationUpgrade to version 1.14.15 or higher of the plugin and enable 2FA secret encryption in the plugin settings.


The Two Factor Authentication plugin by Updraft employs a broken encryption scheme that allows an attacker to permanently bypass all 2FA checks under the condition that the target website was vulnerable at any point in time to one of the never-ending read-only SQL-Injections in any plugin, theme, or WordPress core.

Proof of concept

Among other methods, the plugin provides two-factor authentication through time-based-one-time-passwords (TOTP).

The entire security of this implementation relies on the fact that an attacker can not gain access to the secret key of a user. Therefore, anybody with access to a TOTP secret key can generate valid six-digit passwords for any given time window.

The plugin stores TOTP secret keys encrypted in the “wp_user_meta” table.
However, the plugin does so using an utterly broken encryption scheme.

The class Simba_TFA_Provider_TOTP contains the “encryptString” method, which encrypts TOTP secrets and emergency backup codes before saving them to the database.

  public function encryptString($string, $salt_suffix)
    $key = $this->hashAndBin($this->pw_prefix.$salt_suffix, $this->salt_prefix.$salt_suffix);
    $iv_size = $this->get_iv_size();
    $iv = $GLOBALS['simba_two_factor_authentication']->random_bytes($iv_size);
    // EDITOR: This will call openssl_encrypt
    $enc = $this->encrypt($key, $string, $iv);
    if (false === $enc) {
      return false;
    $enc = $iv.$enc;
    $enc_b64 = base64_encode($enc);
    return $enc_b64;

It’s immediately apparent that this is yet another broken, self-rolled crypto class since the message authentication part is missing.

However, much more significant problems surface once we inspect the content of the “hashAndBin” method, which provides the encryption key.

private function hashAndBin($pw, $salt)
    $key = $this->hash($pw, $salt);
    $key = pack('H*', $key);
    // Yes: it's a null encryption key. See:
    // Basically: the original plugin had a bug here, which caused a null encryption key. This fails on PHP 5.6+. But, fixing it would break backwards compatibility for existing installs - and note that the only unknown once you have access to the encrypted data is the AUTH_SALT and AUTH_KEY constants... which means that actually the intended encryption was non-portable, + problematic if you lose your wp-config.php or try to migrate data to another site, or changes these values. (Normally changing these values only causes a compulsory re-log-in - but with the intended encryption in the original author's plugin, it'd actually cause a permanent lock-out until you disabled his plugin). If someone has read-access to the database, then it'd be reasonable to assume they have read-access to wp-config.php too: or at least, the number of attackers who can do one and not the other would be small. The "encryption's" not worth it.
    // In summary: this isn't encryption, and is not intended to be.
    return str_repeat(chr(0), 16);

Instead of generating a new key for each installation upon activating the plugin, a hardcoded encryption key is used (16 NULL-bytes).

The TOTP secret keys are thus effectively stored as plaintext in the database as anybody can decrypt them since the encryption key is public domain, making this a perfect example of security theater.

Thus, having any of the never-ending WordPress SQL-Injection vulnerabilities on the target site only ONCE will render the plugin useless until all users have reset their 2FA configuration.

The following SQL is sufficient to compromise all secret keys of all users:

SELECT meta_value from wp_usermeta where meta_key = "tfa_priv_key_64"

In the same way, an attacker can compromise all emergency codes by executing the following query:

SELECT meta_value from wp_usermeta where meta_key = "simba_tfa_emergency_codes_64"

Proposed patch

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.

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);
      return $contents;
    if(isset($_SERVER[$secret_name])) {
      return $_SERVER[$secret_name];
    $value = @constant($secret_name);

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



Vendor contactedMay 30, 2022 (through WPScan)
First ResponseJune 28, 2022 (Vendor contacted by WPScan)
Fully patched atMay 05, 2023
Publicly disclosedApril 24, 2023


Leave a Reply

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