The Exploit
An authenticated WordPress user with subscriber-level access and above can execute arbitrary SQL queries and trigger PHP object injection by sending a specially crafted AJAX request to the Email Subscribers by Icegram Express plugin.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<subscriber_session>
action=es_campaign&sub_action=update_campaign_status&campaign_ids[]=999 OR 1=1&new_status=active
The attacker observes that the query executes without validation — the campaign_ids parameter bypasses integer type enforcement, allowing SQL injection operators to be passed directly into the database query. The server returns a success response, confirming the malicious operation executed. In parallel, if the attacker crafts a request with a malicious serialized PHP object in the meta field, the plugin deserializes it unsafely, triggering object instantiation chains that can lead to remote code execution.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
Cookie: wordpress_logged_in=<subscriber_session>
action=es_campaign&sub_action=save_campaign&meta=O:8:"BadClass":1:{s:4:"file";s:20:"/tmp/shell.php";}
What the Patch Did
Before:
$campaign_ids = $args['campaign_ids'];
$new_status = $args['new_status'];
if ( ! empty( $campaign_ids ) ) {
$status_updated = ES()->campaigns_db->update_status( $campaign_ids, $new_status );
return $status_updated;
}
After:
if ( is_array( $args ) && isset( $args['campaign_ids'], $args['new_status'] ) ) {
$campaign_ids = isset($args['campaign_ids']) ? $args['campaign_ids'] : array();
$campaign_ids = array_map('absint', $campaign_ids);
$new_status = sanitize_text_field( $args['new_status'] );
if (!empty($campaign_ids)) {
$status_updated = ES()->campaigns_db->update_status( $campaign_ids, $new_status );
return $status_updated;
}
}
The patch added three input validation controls. First, it validates that $args is actually an array using is_array(), and that required keys exist via isset(). Second, it applies array_map('absint', $campaign_ids) to coerce all campaign ID values to unsigned integers, eliminating any non-numeric payload. Third, it applies sanitize_text_field() to the status string to remove dangerous characters. The status values also now validate against expected enum states, preventing injection of unexpected SQL tokens.
For deserialization vulnerabilities, the patch replaced all calls to WordPress's native maybe_unserialize() with a custom ig_es_maybe_unserialize() function that implements safer deserialization, likely validating the structure before instantiation or using JSON-based alternatives.
SQL Query Deserialization Before:
$meta = ! empty( $data['meta'] ) ? maybe_unserialize( $data['meta'] ) : array();
SQL Query Deserialization After:
$meta = ! empty( $data['meta'] ) ? ig_es_maybe_unserialize( $data['meta'] ) : array();
Root Cause
CWE-20 (Improper Input Validation) and CWE-89 (SQL Injection): The AJAX handler handle_ajax_request() in the campaign controller accepts user-supplied campaign_ids and new_status directly from $_POST (via $args) without type checking or whitelist validation. The campaign_ids parameter is expected to be an array of integers but is passed directly to ES()->campaigns_db->update_status(), which constructs a SQL query by concatenating the IDs into an IN() clause without parameterization. An attacker can inject SQL syntax into campaign IDs — for example, 999 OR 1=1 — which reaches the database layer unchecked.
CWE-502 (Deserialization of Untrusted Data): The meta field is deserialized using maybe_unserialize(), which internally calls PHP's native unserialize() on arbitrary user-controlled strings. PHP's unserialize creates object instances during deserialization, allowing an attacker to craft a malicious serialized object (using a gadget chain in the codebase or WordPress itself) that executes code during object construction or destruction.
Why It Works
The load-bearing line in the campaign ID fix is $campaign_ids = array_map('absint', $campaign_ids);. This single operation converts every element in the array to an unsigned integer; any non-numeric portion is truncated to zero. An attacker cannot inject SQL operators because absint('999 OR 1=1') becomes 999. Without this line, the entire fix is cosmetic — the isset() check only verifies keys exist, not their type or safety.
The engineer added is_array() and isset() as depth-of-defense, ensuring the handler fails gracefully if $args is malformed, preventing unexpected type coercion bugs. sanitize_text_field() on new_status follows the same pattern: it removes HTML tags and dangerous characters, but the real control is absint() on the IDs. The deserialization fix is complementary — it moves the handler away from PHP's native unserialize() altogether, eliminating the gadget-chain attack surface entirely.
Hardening Checklist
-
Always apply
absint(),intval(), orfloatval()to numeric parameters before passing them to database queries. In WordPress, usearray_map()for array values. Type coercion must happen before the value reaches SQL construction. -
Use
$wpdb->prepare()with placeholders for every user-controlled SQL value, even integers. Replace string concatenation (e.g.,implode()into IN clauses) with parameterized queries:$wpdb->prepare("WHERE id IN (%d, %d)", $id1, $id2). -
Never call PHP's native
unserialize()on user input. Replace it withjson_decode()if structured data is needed, or implement a custom whitelist-based deserializer that validates object classes before instantiation. -
Wrap AJAX handler methods with
is_array()andisset()checks before accessing$argskeys. WordPress passes$_POSTas$args, which is user-controlled; defensive checks prevent type confusion bugs. -
Make helper methods
privateunless they must be overridden or called externally. In the patch,format_campaign_data()changed frompublictoprivate, reducing the attack surface by preventing external callers from bypassing internal validation.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-4010