Compromise of 2FA secrets and backup codes through read-only SQLi – (WordFence <= 7.6.2)

Affected pluginWordFence
Active installs4+ million
Vulnerable version<= 7.6.1
Audited version7.6.1
Fully patched version
Recommended remediationRemoval of the plugin

Description


The plugin stores users’ emergency backup codes and TOTP secrets as plaintext in the database.
An attacker that can obtain one of the seemingly never-ending read-only SQL-Injections will be able to bypass all 2FA checks for all users indefinitely.

Proof of concept


A look at the Controller_TOTP::validate_2fa method reveals that WordFence stores all TOTP secrets and emergency backup codes as plaintext (in binary form).

public function validate_2fa($user, $code, $update = true) {
    global $wpdb;
    $table = Controller_DB::shared()->secrets;
    $record = $wpdb->get_row($wpdb->prepare("SELECT * FROM `{$table}` WHERE `user_id` = %d FOR UPDATE", $user->ID), ARRAY_A);
    if (!$record) {
        return null;
    }
    
    if (preg_match('/^(?:[a-f0-9]{4}\s*){4}$/i', $code)) { //Recovery code
        $code = strtolower(preg_replace('/\s/i', '', $code));
        $recoveryCodes = str_split(strtolower(bin2hex($record['recovery'])), 16);
        
        $index = array_search($code, $recoveryCodes);
        if ($index !== false) {
            if ($update) {
                unset($recoveryCodes[$index]);
                $updatedRecoveryCodes = implode('', $recoveryCodes);
                $wpdb->query($wpdb->prepare("UPDATE `{$table}` SET `recovery` = X%s WHERE `id` = %d", $updatedRecoveryCodes, $record['id']));
            }
            $wpdb->query('COMMIT');
            return true;
        }
    }
    else if (preg_match('/^(?:[0-9]{3}\s*){2}$/i', $code)) { //TOTP code
        $code = preg_replace('/\s/i', '', $code);
        $secret = bin2hex($record['secret']);
        
        $matches = $this->check_code($secret, $code, floor($record['vtime'] / self::TIME_WINDOW_LENGTH));
        if ($matches !== false) {
            if ($update) {
                $wpdb->query($wpdb->prepare("UPDATE `{$table}` SET `vtime` = %d WHERE `id` = %d", $matches, $record['id']));
            }
            $wpdb->query('COMMIT');
            return true;
        }
    }
    
    $wpdb->query('ROLLBACK');
    return false;
}

The highlighted lines 4,11, and 26 prove that both values are stored as plaintext in the database. Otherwise, it would not be possible to compare them to user input without any conversion.

An attacker can use a read-only SQLi to bypass all 2FA checks for all users indefinitely.

SELECT user_id, HEX(recovery), HEX(secret) from wp_wfls_2fa_secrets

Timeline


Vendor contactedSeptember 08, 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 *