600,000+ Install WordPress Plugin WP Statistics Isn’t Properly Securing Its Optimization Functionality
Yesterday the JVN released a vague report claiming that a cross-site scripting (XSS) vulnerability had been fixed in version 13.2.0 of the WordPress plugin WP Statistics. There isn’t enough information provided to confirm that there was a vulnerability or that it was fixed.
Confusingly, one of our competitors, Automattic’s WPScan, is citing that report as the source for a claim that a vulnerability was fixed in version 13.2.2 of the plugin:
More confusingly, they claim they haven’t verified that.
Another competitor, Patchstack, is making an almost identical claim to WPScan, while citing the JVN and not WPScan as the source:
Based on what we have seen of Patchstack, they are likely copying their information without credit from WPScan. Which runs against how they market their data:
Hand curated, verified and enriched vulnerability information by Patchstack security experts.
In looking over the changes made in version 13.2.0, we found numerous security changes were being made, which makes it difficult to narrow down what the JVN report might refer to, whether there was a vulnerability, and if it was properly fixed. In the process of looking into those changes, we found vulnerable code that still hasn’t been fixed.
Security Checks Missing
The plugin loads the file /includes/admin/pages/class-wp-statistics-admin-page-optimization.php if is_admin() is true:
166 167 | // Admin classes if (is_admin()) { |
183 | require_once WP_STATISTICS_DIR . 'includes/admin/pages/class-wp-statistics-admin-page-optimization.php'; |
It appears that the developer is using that function as intended, as they appear to be using to check if the admin area of WordPress is being accessed, as opposed to trying to check if someone is logged in to WordPress.
In that file, the functions save() and optimize_table() are registered to run when admin screen notices are shown:
12 | add_action('admin_notices', array($this, 'save')); |
20 | add_action('admin_notices', array($this, 'optimize_table')); |
That means they will run for anyone logged in to WordPress that has access to the admin area, which unless a plugin is restricting things, means that anyone logged in has access. Both of those allow taking various optimization actions for the plugin, which are only intended to be accessed by Administrators. Despite that, there isn’t any security check to make sure only those users have access to the function save():
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | public function save() { global $wpdb; // Check Hash IP Update if (isset($_GET['hash-ips']) and intval($_GET['hash-ips']) == 1) { IP::Update_HashIP_Visitor(); Helper::wp_admin_notice(__('IP Addresses replaced with hash values.', "wp-statistics"), "success"); } // Update All GEO IP Country if (isset($_GET['populate']) and intval($_GET['populate']) == 1) { $result = GeoIP::Update_GeoIP_Visitor(); Helper::wp_admin_notice($result['data'], ($result['status'] === false ? "error" : "success")); } // Re-install All DB Table if (isset($_GET['install']) and intval($_GET['install']) == 1) { Install::create_table(false); Helper::wp_admin_notice(__('Install routine complete.', "wp-statistics"), "success"); } // Update Historical Value if (isset($_POST['historical-submit'])) { $historical_table = DB::table('historical'); // Historical Visitors if (isset($_POST['wps_historical_visitors'])) { // Update DB $result = $wpdb->update($historical_table, array('value' => sanitize_text_field($_POST['wps_historical_visitors'])), array('category' => 'visitors')); if ($result == 0) { $result = $wpdb->insert($historical_table, array('value' => sanitize_text_field($_POST['wps_historical_visitors']), 'category' => 'visitors', 'page_id' => -1, 'uri' => '-1')); } } // Historical Visits if (isset($_POST['wps_historical_visits'])) { // Update DB $result = $wpdb->update($historical_table, array('value' => sanitize_text_field($_POST['wps_historical_visits'])), array('category' => 'visits')); if ($result == 0) { $result = $wpdb->insert($historical_table, array('value' => sanitize_text_field($_POST['wps_historical_visits']), 'category' => 'visits', 'page_id' => -2, 'uri' => '-2')); } } |
In the function optimize_table() there is an indirect check, as the code is only run if a certain page is being accessed:
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | public function optimize_table() { global $wpdb; if (Menus::in_page('optimization') and isset($_GET['optimize-table']) and !empty($_GET['optimize-table'])) { $tbl = trim(sanitize_text_field($_GET['optimize-table'])); if ($tbl == "all") { $tables = array_filter(array_values(DB::table('all'))); } else { $tables = array_filter(array(DB::table($tbl))); } if (!empty($tables)) { $notice = ''; $okay = true; // Use wp-admin/maint/repair.php foreach ($tables as $table) { $check = $wpdb->get_row("CHECK TABLE $table"); if ('OK' === $check->Msg_text) { /* translators: %s: Table name. */ $notice .= sprintf(__('The %s table is okay.', "wp-statistics"), "<code>$table</code>"); $notice .= ' '; } else { $notice .= sprintf(__('The %1$s table is not okay. It is reporting the following error: %2$s. WordPress will attempt to repair this table…', "wp-statistics"), "<code>$table</code>", "<code>$check->Msg_text</code>"); $repair = $wpdb->get_row("REPAIR TABLE $table"); $notice .= ' '; if ('OK' === $repair->Msg_text) { $notice .= sprintf(__('Successfully repaired the %s table.', "wp-statistics"), "<code>$table</code>"); } else { $notice .= sprintf(__('Failed to repair the %1$s table. Error: %2$s', "wp-statistics"), "<code>$table</code>", "<code>$check->Msg_text</code>") . ' '; $problems[$table] = $check->Msg_text; $okay = false; } } if ($okay) { $check = $wpdb->get_row("ANALYZE TABLE $table"); if ('Table is already up to date' === $check->Msg_text) { $notice .= sprintf(__('The %s table is already optimized.', "wp-statistics"), "<code>$table</code>"); $notice .= ' '; } else { $check = $wpdb->get_row("OPTIMIZE TABLE $table"); |
So the optimization functionality accessed through the save() function is accessible to anyone logged in to WordPress who can access the admin area. As there isn’t protection against cross-site request forgery (CSRF) in either function, an attacker could cause the optimization functionality accessed through both functions to be run by someone who has access, without the victim intending to run it.
WordPress Causes Full Disclosure
As a protest of the moderators of the WordPress Support Forum’s continued inappropriate behavior we changed from reasonably disclosing to full disclosing vulnerabilities for plugins in the WordPress Plugin Directory in protest, until WordPress gets that situation cleaned up, so we are releasing this post and then leaving a message about that for the developer through the WordPress Support Forum. (For plugins that are also in the ClassicPress Plugin Directory, we will follow our reasonable disclosure policy.)
You can notify the developer of this issue on the forum as well.
Hopefully, the moderators will finally see the light and clean up their act soon, so these full disclosures will no longer be needed (we hope they end soon). You would think they would have already done that, but considering that they believe that having plugins, which have millions installs, remain in the Plugin Directory despite them knowing they are vulnerable is “appropriate action”, something is very amiss with them (which is even more reason the moderation needs to be cleaned up).
If the moderation is cleaned up, it would also allow the possibility of being able to use the forum to start discussing fixing the problems caused by the very problematic handling of security by the team running the Plugin Directory, discussions which they have for years shut down through their control of the Support Forum.
Update: To clear up the confusion where developers claim we hadn’t tried to notify them through the Support Forum (while at the same time moderators are complaining about us doing just that), here is the message we left for this vulnerability:
Is It Fixed?
If you are reading this post down the road the best way to find out if this vulnerability or other WordPress plugin vulnerabilities in plugins you use have been fixed is to sign up for our service, since what we uniquely do when it comes to that type of data is to test to see if vulnerabilities have really been fixed. Relying on the developer’s information can lead you astray, as we often find that they believe they have fixed vulnerabilities, but have failed to do that.
Proof of Concept
The following proof of concept will replace IP Addresses with hash values, when logged in to WordPress.
Make sure to replace “[path to WordPress]” with the location of WordPress.
http://[path to WordPress]/wp-admin/profile.php?hash-ips=1
Concerned About The Security of the Plugins You Use?
When you are a paying customer of our service, you can suggest/vote for the WordPress plugins you use to receive a security review from us. You can start using the service for free when you sign up now. We also offer security reviews of WordPress plugins as a separate service.Plugin Security Scorecard Grade for Patchstack
Checked on March 5, 2025See issues causing the plugin to get less than A+ grade