Security Researcher Cyku Hong from DEVCORE found a vulnerability in WP Statistics, a WordPress plugin installed on over 600,000 sites. This vulnerability made it possible for unauthenticated attackers to execute arbitrary SQL queries by appending them to an existing SQL query. This could be used to extract sensitive information like password hashes and secret keys from the database.

WP Statistics is a WordPress plugin designed to provide a centralized hub for all of a WordPress site’s statistics, such as visitor data, and it emphasizes storing this data locally to the WordPress site to preserve user privacy. As such, it is reasonable to expect that the plugin would implement a lot of functionality to store and retrieve information from the database through the use of SQL queries. Unfortunately, the implementation of one of these queries was insecure, creating a SQL Injection vulnerability.

When the “Record Exclusions” feature was enabled, this vulnerability became exploitable. The “Record Exclusions” feature is designed to record when a visit, or a “hit”, is excluded from the site’s statistics, such as visits by users with specific roles, login page access, and anything else that a site owner may have explicitly selected to exclude. It records that data to a separate database table so as not to contaminate the main statistical data the plugin collects.

In order to record these hits when a caching plugin was enabled, the plugin registered a REST route /wp-json/wp-statistics/v2/hit that would call the hit_callback() function. This function would then call the record() function from the ‘Hits’ class which checks to see if the request should be excluded and determines what exclusion the request correlates to, prior to calling the next appropriate record() function.

public static function record()
{
 
    # Check Exclusion This Hits
    $exclusion = Exclusion::check();
 
    # Record Hits Exclusion
    if ($exclusion['exclusion_match'] === true) {
        Exclusion::record($exclusion);
    }
 
    # Record User Visits
    if (Visit::active() and $exclusion['exclusion_match'] === false) {
        Visit::record();
    }
 
    # Record Visitor Detail
    if (Visitor::active()) {
        $visitor_id = Visitor::record($exclusion);
    }

When the exclusion_match parameter is set to true in a request, the data is then passed to the record() function from the ‘Exclusion’ class where the plugin attempts to update the count of an exclusion reason for the day if it is present in the database. If the exclusion reason isn’t present in the database for the current date the initial query will return false and trigger the next query to add a new record count to the table for the reason.

READ
FTC Warns of Soaring "Task Scams" Preying on Online Job Seekers
Buy Me a Coffee
public static function record($exclusion = array())
{
    global $wpdb;
 
    // If we're not storing exclusions, just return.
    if (self::record_active() != true) {
        return;
    }
 
    // Check Exist this Exclusion in this day
    $result = $wpdb->query("UPDATE " . DB::table('exclusions') . " SET `count` = `count` + 1 WHERE `date` = '" . TimeZone::getCurrentDate('Y-m-d') . "' AND `reason` = '{$exclusion['exclusion_reason']}'");
    if (!$result) {
        $insert = $wpdb->insert(
            DB::table('exclusions'),
            array(
                'date'   => TimeZone::getCurrentDate('Y-m-d'),
                'reason' => $exclusion['exclusion_reason'],
                'count'  => 1,
            )
        );
        if (!$insert) {
            if (!empty($wpdb->last_error)) {
                \WP_Statistics::log($wpdb->last_error);
            }
        }

The $wpdb->query() function was used for the initial UPDATE query and used the user-supplied ‘exclusion_reason‘ value as part of the query. Due to the fact that there was no escaping on the user supplied value, or parameterization on the query, attackers could easily append additional SQL queries to the existing query via the ‘exclusion_reason‘ and extract sensitive information from the database.

Since no data from the SQL query was returned in the response, and the response did not indicate a boolean answer, an attacker would need to use a Time-Based blind approach to extract information from the database. This means that they would need to use SQL CASE statements along with the SLEEP() command while observing the response time of each request to steal information from the database. This is an intricate, yet frequently successful method to obtain information from a database when exploiting SQL Injection vulnerabilities.

Upon further analysis, Wordfence Team uncovered that a user could also simply pass the exclusion_match parameter equal to yes, the exclusion_reason parameter set to the SQLi payload, and the wp_statistics_hit_rest parameter set to true, along with passing the string wp-json/ in the request URI to trigger the same record() function from the ‘Exclusions’ class. This method did not require a caching plugin to be enabled to obtain a valid nonce to trigger the REST endpoint. This is due to the is_rest_request() function returning true when the $_SERVER['REQUEST_URI'] contains the REST prefix, wp-json/, even if the request isn’t a genuine REST request. This ultimately triggers the entire record process.

READ
Operation PowerOFF: Global Crackdown Disrupts DDoS-for-Hire Platforms

We recommend that WordPress site owners immediately verify that their site has been updated to the latest patched version available, which is version 13.1.5 at the time of this publication.