CVE-2026-5721

wpDataTables – WordPress Data Table, Dynamic Tables & Table Charts Plugin <= 6.5.0.4 - Unauthenticated Stored Cross-Site Scripting via CSV/Excel Data Import

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
4.7
CVSS Score
4.7
CVSS Score
medium
Severity
6.5.0.5
Patched in
1d
Time to patch

Description

The wpDataTables – WordPress Data Table, Dynamic Tables & Table Charts Plugin plugin for WordPress is vulnerable to Stored Cross-Site Scripting in all versions up to, and including, 6.5.0.4. This is due to insufficient input sanitization and output escaping in the prepareCellOutput() method of the LinkWDTColumn, ImageWDTColumn, and EmailWDTColumn classes. This makes it possible for unauthenticated attackers to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page, given that they can trick an Administrator into importing data from an attacker-controlled source and the affected column types (Link, Image, or Email) are configured.

CVSS Vector Breakdown

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

Technical Details

Affected versions<=6.5.0.4
PublishedApril 20, 2026
Last updatedApril 20, 2026
Affected pluginwpdatatables

What Changed in the Fix

Changes introduced in v6.5.0.5

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan - CVE-2026-5721 ## 1. Vulnerability Summary The **wpDataTables** plugin (<= 6.5.0.4) contains a stored Cross-Site Scripting (XSS) vulnerability. The flaw exists in the `prepareCellOutput()` method within three column-handler classes: `EmailWDTColumn`, `ImageWDTColumn`, …

Show full research plan

Exploitation Research Plan - CVE-2026-5721

1. Vulnerability Summary

The wpDataTables plugin (<= 6.5.0.4) contains a stored Cross-Site Scripting (XSS) vulnerability. The flaw exists in the prepareCellOutput() method within three column-handler classes: EmailWDTColumn, ImageWDTColumn, and LinkWDTColumn. These methods fail to sanitize or escape data before interpolating it into HTML string templates. While the import process itself is typically an administrative action, the vulnerability is classified as unauthenticated because an attacker can host a malicious CSV/Excel/JSON file on a remote server. If an administrator is tricked into creating a table from this remote source, the malicious scripts are stored and executed whenever any user (including the administrator) views the table.

2. Attack Vector Analysis

  • Endpoint: The vulnerability is triggered during table rendering, typically via the wp_ajax_wpdatatables_get_table AJAX action (for server-side tables) or the initial shortcode rendering ([wpdatatable id=...]).
  • Vulnerable Sink:
    • EmailWDTColumn::prepareCellOutput($content) in source/class.email.wpdatacolumn.php
    • ImageWDTColumn::prepareCellOutput($content) in source/class.image.wpdatacolumn.php
  • Payload Delivery: A malicious CSV/Excel file containing XSS payloads in columns mapped as "Email", "Image", or "Link" types.
  • Authentication: Unauthenticated (Attacker-controlled source) / Requires Admin Interaction (Import/Configuration).
  • Preconditions:
    1. A table must be created using the malicious source.
    2. Column types must be set to "Email" or "Image".

3. Code Flow

  1. Data Acquisition: wpDataTables fetches data from a source (CSV file, Excel file, or Database).
  2. Table Initialization: The plugin identifies column types based on settings stored in the {prefix}_wpdatatables_columns table.
  3. Rendering Phase: During the generation of the table response (frontend or backend), the plugin iterates through rows and columns.
  4. Sink Call: For "Email" or "Image" columns, the prepareCellOutput($content) method is invoked.
    • Email (source/class.email.wpdatacolumn.php):
      $formattedValue = "<a href='mailto:{$content}'>{$content}</a>"; // Content is reflected raw
      
    • Image (source/class.image.wpdatacolumn.php):
      $formattedValue = "<img src='{$content}' />"; // Content is reflected raw
      
  5. Output: The unescaped HTML string is returned to the browser and executed.

4. Nonce Acquisition Strategy

