The Exploit
An authenticated user with subscriber-level permissions can execute arbitrary SQL queries through the getQueryData() AJAX handler by sending a POST request with a malicious query parameter. The plugin fails to validate the user's capability to run database queries, allowing privilege escalation and data exfiltration.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in_xxx=subscriber_session_token
action=visualizer_get_query_data¶ms[chart_id]=1¶ms[query]=SELECT+user_login,user_pass+FROM+wp_users+UNION+SELECT+option_name,option_value+FROM+wp_options+WHERE+option_name+LIKE+'%transient%'
When this request lands, the server returns JSON containing the plaintext output of the attacker's SQL query — including WordPress user password hashes and sensitive option values. The attacker can then use these hashes with offline cracking tools or modify post meta to escalate their account to administrator.
What the Patch Did
Before
$params = wp_parse_args( $_POST['params'] );
$chart_id = filter_var( $params['chart_id'], FILTER_VALIDATE_INT );
$source = new Visualizer_Source_Query( stripslashes( $params['query'] ), $chart_id, $params );
$source->fetch( false );
$error = $source->get_error();
if ( empty( $error ) ) {
update_post_meta( $chart_id, Visualizer_Plugin::CF_DB_QUERY, stripslashes( $params['query'] ) );
wp_send_json_success( $source->get_data() );
}
After
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'msg' => __( 'Action not allowed for this user.', 'visualizer' ) ) );
}
$params = wp_parse_args( $_POST['params'] );
$chart_id = filter_var( $params['chart_id'], FILTER_VALIDATE_INT );
$query = trim( $params['query'], ';' );
$source = new Visualizer_Source_Query( stripslashes( $query ), $chart_id, $params );
$source->fetch( false );
$error = $source->get_error();
if ( empty( $error ) ) {
update_post_meta( $chart_id, Visualizer_Plugin::CF_DB_QUERY, stripslashes( $query ) );
wp_send_json_success( $source->get_data() );
}
The patch added a capability check using current_user_can( 'manage_options' ) — WordPress's standard API for verifying administrator-level access — before the AJAX handler processes any query data. This enforces role-based access control on a sensitive operation. Secondary hardening includes trim( $params['query'], ';' ) to strip trailing semicolons, reducing the surface for query manipulation, though this is defense-in-depth rather than the primary control.
Root Cause
CWE-862: Missing Authorization Check. The getQueryData() AJAX action registered via wp_ajax_ hooks accepts the query parameter from $_POST['params'] without verifying the requesting user's role or capability. The dataflow is: untrusted $_POST['params']['query'] → passed directly to Visualizer_Source_Query constructor → executed against the WordPress database via the source's fetch() method. The trust boundary — the line separating "data users can submit" from "operations only admins should perform" — was never enforced. Any authenticated user, regardless of role, could reach the AJAX endpoint and trigger arbitrary SQL execution.
Why It Works
The load-bearing line is if ( ! current_user_can( 'manage_options' ) ). Removing it restores the vulnerability instantly: a subscriber can still craft the same POST request and receive query results. The engineer added trim( $params['query'], ';' ) as defense-in-depth, catching a class of query-manipulation bypasses (e.g., SELECT * FROM wp_users; DROP TABLE wp_users;), but this alone would not have stopped the fundamental exploit — an attacker can execute harmful queries without trailing semicolons. The real fix was the capability check, which short-circuits the entire function before any database access occurs. The trim operation exists to prevent future authors from accidentally re-opening the door via query parsing edge cases.
Hardening Checklist
-
Call
current_user_can()at the top of every AJAX handler that performs mutation or reads sensitive data. Use'manage_options'for admin-only operations; use'edit_posts'or plugin-defined caps for contributor-level actions. Never defer capability checks to a class constructor or library function — make them explicit in the handler. -
Sanitize all SQL inputs via
$wpdb->prepare(), using parameterized queries. The trim() in the patch is insufficient alone; ifVisualizer_Source_Queryconstructs raw SQL, rewrite it to use prepared statements with%s,%dplaceholders. -
Validate and restrict POST parameter schema with
rest_validate_request_arg()or manual type checks. The chart ID correctly usedfilter_var( $params['chart_id'], FILTER_VALIDATE_INT )— extend this to the query parameter by whitelisting allowed keywords (SELECT, FROM, WHERE, JOIN) and rejecting DDL (ALTER, DROP, CREATE). -
Log all AJAX calls that read or modify user data to
error_log()or a dedicated audit table, including the user ID, the query fingerprint (not the full query, to avoid logs becoming attack vectors), and the timestamp. This enables incident detection when privilege escalation attempts occur. -
Audit all registered AJAX handlers (
add_action( 'wp_ajax_...' )) in a security review before releasing. AJAX handlers are a common attack surface because they are often overlooked in code review — treat them as you would REST endpoints.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-3750