Multiple Vulnerabilities Fixed In WP Statistics Plugin Version <= 13.2.10

Published 2 February 2023
Updated 24 July 2023
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

If you're a WP Statistics plugin user, please update the plugin to at least version 13.2.11.

Patchstack paid plan users are protected from the vulnerability. You can also sign up for the Patchstack Community plan to be notified about vulnerabilities as soon as they become disclosed.

For plugin developers, we have security audit services and Threat Intelligence Feed API for hosting companies.

Introduction

The plugin WP Statistics (versions 13.2.10 and below), which has over 600.000 active installations is a Privacy-focused analytics plugin for WordPress.

This is one of the most popular WordPress Statistic Plugins which could be used for understanding the traffic and user data of a website. It provides detailed information about the browser, search engine, and most popular content (categorized by tags, categories, and authors) of the website’s visitors.

WP Statistics Plugin

This plugin suffers from multiple authenticated SQL injection vulnerabilities. These vulnerabilities allow any authenticated user with at least the Subscriber user role to perform SQL injection.

The described vulnerabilities were fully fixed in version 13.2.11 and assigned CVE-2022-38074. You can view the vulnerability entry on our database:

Find out more from the Patchstack database.

The security vulnerability in WP Statistics

Initial Discovery

One of the first things to search in this WordPress plugin is the provided API feature. This feature sometimes could be looked over from the researcher's standpoint because the feature is sometimes not noticeable from the front end. The initial gateway for the SQL injection is in the Meta Box API feature. To clarify what this feature is, WordPress Meta Boxes are draggable boxes that show on your editing screen and are used to handle additional data such as Taxonomy terms. WordPress plugins could register their own API route and handler using the provided register_routes function. Let's view that function in wp-statistics/includes/api/v2/class-wp-statistics-api-meta-box.php :

public function register_routes()
{

    // Get Admin Meta Box
    register_rest_route(self::$namespace, '/metabox', array(
        array(
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => array($this, 'meta_box_callback'),
            'args'                => array(
                'name' => array(
                    'required' => true
                )
            ),
            'permission_callback' => function (\WP_REST_Request $request) {
                return true;
            }
        )
    ));
}

The permission handler of the API is handled inside the permission_callback parameter. The callback function indicates that this specific API could be accessed without authentication because the callback function immediately return true without any validation.

Let's analyze the main function handler of the API that located on the callback parameter that point to the meta_box_callback function :

public function meta_box_callback(\WP_REST_Request $request)
{
    // Check User Auth
    $user = wp_get_current_user();
    if ($user->ID == 0) {
        return new \WP_REST_Response(array('code' => 'user_auth', 'message' => __('You do not have enough access privileges for checking out information. Please check the accessibility of the information display in the settings section of WP-Statistics.', 'wp-statistics')), 400);
    }

    // Check Exist MetaBox Name
    if (in_array($request->get_param('name'), array_keys(\WP_STATISTICS\Meta_Box::getList())) and \WP_STATISTICS\Meta_Box::IsExistMetaBoxClass($request->get_param('name'))) {
        $class = \WP_STATISTICS\Meta_Box::getMetaBoxClass($request->get_param('name'));
        return $class::get($request->get_params());
    }

    // Not Define MetaBox
    return new \WP_REST_Response(array('code' => 'not_found_meta_box', 'message' => __('The name of MetaBox is invalid on request.', 'wp-statistics')), 400);
}

The function will first check if the $user->ID value equals 0. This indicates that this API could only be accessible from any authenticated user on the WordPress site since id 0 indicates that WordPress could not validate the wp_get_current_user() (because the user is not authenticated.)

The API handler then will invoke the class $class::get() with getMetaBoxClass function and supply it with the request parameters. Available class files can be seen under wp-statistics/includes/admin/meta-box/wp-statistics-meta-box-browsers.php directory.

Vulnerability Details

Below is the vulnerable code that handles each class name:

1. Class pages

Initial class handler :

class pages
{
    /**
     * Get MetaBox Rest API Data
     *
     * @param array $args
     * @return array
     */
    public static function get($args = array())
    {

        // Get List Top Page
        $response = \WP_STATISTICS\Pages::getTop($args);

        // Check For No Data Meta Box
        if (count($response) < 1) {
            $response['no_data'] = 1;
        }

        // Response
        return $response;
    }

}