If the proof-of-concept requires interacting with the AJAX table data endpoint (wp_ajax_wpdatatables_get_table), a nonce is required.

  1. Shortcode Identification: The default shortcode is [wpdatatable id=ID].
  2. Page Creation:
    wp post create --post_type=page --post_title="Table Page" --post_status=publish --post_content='[wpdatatable id=1]'
    
  3. Nonce Extraction:
    Navigate to the newly created page. The plugin localizes configuration data into the window object.
    • Variable Name: wpdatatables_frontend_config (inferred from common plugin patterns) or check for scripts enqueued with the handle wpdatatables-frontend.
    • JS Path: window.wpDataTables?["1"]?.wdtNonce (where "1" is the table ID) or a similar localized object.
    • Browser Command: browser_eval("wpdatatables_frontend_config.wdtNonce")

5. Exploitation Strategy

The goal is to demonstrate that data imported from a CSV is rendered without escaping.

Step 1: Prepare Malicious CSV

Create a CSV file containing XSS payloads.

email_row,image_row
"attacker@evil.com'><img src=x onerror=alert('CVE-2026-5721-Email')>","https://example.com/logo.png' onerror='alert(""CVE-2026-5721-Image"")"

Step 2: Simulate Admin Table Creation

Since an automated agent cannot "trick" an admin, we will use wp eval to programmatically create the wpDataTable configuration to simulate the state after an admin has imported the malicious file.

Step 3: Trigger XSS

Navigate to the frontend page containing the table. The browser will execute the payloads during the rendering of the email_row and image_row cells.

6. Test Data Setup

  1. Create Table Entry: Use wp db query or wp eval to insert a row into {prefix}_wpdatatables with a specific title and table_type='manual' (to store the payload directly in the DB) or table_type='csv'.
  2. Define Columns:
    • Column 1: orig_header='email_row', display_header='Email', column_type='email'.
    • Column 2: orig_header='image_row', display_header='Image', column_type='image'.
  3. Insert Malicious Data: Insert a row into the generated table (e.g., {prefix}_wpdatatable_1) containing the XSS payloads.
  4. Create Frontend Page:
    wp post create --post_type=page --post_status=publish --post_content='[wpdatatable id=1]'
    

7. Expected Results

  • When navigating to the page, the browser should display two alert boxes:
    • alert('CVE-2026-5721-Email')
    • alert('CVE-2026-5721-Image')
  • Inspecting the DOM will reveal unescaped payloads:
    • <a href='mailto:attacker@evil.com'><img src=x onerror=alert('CVE-2026-5721-Email')>'>...</a>
    • <img src='https://example.com/logo.png' onerror='alert("CVE-2026-5721-Image")' />

8. Verification Steps

  1. Database Check: Verify the payload is stored raw in the table:
    wp db query "SELECT email_row FROM wp_wpdatatable_1"
    
  2. Response Check: Use http_request to fetch the table data AJAX response and check for the unescaped payload:
    # Use the correct AJAX action and nonce
    POST /wp-admin/admin-ajax.php
    Content-Type: application/x-www-form-urlencoded
    Body: action=wpdatatables_get_table&table_id=1&wdtNonce=[NONCE]
    
  3. DOM Check: Use browser_eval to confirm the presence of the onerror attribute in the rendered table.

9. Alternative Approaches

If "Manual" tables are sanitized during input (though the vulnerability description says the sink is in the output), use a "URL-linked" CSV table:

  1. Host the malicious CSV on a local web server (or a file path if the plugin allows).
  2. Configure the wpDataTable to use the CSV URL as its source.
  3. Call the plugin's refresh/sync method to pull the malicious data.
  4. Render the frontend page.
Research Findings
Static analysis — not yet PoC-verified

Summary

The wpDataTables plugin is vulnerable to Stored Cross-Site Scripting (XSS) because it fails to properly sanitize or escape data imported from external files (CSV, Excel, JSON) before rendering it in Email, Image, or Link columns. This allows attackers to store malicious scripts in the table configuration which execute in the browser of any user viewing the table, provided an administrator can be tricked into importing the data source.

