CVE-2026-25008

Ninja Tables – Easy Data Table Builder <= 5.2.5 - Authenticated (Contributor+) Information Exposure

mediumExposure of Sensitive Information to an Unauthorized Actor
4.3
CVSS Score
4.3
CVSS Score
medium
Severity
5.2.6
Patched in
107d
Time to patch

Description

The Ninja Tables – Easy Data Table Builder plugin for WordPress is vulnerable to Sensitive Information Exposure in all versions up to, and including, 5.2.5. This makes it possible for authenticated attackers, with Contributor-level access and above, to extract sensitive user or configuration data.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
Low
Confidentiality
None
Integrity
None
Availability

Technical Details

Affected versions<=5.2.5
PublishedJanuary 18, 2026
Last updatedMay 4, 2026
Affected pluginninja-tables

What Changed in the Fix

Changes introduced in v5.2.6

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This research plan targets **CVE-2026-25008**, an information exposure vulnerability in **Ninja Tables**. The vulnerability exists because the plugin's custom REST API router (defined in `app/Http/Routes/routes.php`) uses a `UserPolicy` that likely only verifies a user is authenticated, failing to e…

Show full research plan

This research plan targets CVE-2026-25008, an information exposure vulnerability in Ninja Tables. The vulnerability exists because the plugin's custom REST API router (defined in app/Http/Routes/routes.php) uses a UserPolicy that likely only verifies a user is authenticated, failing to enforce high-level administrative capabilities (like manage_options) for sensitive data retrieval. This allows Contributor-level users to access table configurations and, more critically, the actual data rows stored within those tables.

1. Vulnerability Summary

  • Vulnerability: Authenticated (Contributor+) Information Exposure.
  • Location: Custom REST API endpoints registered in app/Http/Routes/routes.php and handled by TablesController and TableItemsController.
  • Cause: Insufficient authorization checks within the UserPolicy or the controller methods. While the Admin UI is restricted via ninja_table_admin_role() in AdminMenuHandler.php, the underlying REST API allows any logged-in user to query table lists and row data.

2. Attack Vector Analysis

  • Endpoint: /wp-json/ninja-tables/v1/tables and /wp-json/ninja-tables/v1/tables/{id}/item
  • HTTP Method: GET
  • Authentication: Authenticated (Contributor level or higher).
  • Precondition: At least one table must exist in the system (created by an Administrator).
  • Required Headers: X-WP-Nonce (standard WordPress REST API nonce).

3. Code Flow

  1. Route Registration: In app/Http/Routes/routes.php, routes are grouped under $router->withPolicy('UserPolicy').
    • $route->get('/', [TablesController::class, 'index']) (Prefix: tables)
    • $route->get('/', [TableItemsController::class, 'index']) (Prefix: tables/{id}/item)
  2. Request Handling: When a Contributor sends a request to /wp-json/ninja-tables/v1/tables, the UserPolicy is evaluated. If it only checks is_user_logged_in(), the request proceeds.
  3. Data Retrieval:
    • TablesController::index() calls Post::getPosts(), which queries the ninja-table post type without ownership restrictions.
    • TableItemsController::index() (implied logic) queries the ninja_table_items database table for the given table ID and returns all rows.
  4. Sink: The data is returned as a JSON response via $this->json($data, 200) in the controller.

4. Nonce Acquisition Strategy

The plugin uses the standard WordPress REST API nonce (wp_rest). Any authenticated user, including a Contributor, can retrieve this nonce from the default WordPress admin dashboard.

  1. Login: Log in as the Contributor user using browser_login.
  2. Navigate: Navigate to the WordPress Profile page (/wp-admin/profile.php).
  3. Extract: Execute JavaScript to retrieve the nonce from the global wpApiSettings object provided by WordPress core.
    • Command: browser_eval("wpApiSettings.nonce")
  4. Fallback: If wpApiSettings is not available, the nonce can be extracted from the page source where scripts are localized (searching for "nonce":"...").

5. Exploitation Strategy

Step 1: List All Tables

Retrieve the list of all tables to find the ID of a target table containing sensitive information.

  • Endpoint: /wp-json/ninja-tables/v1/tables
  • Method: GET
  • Headers:
    • Content-Type: application/json
    • X-WP-Nonce: [EXTRACTED_NONCE]
  • Expected Response: A JSON object containing an array of tables in the data key.

