Compromise of 2FA secrets and backup codes possible in combination with SQLi – (SiteGround Security <= 1.3.0)

Affected pluginSiteGround Security
Active installs600,000+
Vulnerable version<= 1.3.0
Audited version1.3.0
Fully patched version1.3.6 (Not verified by Snicco)
Recommended remediationUpgrade to version 1.3.6 or higher.


The plugin stores users’ TOTP secret keys and emergency backup codes as plain text in the database. An attacker that is able to 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

Once a user enables two-factor authentication for his account the plugin will call the Sg_2fa::enable_for_user method.

This method will generate a TOTP secret for the user and store it as plaintext in the “wp_usermeta” table.

  public function generate_user_secret( $user_id ) {
    // Check if the user has secret code.
    $secret = get_user_meta( $user_id, 'sg_security_2fa_secret', true ); // phpcs:ignore

    // Bail if the user already has a secret code.
    if ( ! empty( $secret ) ) {
      return $user_id;

    // Add the user secret meta.
    return update_user_meta( // phpcs:ignore
      $this->google_authenticator->createSecret() // Generate the secret code.

Furthermore, the plugin generates emergency backup codes which are also stored as plain text in the “wp_usermeta” table.

  public function generate_user_backup_codes( $user_id ) {
    // Check if the user has backup codes.
    $backup_codes = get_user_meta( $user_id, 'sg_security_2fa_backup_codes', true ); // phpcs:ignore

    // Bail if the user already has a backup codes.
    if ( ! empty( $backup_codes ) ) {
      return $user_id;

    // Add the user backup_codes meta.
    return update_user_meta( // phpcs:ignore
      $this->recovery->numeric()->setCount( 8 )->setBlocks( 1 )->setChars( 8 )->toArray() // Generate the backup codes.

An attacker can abuse a read-only SQLi to compromise all two-factor checks for all users by getting the following SQL query to execute:

SELECT user_id,meta_value, meta_key FROM wp_usermeta WHERE meta_key = 'sg_security_2fa_backup_codes' OR meta_key = 'sg_security_2fa_secret';

Proposed patch

Storing a user’s TOTP secrets encrypted in the database:

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 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, the plugin must not store encryption keys 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;


The plugin must encrypt a user’s TOTP secret before saving it to the database. On validation, the plugin must then first decrypt the TOTP secret.

Storing backup codes hashed:

Emergency backup codes are password equivalent. They must be stored hashed in the database. There is no need to encrypt backup codes as it should not be possible to display them again after a user has saved them to a secure location.

Then, when a user attempts to log in, the user-provided backup code is hashed and compared against hashed backup codes in the database to validate them.

function validateBackupCodes(int $user_id, string $provided_backup_code) :bool {
 $hashed_backup_codes = /* GET HASHED BACKUP CODES FROM DATABASE */;
 foreach ($hashed_backup_codes as $index => $code) {
  if(hash_equals($code, wp_hash_password($provided_backup_code))){
   // Save updated backup codes.
   return true;
 return false;


Vendor contactedSeptember 07, 2022
First ResponseSeptember 12, 2022
Fully patched atNovember 08, 2022 (Not verified by Snicco)
Publicly disclosedApril 24, 2023


  • As per our recommendation, the plugin stores backup codes hashed since version 1.3.2. TOTP secrets are stilled stored as plaintext.

Leave a Reply

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