Vulnerable Code

// source/class.email.wpdatacolumn.php
    public function prepareCellOutput($content)
    {
        $content = apply_filters('wpdatatables_filter_email_cell_before_formatting', $content, $this->getParentTable()->getWpId());

        if (is_null($content)) {
            $formattedValue = '';
        } else {
            if (strpos($content, '||') !== false) {
                list($link, $content) = explode('||', $content);
                $formattedValue = "<a href='mailto:{$link}'>{$content}</a>";
            } else {
                $formattedValue = "<a href='mailto:{$content}'>{$content}</a>";
            }
        }
        return apply_filters('wpdatatables_filter_email_cell', $formattedValue, $this->getParentTable()->getWpId());
    }

---

// source/class.image.wpdatacolumn.php
    public function prepareCellOutput($content)
    {
        $content = apply_filters('wpdatatables_filter_image_cell_before_formatting', $content, $this->getParentTable()->getWpId());

        if (empty($content)) {
            return '';
        }
        if (FALSE !== strpos($content, '||')) {
            list($image, $link) = explode('||', $content);
            $formattedValue = "<a href='{$link}' target='_blank' rel='lightbox[-1]'><img src='{$image}' /></a>";
        } else {
            $formattedValue = "<img src='{$content}' />";
        }
        return apply_filters('wpdatatables_filter_image_cell', $formattedValue, $this->getParentTable()->getWpId());
    }

---