Step 2: Extract Table Data

Retrieve the actual rows from the target table identified in Step 1.

  • Endpoint: /wp-json/ninja-tables/v1/tables/{TABLE_ID}/item
  • Method: GET
  • Headers:
    • Content-Type: application/json
    • X-WP-Nonce: [EXTRACTED_NONCE]
  • Expected Response: A JSON object containing the values of every row in the table, potentially exposing PII or internal configurations.

Step 3: Leak User Information (Optional/Alternative)

Check if author information is exposed.

  • Endpoint: /wp-json/ninja-tables/v1/wp-posts/authors
  • Method: GET
  • Headers: X-WP-Nonce: [EXTRACTED_NONCE]

6. Test Data Setup

  1. Administrator Action:
    • Create a table named "Sensitive Customer Data".
    • Add columns: "Name", "Email", "Phone".
    • Insert a row: {"Name": "John Doe", "Email": "john@secret.com", "Phone": "555-0199"}.
  2. Attacker Action:
    • Create a user with the Contributor role.

7. Expected Results

  • The Contributor user, who should not have access to Ninja Tables data, successfully receives a 200 OK response from the REST endpoints.
  • The JSON response contains the private table's title, settings, and row values (e.g., john@secret.com).

8. Verification Steps

  1. Verify via WP-CLI: Check the database directly to confirm the table ID and content match the leaked data.
    • wp post list --post_type=ninja-table
    • wp db query "SELECT * FROM wp_ninja_table_items WHERE table_id = [ID]"
  2. Check Permissions: Confirm the Contributor user lacks the manage_options capability.
    • wp user cap list [USER_ID]

9. Alternative Approaches

If the ninja-tables/v1 namespace is different in the specific version:

  1. Check app/Hooks/Handlers/AdminMenuHandler.php inside getRestInfo():
    $ns  = $app->config->get('app.rest_namespace'); // Check config/app.php or equivalent
    $ver = $app->config->get('app.rest_version');
    
  2. Search for register_rest_route calls in the entire codebase if the custom router is not used for all versions.
  3. Test the shortcode-based info exposure in PublicDataHandler::tableInfoShortcode by creating a post as Contributor and inserting [ninja_table_info id="[ID]" field="last_editor"] to see if it bypasses the is_preview() check.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Ninja Tables plugin for WordPress is vulnerable to information exposure because its REST API and internal shortcode handlers lack adequate authorization checks beyond basic authentication. Authenticated users with Contributor-level access or higher can exploit this to list all table configurations and extract sensitive row data, including PII or internal configurations.

Vulnerable Code

