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.
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:
- https://github.com/wp-statistics/wp-statistics/commit/4e7d9157d8b2211900a7fac96136d6d772e9aff9
- https://github.com/wp-statistics/wp-statistics/commit/b6d312b3be04a7239147d71367c13dfcd4813338
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:
- https://github.com/wp-statistics/wp-statistics/commit/75e68878acfd591606218e1d45fe4a204ecbcd1b
- https://github.com/wp-statistics/wp-statistics/commit/36ebdc68d305e9d96afbedef6c70c413658c361d
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.