// source/class.link.wpdatacolumn.php (approx. line 40)
    public function prepareCellOutput($content)
    {
        $targetAttribute = $this->getLinkTargetAttribute();
        $nofollowAttribute = $this->getLinkNofollowAttribute() == 1 ? ' nofollow ' : '';
        $noreferrerAttribute = $this->getLinkNoreferrerAttribute() == 1 ? ' noreferrer ' : '';
        $sponsoredAttribute = $this->getLinkSponsoredAttribute() == 1 ? ' sponsored ' : '';
        $rel = $nofollowAttribute . $noreferrerAttribute . $sponsoredAttribute;
        $buttonClass = $this->getLinkButtonClass();

        $content = apply_filters('wpdatatables_filter_link_cell_before_formatting', $content, $this->getParentTable()->getWpId());

        if (is_null($content)){
            $formattedValue = '';
        } else {
            if (strpos($content, '||') !== false) {
                list($link, $content) = explode('||', $content);
                $buttonLabel = $this->getLinkButtonLabel() !== '' ? $this->getLinkButtonLabel() : $content;

                if ($this->getLinkButtonAttribute() == 1 && $content !== '') {
                    $formattedValue = "<a data-content='{$content}' href='{$link}' rel='{$rel}' target='{$targetAttribute}'><button class='{$buttonClass}'>{$buttonLabel}</button></a>";
                } else {
                    $formattedValue = "<a data-content='{$content}' href='{$link}' rel='{$rel}' target='{$targetAttribute}'>{$content}</a>";
                }
// ... (truncated)

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wpdatatables/6.5.0.4/source/class.email.wpdatacolumn.php /home/deploy/wp-safety.org/data/plugin-versions/wpdatatables/6.5.0.5/source/class.email.wpdatacolumn.php
--- /home/deploy/wp-safety.org/data/plugin-versions/wpdatatables/6.5.0.4/source/class.email.wpdatacolumn.php	2026-04-14 07:28:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wpdatatables/6.5.0.5/source/class.email.wpdatacolumn.php	2026-04-20 08:53:10.000000000 +0000
@@ -26,17 +26,32 @@
     {
         $content = apply_filters('wpdatatables_filter_email_cell_before_formatting', $content, $this->getParentTable()->getWpId());
 
-        if (is_null($content)) {
+        if (is_null($content) || '' === $content) {
             $formattedValue = '';
         } else {
             if (strpos($content, '||') !== false) {
-                list($link, $content) = explode('||', $content);
-                $formattedValue = "<a href='mailto:{$link}'>{$content}</a>";
+                $parts = explode('||', $content, 2);
+                $email_raw = isset($parts[0]) ? trim($parts[0]) : '';
+                $label = isset($parts[1]) ? $parts[1] : '';
+                $email = sanitize_email($email_raw);
+                if (empty($email)) {
+                    $formattedValue = esc_html($content);
+                } else {
+                    $mailto_href = 'mailto:' . $email;
+                    $display = ( $label !== '' && $label !== null ) ? $label : $email;
+                    $formattedValue = '<a href="' . esc_url($mailto_href) . '">' . esc_html($display) . '</a>';
+                }
             } else {
-                $formattedValue = "<a href='mailto:{$content}'>{$content}</a>";
+                $trimmed = trim($content);
+                $email = sanitize_email($trimmed);
+                if (empty($email)) {
+                    $formattedValue = esc_html($content);
+                } else {
+                    $formattedValue = '<a href="' . esc_url('mailto:' . $email) . '">' . esc_html($trimmed) . '</a>';
+                }
             }
         }
         return apply_filters('wpdatatables_filter_email_cell', $formattedValue, $this->getParentTable()->getWpId());
     }
 
-}
\ No newline at end of file
+}
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/wpdatatables/6.5.0.4/source/class.image.wpdatacolumn.php /home/deploy/wp-safety.org/data/plugin-versions/wpdatatables/6.5.0.5/source/class.image.wpdatacolumn.php
--- /home/deploy/wp-safety.org/data/plugin-versions/wpdatatables/6.5.0.4/source/class.image.wpdatacolumn.php	2026-04-14 07:28:04.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/wpdatatables/6.5.0.5/source/class.image.wpdatacolumn.php	2026-04-20 08:53:10.000000000 +0000
@@ -29,11 +29,26 @@
         if (empty($content)) {
             return '';
         }
-        if (FALSE !== strpos($content, '||')) {
-            list($image, $link) = explode('||', $content);
-            $formattedValue = "<a href='{$link}' target='_blank' rel='lightbox[-1]'><img src='{$image}' /></a>";
+        if (false !== strpos($content, '||')) {
+            $parts = explode('||', $content, 2);
+            $image = isset($parts[0]) ? trim($parts[0]) : '';
+            $link = isset($parts[1]) ? trim($parts[1]) : '';
+            $image = esc_url($image);
+            $link = esc_url($link);
+            if ($image === '' && $link === '') {
+                $formattedValue = '';
+            } elseif ($image !== '' && $link !== '') {
+                $formattedValue = '<a href="' . $link . '" target="_blank" rel="lightbox[-1] noopener noreferrer">'
+                    . '<img src="' . $image . '" alt="" /></a>';
+            } elseif ($image !== '') {
+                $formattedValue = '<img src="' . $image . '" alt="" />';
+            } else {
+                $formattedValue = '';
+            }
         } else {
-            $formattedValue = "<img src='{$content}' />";
+            $src = esc_url(trim($content));
+            $formattedValue = $src !== '' ? '<img src="' . $src . '" alt="" />' : '';
         }
         return apply_filters('wpdatatables_filter_image_cell', $formattedValue, $this->getParentTable()->getWpId());
     }

Exploit Outline

1. Create a malicious CSV or Excel file containing XSS payloads (e.g., `"'><img src=x onerror=alert('XSS')>"`) in columns intended to be mapped as Email, Image, or Link types. 2. Host the malicious file on an attacker-controlled remote server or deliver it to an administrator via social engineering. 3. An administrator imports the file into wpDataTables to create a new table and configures the relevant columns as 'Email', 'Image', or 'Link'. 4. The plugin stores the raw XSS payload in the database or links it to the remote source. 5. When any user (including the administrator) views the page where the table is embedded, the plugin's `prepareCellOutput()` method for the respective column types interpolates the unsanitized payload directly into HTML attributes or tags, triggering the script execution.

Check if your site is affected.

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