The Exploit
An unauthenticated attacker can craft arbitrary POST parameters with SQL injection payloads in the parameter names (not values), bypassing the blacklist-based validation and achieving blind time-based SQL injection via the stickymenu_contact_lead_form AJAX action.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=stickymenu_contact_lead_form&widget_id=1&contact_name=John&[email protected]&contact_phone=555&page_link=http://test&contact_name' AND (SELECT * FROM (SELECT(SLEEP(5)))a)-- =1
When the request lands, the server sleeps for 5 seconds before responding with a success message. The attacker observes the time-delay response and confirms blind SQL injection is possible. By varying the injected condition (e.g., SLEEP(5) IF(1=1)), the attacker can extract database contents one bit at a time, reading password hashes, admin credentials, and arbitrary table data without ever seeing query results directly.
What the Patch Did
Before
foreach( $postArr as $key => $val ){
if( $key != 'action' && $key != 'widget_id' && $key != 'save_form_lead' && $key != 'wpnonce'){
$params[$key] = (isset($val) && $val != '') ? esc_sql( sanitize_text_field($val) ) : '';
}
}
After
$allowed_keys = ['contact_name', 'contact_email', 'contact_phone', 'page_link'];
$params = [];
foreach ($allowed_keys as $key) {
if (isset($_POST[$key]) && $_POST[$key] !== '') {
$params[$key] = sanitize_text_field($_POST[$key]);
}
}
The patch replaced a blacklist-based filtering strategy with explicit whitelist validation. The old code excluded only four parameter names (action, widget_id, save_form_lead, wpnonce) and trusted any other POST key as a valid column identifier for the $wpdb->insert() call. The fixed code defines exactly which parameters are acceptable (contact_name, contact_email, contact_phone, page_link) and iterates only over those keys, rejecting any attacker-supplied column names outright. Additionally, the patch removed the esc_sql() call on parameter values, relying instead on $wpdb->insert()'s built-in prepared statement handling, which separates identifiers from values at the database protocol level.
Root Cause
CWE-20: Improper Input Validation feeding into CWE-89: SQL Injection.
The stickymenu_contact_lead_form AJAX handler accepts POST parameters directly from the client without validating their names. The vulnerable code iterates over all POST keys, applies a blacklist filter that only checks for four hardcoded exclusions, then passes the remaining keys as column identifiers into $wpdb->insert(). An attacker crafts a POST request with a malicious parameter name like contact_name' AND SLEEP(5)-- , which clears the blacklist (it is not one of the four excluded names) and reaches the $wpdb->insert() call. Although parameter values are escaped via esc_sql() and sanitize_text_field(), the parameter keys are used unvalidated as SQL column names. The database interprets the injected SQL in the column list, enabling arbitrary query manipulation.
Why It Works
The load-bearing line is:
$allowed_keys = ['contact_name', 'contact_email', 'contact_phone', 'page_link'];
Remove this and the vulnerability remains exploitable: an attacker simply injects a new parameter name that is not in the (now-empty or non-existent) whitelist, and it reaches the sink. The whitelist constraint itself is the control. The remaining lines—iterating only over $allowed_keys and checking isset($_POST[$key])—are the enforcement of that constraint. They are necessary because without them, you revert to trusting all parameter names again. The engineer also removed esc_sql() on values, recognizing that escaping was a false sense of security when the real problem was that parameter names were never validated in the first place. Prepared statements in $wpdb->insert() handle value safety; the whitelist handles identifier safety. Together they form defense-in-depth.
Hardening Checklist
-
Use
wp_verify_nonce()before processing form submissions: Add a nonce field to the form, validate it in the AJAX handler withwp_verify_nonce($_POST['_wpnonce'], 'action_name'), and reject requests that fail verification, even from unauthenticated users where possible viacheck_ajax_referer(). -
Implement explicit whitelist validation on all POST/GET parameters: Define an array of acceptable parameter names and iterate only over that array. Never use a blacklist to exclude parameters; attackers will find keys you did not anticipate.
-
Use
$wpdb->prepare()for dynamic queries, notesc_sql(): When constructing INSERT or UPDATE statements with user data, use$wpdb->prepare("INSERT INTO table (col1, col2) VALUES (%s, %s)", $val1, $val2)to ensure values are parameterized. Alternatively, rely on$wpdb->insert()with a pre-validated column-name array. -
Sanitize based on context, not just escaping: For form fields, call
sanitize_email(),sanitize_url(), orsanitize_text_field()before database insertion, not during. These functions normalize input;esc_sql()alone does not prevent all injection vectors. -
Add a capability check or nonce to AJAX actions that modify data: Even if the action is registered for unauthenticated users, include
check_ajax_referer()or gate it behindcurrent_user_can()to prevent CSRF and reduce the attack surface.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-3657