// app/Http/Routes/routes.php
$router->withPolicy('UserPolicy')->group(function ($router) {
    $router->prefix('tables')->group(function ($route) {
        $route->get('/', [TablesController::class, 'index']);
        // ...
        $route->prefix('/{id}')->group(function ($route) {
            // ...
            $route->prefix('/item')->group(function ($route) {
                $route->get('/', [TableItemsController::class, 'index'])->int('id');

---

// app/Http/Controllers/TablesController.php, lines 25-33
$args = array(
    'posts_per_page' => $perPage,
    'offset'         => $skip,
    'orderby'        => Sanitizer::sanitizeTextField(Arr::get($request->all(), 'orderBy')),
    'order'          => Sanitizer::sanitizeTextField(Arr::get($request->all(), 'order')),
    'post_type'      => $this->cptName,
    'post_status'    => 'any',
);

---

// app/Hooks/Handlers/PublicDataHandler.php, lines 105-110
$id = absint($id);
$table = get_post($id);
if (!$table) {
    return;
}

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/ninja-tables/5.2.5/app/Hooks/Handlers/AdminMenuHandler.php /home/deploy/wp-safety.org/data/plugin-versions/ninja-tables/5.2.6/app/Hooks/Handlers/AdminMenuHandler.php
--- /home/deploy/wp-safety.org/data/plugin-versions/ninja-tables/5.2.5/app/Hooks/Handlers/AdminMenuHandler.php	2025-12-17 05:18:32.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/ninja-tables/5.2.6/app/Hooks/Handlers/AdminMenuHandler.php	2026-01-27 13:17:08.000000000 +0000
@@ -290,7 +290,9 @@
         $cptName             = 'ninja-table';
         $tableCount          = wp_count_posts($cptName);
         $totalPublishedTable = 0;
+	$totalTrashedTable   = 0;
         $publish             = property_exists($tableCount, "publish") ? $tableCount->publish : 0;
+	$trash               = property_exists($tableCount, "trash") ? $tableCount->trash : 0;
 
         if ($tableCount && $publish > 1) {
             $leadStatus = $app->applyFilters('ninja_tables_show_lead', $leadStatus);
@@ -304,6 +306,10 @@
             $totalPublishedTable = $publish;
         }
 
+        if ($tableCount && $trash > 0) {
+            $totalTrashedTable = $trash;
+        }
+
         $hasFluentFrom       = defined('FLUENTFORM_VERSION');
         $isFluentFromUpdated = false;
         $hasTablePress       = defined('TABLEPRESS_ABSPATH');
@@ -363,6 +369,7 @@
             'hasValidLicense'          => get_option('_ninjatables_pro_license_status'),
             'i18n'                     => I18nStrings::getStrings(),
             'published_tables'         => $totalPublishedTable,
+            'trashed_tables'           => $totalTrashedTable,
             'preview_required_scripts' => array(
                 $assets . "css/ninjatables-public.css",
                 $assets . "libs/footable/js/footable.min.js",
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/ninja-tables/5.2.5/app/Hooks/Handlers/PublicDataHandler.php /home/deploy/wp-safety.org/data/plugin-versions/ninja-tables/5.2.6/app/Hooks/Handlers/PublicDataHandler.php
--- /home/deploy/wp-safety.org/data/plugin-versions/ninja-tables/5.2.5/app/Hooks/Handlers/PublicDataHandler.php	2025-12-17 05:18:32.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/ninja-tables/5.2.6/app/Hooks/Handlers/PublicDataHandler.php	2026-01-27 13:17:08.000000000 +0000
@@ -86,8 +86,9 @@
 
         $id = absint($id);
         $table = get_post($id);
-		if (!$table) {
-			return;
+
+        if (!$table || $table->post_type !== 'ninja-table' || $table->post_status !== 'publish') {
+            return 'Error: Invalid table ID.';
         }
 
         $validFields = [
@@ -169,6 +170,11 @@
         }
 
         $id = absint($id);
+
+        if (get_post_type($id) !== 'ninja-table' || get_post_status($id) !== 'publish') {
+            return 'Sorry! The table is not available.';
+        }
+
         $tableSettings = ninja_table_get_table_settings($id, 'public');
 
         if ($row_id) {
@@ -281,7 +287,7 @@
         }
         $table = get_post($table_id);
 
-        if (!$table || $table->post_type != 'ninja-table') {
+        if (!$table || $table->post_type !== 'ninja-table' || $table->post_status !== 'publish') {
             return;
         }
 
@@ -362,7 +368,7 @@
 
         $table = get_post($table_id);
 
-        if (!$table || $table->post_type != 'ninja-table') {
+        if (!$table || $table->post_type !== 'ninja-table' || $table->post_status !== 'publish') {
             return;
         }
 
@@ -50,10 +52,10 @@
 
     public function store(Request $request)
     {
-        if ( ! Sanitizer::sanitizeTextField(Arr::get($request->all(), 'post_title'))) {
-            $this->sendError(array(
-                'message' => __('The name field is required.', 'ninja-tables')
-            ), 423);
+        if (empty($request->get('post_title'))) {
+            wp_send_json([
+                'message' => __('Title is required', 'ninja-tables')
+            ], 422);
         }

Exploit Outline

The exploit leverages the plugin's custom REST API endpoints, which are protected by a 'UserPolicy' that only checks if a user is authenticated rather than enforcing specific administrative roles. 1. Authentication: Log in to the target WordPress site with Contributor-level credentials. 2. Nonce Retrieval: Obtain a valid WordPress REST API nonce (standard `_wpnonce`) from the admin dashboard (e.g., by checking `wpApiSettings.nonce` in the browser console). 3. Table Discovery: Send a GET request to `/wp-json/ninja-tables/v1/tables` with the `X-WP-Nonce` header. The server will return a JSON list of all defined tables, regardless of the user's permissions, including table IDs and titles. 4. Data Extraction: For each discovered table ID, send a GET request to `/wp-json/ninja-tables/v1/tables/{ID}/item`. The server responds with the complete set of row data for that table, exposing any information stored within.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.