The vulnerable code exists in \WP_STATISTICS\Pages::getTop function:

    public static function getTop($args = array())
    {
        global $wpdb;

        // Define the array of defaults
        $defaults = array(
            'per_page' => 10,
            'paged'    => 1,
            'from'     => '',
            'to'       => ''
        );

        $args = wp_parse_args($args, $defaults);

        // Date Time SQL
        $DateTimeSql = "";
        if (!empty($args['from']) and !empty($args['to'])) {
            $DateTimeSql = "WHERE (`pages`.`date` BETWEEN '{$args['from']}' AND '{$args['to']}')";
        }

        // Generate SQL
        $sql = "SELECT `pages`.`date`,`pages`.`uri`,`pages`.`id`,`pages`.`type`, SUM(`pages`.`count`) + IFNULL(`historical`.`value`, 0) AS `count_sum` FROM `" . DB::table('pages') . "` `pages` LEFT JOIN `" . DB::table('historical') . "` `historical` ON `pages`.`uri`=`historical`.`uri` AND `historical`.`category`='uri' {$DateTimeSql} GROUP BY `uri` ORDER BY `count_sum` DESC";

        // Get List Of Pages
        $list   = array();
        $result = $wpdb->get_results($sql . " LIMIT " . ($args['paged'] - 1) * $args['per_page'] . "," . $args['per_page']);

2. Class browsers

Initial class handler and vulnerable code:


class browsers
{
    /**
     * Get Browser ar Chart
     *
     * @param array $arg
     * @return array
     * @throws \Exception
     */    
    public static function get($arg = array())
    {
        global $wpdb;

        // Set Default Params
        $defaults = array(
            'ago'     => 0,
            'from'    => '',
            'to'      => '',
            'browser' => 'all',
            'number'  => 10
        );
        $args     = wp_parse_args($arg, $defaults);
---------------------------- CUTTED HERE ---------------------------------------
        } else {

            // Set Browser info
            $lists_keys[] = strtolower($args['browser']);
            $lists_logo[] = UserAgent::getBrowserLogo($args['browser']);

            // Get List Of Version From Custom Browser
            $list = $wpdb->get_results("SELECT version, COUNT(*) as count FROM " . DB::table('visitor') . " WHERE agent = '" . $args['browser'] . "' AND `last_counter` BETWEEN '" . reset($days_time_list) . "' AND '" . end($days_time_list) . "' GROUP BY version", ARRAY_A);
---------------------------- CUTTED HERE ---------------------------------------

3. Class countries

Initial class handler:

class countries
{

    public static function get($args = array())
    {

        // Check Number of Country
        if (!isset($args['limit'])) {
            $args['limit'] = 10;
        }

        // Get List Top Country
        $response = Country::getTop($args);

Vulnerable code is on Country::getTop :

    public static function getTop($args = array())
    {
        global $wpdb;

        // Load List Country Code
        $ISOCountryCode = Country::getList();

        // Get List From DB
        $list = array();

        // Check Custom Date
        $where = '';
        if (isset($args['from']) and isset($args['to'])) {
            $where = "WHERE `last_counter` BETWEEN '" . $args['from'] . "' AND '" . $args['to'] . "'";
        }

4. Class devices

Initial class handler and vulnerable code:

class devices
{
    /**
     * Get Devices Chart
     *
     * @param array $arg
     * @return array
     * @throws \Exception
     */
    public static function get($arg = array())
    {
        global $wpdb;

        // Set Default Params
        $defaults = array(
            'ago'    => 0,
            'from'   => '',
            'to'     => '',
            'order'  => '',
            'number' => 10 // Get Max number of platform
        );
        $args     = wp_parse_args($arg, $defaults);
---------------------------- CUTTED HERE ---------------------------------------
        $list = $wpdb->get_results("SELECT device, COUNT(*) as count FROM " . DB::table('visitor') . " WHERE device != '" . _x('Unknown', 'Device', 'wp-statistics') . "' AND `last_counter` BETWEEN '" . reset($days_time_list) . "' AND '" . end($days_time_list) . "' GROUP BY device " . ($args['order'] != "" ? 'ORDER BY `count` ' . $args['order'] : ''), ARRAY_A);

5. Class models

Initial class handler and vulnerable code:

class models
{
    /**
     * Get Manufacturers Chart
     *
     * @param array $arg
     * @return array
     * @throws \Exception
     */
    public static function get($arg = array())
    {
        global $wpdb;

        // Set Default Params
        $defaults = array(
            'ago'    => 0,
            'from'   => '',
            'to'     => '',
            'order'  => '',
            'number' => 10 // Get Max number of platform
        );
        $args     = wp_parse_args($arg, $defaults);
---------------------------- CUTTED HERE ---------------------------------------
        $list = $wpdb->get_results("SELECT model, COUNT(*) as count FROM " . DB::table('visitor') . " WHERE model != '" . _x('Unknown', 'Model', 'wp-statistics') . "' AND `last_counter` BETWEEN '" . reset($days_time_list) . "' AND '" . end($days_time_list) . "' GROUP BY model " . ($args['order'] != "" ? 'ORDER BY `count` ' . $args['order'] : ''), ARRAY_A);

6. Class platforms

Initial class handler and vulnerable code:

class platforms
{
    /**
     * Get Platforms Chart
     *
     * @param array $arg
     * @return array
     * @throws \Exception
     */
    public static function get($arg = array())
    {
        global $wpdb;

        // Set Default Params
        $defaults = array(
            'ago'    => 0,
            'from'   => '',
            'to'     => '',
            'order'  => '',
            'number' => 10 // Get Max number of platform
        );
        $args     = wp_parse_args($arg, $defaults);
---------------------------- CUTTED HERE ---------------------------------------
        $list = $wpdb->get_results("SELECT platform, COUNT(*) as count FROM " . DB::table('visitor') . " WHERE platform != '" . _x('Unknown', 'Platform', 'wp-statistics') . "' AND `last_counter` BETWEEN '" . reset($days_time_list) . "' AND '" . end($days_time_list) . "' GROUP BY platform " . ($args['order'] != "" ? 'ORDER BY `count` ' . $args['order'] : ''), ARRAY_A);

7. Class recent

Initial class handler:

class recent
{

    public static function get($args = array())
    {

        // Prepare Response
        try {
            $response = Visitor::get($args);

Vulnerable code:

    public static function get($arg = array())
    {
        global $wpdb;

        // Define the array of defaults
        $defaults = array(
            'sql'      => '',
            'per_page' => 10,
            'paged'    => 1,
            'fields'   => 'all',
            'order'    => 'DESC',
            'orderby'  => 'ID'
        );
        $args     = wp_parse_args($arg, $defaults);

        // Prepare Query
        if (empty($args['sql'])) {
            $args['sql'] = "SELECT * FROM `" . DB::table('visitor') . "` ORDER BY ID DESC";
        }

        // Set Pagination
        $args['sql'] = $args['sql'] . " LIMIT " . (($args['paged'] - 1) * $args['per_page']) . ", {$args['per_page']}";

        // Send Request
        $result = $wpdb->get_results($args['sql']);

8. Class referring

Initial class handler:

class referring
{

    public static function get($args = array())
    {

        // Check Number of Country
        $number = (isset($args['number']) ? $args['number'] : 10);

        // Get List Top Referring
        try {
            $response = Referred::getTop($number);

Vulnerable code is in Referred::getTop:

    public static function getTop($number = 10)
    {
        global $wpdb;

        //Get Top Referring
        if (false === ($get_urls = get_transient(self::$top_referring_transient))) {

            $result = $wpdb->get_results(self::GenerateReferSQL("ORDER BY `number` DESC LIMIT $number", ''));

9. Class useronline

Initial class handler:

class useronline
{

    public static function get($args = array())
    {

        // Prepare Response
        try {
            $response = \WP_STATISTICS\UserOnline::get($args);

Vulnerable code is in \WP_STATISTICS\UserOnline::get:

        // Prepare SQL
        $SQL = "SELECT";

        // Check Fields
        if ($args['fields'] == "count") {
            $SQL .= " COUNT(*)";
        } elseif ($args['fields'] == "all") {
            $SQL .= " *";
        } else {
            $SQL .= $args['fields'];
        }
        $SQL .= " FROM `" . DB::table('useronline') . "`";

        // Check Count
        if ($args['fields'] == "count") {
            return $wpdb->get_var($SQL);
        }

        // Prepare Query
        if (empty($args['sql'])) {
            $args['sql'] = "SELECT * FROM `" . DB::table('useronline') . "` ORDER BY ID DESC";
        }

        // Set Pagination
        $args['sql'] = $args['sql'] . " LIMIT {$args['offset']}, {$args['per_page']}";

        // Send Request
        $result = $wpdb->get_results($args['sql']);

The patch in the WP Statistics plugin

The initial vulnerability for this case would be a Subscriber+ Broken Access Control on the meta box API endpoint and SQL Injection on the API handler. The vendor released version 13.2.6 which tried to patch all of these issues. You can look at the full patch diff of the code on the links below:

The patched version 13.2.6 implemented access control to the meta box API to only high-privilege users and fixed almost all of the reported cases of SQL Injection.

But there is still one case that has yet to be fixed: class recent that is handled by code in the file wp-statistics/includes/class-wp-statistics-visitor.php.

The vendor then released version 13.2.11 that fixed the case above along with an additional SQL Injection case in the file includes/class-wp-statistics-country.php. You can look at the full patch diff of the code on the links below:

Disclosure timeline of the WP Statistics plugin vulnerability

14-06-2022 - The researcher found the authenticated SQL Injection vulnerability and reached out to the plugin vendor.
07-09-2022 - WP Statistics plugin version 13.2.6 was published to patch almost all of the reported issues.
01-01-2023 - WP Statistics plugin version 13.2.11 was published to fully patch the reported issues.
31-01-2023 - Added the vulnerabilities to the Patchstack vulnerability database.
02-02-2023 - Published the article.

Help us make the web a safer place

Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.

  • If you're a plugin developer, join our mVDP program that makes it easier to report, manage and address vulnerabilities in your software.
  • If you're a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.

The latest in Security Advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu