Total site takeover through broken 2FA authentication 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.2 (Not verified by Snicco)
Recommended remediationUpgrade to version 1.3.2 or higher


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.

This attack is possible if one of the following scenarios is true:

Proof of concept

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

After a user has provided a valid username + password combination, the plugin will intercept the user’s login request and redirect him to a 2FA form.

This happens in the Sg_2fa::init_2fa method.


// Remove the auth cookie.

$random_hash = bin2hex( random_bytes( 18 ) );

setcookie('sgs_2fa_login_nonce', $user->ID . '|' . $random_hash , time() + DAY_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );

update_user_meta( $user->ID, 'sgs_2fa_login_nonce', $random_hash );

// EDITOR: 2FA form is shown here.

First, the plugin generates a random string which it will later use to determine the user that should be logged in after a valid 2FA request.

$random_hash = bin2hex( random_bytes( 18 ) );

Despite the name “$random_hash“, this string is not a hashed at all.
It is stored as plaintext in the “wp_usermeta” table.

update_user_meta( $user->ID, 'sgs_2fa_login_nonce', $random_hash );

Furthermore, the plugin then stores the random challenge token and the user’s ID in an HTTP cookie.

setcookie('sgs_2fa_login_nonce', $user->ID . '|' . $random_hash , time() + DAY_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN );

By default, PHP’s “setcookie” function permits sending the cookie over HTTP and also makes it available to JavaScript, which is a separate vulnerability.

An attacker can use his read-only SQL injection to continuously poll the database for inserts with the “sgs_2fa_login_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 generated login nonce and the user’s plaintext backup codes (or his TOTP secret)

SELECT meta_value from wp_usermeta where meta_key = 'sgs_2fa_login_nonce' OR meta_key = 'sg_security_2fa_backup_codes'

2. Submit the following POST request with one of the stolen emergency codes and the stolen login nonce.

curl -X POST -d "sgc2fabackupcode=STOLEN_EMERGENCY_CODE" -b "sgs_2fa_login_nonce=TARGET_USER_ID|STOLEN_LOGIN_NONCE"

The plugin will now respond with valid WordPress auth-cookies that can be used to log in as the user with the ID {TARGET_USER_ID}.

The responsible method is Sg_2fa::validate_2fabc_login:

The first thing this method does is validate that the provided cookie is valid:

// Get the nonce cookie.
$cookie_data = $this->get_2fa_nonce_cookie();
// Bail if the cookie doens't exists.
if ( empty( $cookie_data ) ) {

The “get_2fa_nonce_cookie” method merely checks that the plaintext token in the cookie matches the plaintext token stored in the “wp_usermeta” table.

Next, the plugin will compare the supplied backup code against the ones that are stored in the “wp_usermeta_table

if ( isset( $_POST['sgc2fabackupcode'] ) ) {
 // Validate the backup code.
 $result = $this->validate_backup_login(
  wp_unslash( $_POST['sgc2fabackupcode'] ),
  wp_unslash( $cookie_data[0] )
 ); // phpcs:ignore

And finally, it will log in the user with the ID that is stored in the cookie:

$this->login_user( $cookie_data[0] );

Remember, that the cookie has the format {STOLEN_USER_ID}|{STOLEN_TOKEN} and is entirely attacker-controlled.

The same attack could also be pulled of by combining the theft of the HTTP cookies with a time-based-side-channel attack on the user’s backup code. However, pulling off this attack is more difficult, although not impossible.

Proposed patch

Three patches are needed to fix this vulnerability.

1. 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 can forge 2FA challenges (assuming he can’t escalate to the filesystem).
Furthermore, we include 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.


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);
    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;

2. Store 2FA secret keys encrypted.

3. Store emergency codes hashed.


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


  • Login nonces are stored hashed since version 1.3.1, but the attack is still possible by using stolen cookies to get the login nonce and using either a read-only SQLi or a time-based-side-channel attack to get a valid backup code.

Leave a Reply

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