This blog post is about an unauthenticated SQL injection vulnerability on the TI WooCommerce Wishlist plugin. If you’re a TI WooCommerce Wishlist user, deactivate and delete the plugin since there is no patched version available.
All paid Patchstack users are protected from this vulnerability. Sign up for the free Community account first, to scan for vulnerabilities and apply protection for only $5 / site per month with Patchstack. For plugin developers, we have security audit services and Enterprise API for hosting companies.
About TI WooCommerce Wishlist plugin
The plugin TI WooCommerce Wishlist, which has over 100,000 active installations, is one of the most popular free plugins for quickly setting up the wishlist functionality with WooCommerce on a WordPress site.
The security vulnerability
In the latest version (2.8.2 as of writing the article) and below, the plugin is vulnerable to a SQL injection vulnerability that allows any users to execute arbitrary SQL queries in the database of the WordPress site. No privileges are required to exploit the issue. The vulnerability is unpatched on the latest version and is tracked as the CVE-2024-43917.
Unauthenticated SQL injection: Case One
The underlying vulnerable code exists in the get() function:
function get( $data = array(), $count = false ) {
global $wpdb;
$default = array(
'count' => 10,
'field' => null,
'offset' => 0,
'order' => 'DESC',
'order_by' => 'date',
'external' => true,
'sql' => '',
);
foreach ( $default as $_k => $_v ) {
if ( array_key_exists( $_k, $data ) ) {
$default[ $_k ] = $data[ $_k ];
unset( $data[ $_k ] );
}
}
// TRIMMED
$default['offset'] = absint( $default['offset'] );
$default['count'] = absint( $default['count'] );
if ( is_array( $default['field'] ) ) {
$default['field'] = '`' . implode( '`,`', $default['field'] ) . '`';
} elseif ( is_string( $default['field'] ) ) {
$default['field'] = array( 'ID', $default['field'] );
$default['field'] = '`' . implode( '`,`', $default['field'] ) . '`';
} else {
$default['field'] = '*';
}
if ( $count ) {
$default['field'] = 'COUNT(`ID`) as `count`';
}
// TRIMMED
$sql = "SELECT {$default[ 'field' ]} FROM `{$this->table}`";
$where = '1';
if ( ! empty( $data ) && is_array( $data ) ) {
if ( array_key_exists( 'meta', $data ) ) {
unset( $data['meta'] );
}
foreach ( $data as $f => $v ) {
$s = is_array( $v ) ? ' IN ' : '=';
if ( is_array( $v ) ) {
foreach ( $v as $_f => $_v ) {
$v[ $_f ] = $wpdb->prepare( '%s', $_v );
}
$v = implode( ',', $v );
$v = "($v)";
} else {
$v = $wpdb->prepare( '%s', $v );
}
$data[ $f ] = sprintf( '`%s`%s%s', $f, $s, $v );
}
$where = implode( ' AND ', $data );
$sql .= ' WHERE ' . $where;
}
// TRIMMED
$sql .= sprintf( ' ORDER BY `%s` %s LIMIT %d,%d;', $default['order_by'], $default['order'], $default['offset'], $default['count'] ); // [1]
$products = $wpdb->get_results( $sql, ARRAY_A ); // WPCS: db call ok; no-cache ok; unprepared SQL ok. [2]
// TRIMMED
if ( empty( $products ) || is_wp_error( $products ) ) {
return array();
$products[ $k ] = apply_filters( 'tinvwl_wishlist_product_get', $product );
}
$_products = wc_get_products( $args );
// Filter wishlist products
$products = apply_filters( 'tinvwl_wishlist_get_products', $products, $this );
return $products;
}
The above function is using string concatenation to form up a SQL query with the user-input as seen in the $sql
variable [1]. The $sql
variable is then getting passed to $wpdb->get_results
() parameter directly [2]. Tracing this function upwards to the source where we can inject the malicious SQL queries, we found that the above function is getting called by the wishlist_get_products()
function:
public function wishlist_get_products( WP_REST_Request $request ) {
$wishlist = $this->get_wishlist_by_share_key( $request );
if ( is_wp_error( $wishlist ) ) {
return $wishlist;
}
$wlp = new TInvWL_Product();
$args = [
'wishlist_id' => $wishlist['wishlist']['ID'],
'external' => false
];
if ( null !== ( $count = $request->get_param( 'count' ) ) ) {
$args['count'] = $count;
}
if ( null !== ( $offset = $request->get_param( 'offset' ) ) ) {
$args['offset'] = $offset;
}
if ( null !== ( $order = $request->get_param( 'order' ) ) ) { // [3]
$args['order'] = $order;
}
$products = $wlp->get( $args ); // [4]
$response = array_map( function ( $product ) use ( $request ) {
return $this->prepare_product_data( $product, 'get_products', $request->get_params() );
}, $products );
return rest_ensure_response( apply_filters( 'tinvwl_api_wishlist_get_products_response', $response ) );
}
In [3], we can see that the order
parameter is getting taken from the user through $request->get_param( 'order' )
. Then, the vulnerable function is getting called at [4] with the user-input. If we go back to the above get()
function, this value is being directly concatenated and executed in the SQL statement confirming the SQLi. Tracing the wishlist_get_products()
function itself, we found that it is called by a REST API endpoint where one can inject their malicious SQL injection queries.
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<share_key>[A-Fa-f0-9]{6})/get_products', [
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'wishlist_get_products' ],
'permission_callback' => '__return_true',
] );
In short, the whole flow of the request towards the vulnerability looks like:
REST API -> wishlist_get_products()
-> get()
Unauthenticated SQL injection: Case Two
The underlying vulnerable code exists in the get_wishlists_data() function:
function get_wishlists_data( $share_key ) {
global $wpdb;
$table = sprintf( '%s%s', $wpdb->prefix, 'tinvwl_items' );
$table_lists = sprintf( '%s%s', $wpdb->prefix, 'tinvwl_lists' );
$table_stats = sprintf( '%s%s', $wpdb->prefix, 'tinvwl_analytics' );
$table_translations = sprintf( '%s%s', $wpdb->prefix, 'icl_translations' );
$table_languages = sprintf( '%s%s', $wpdb->prefix, 'icl_languages' );
$lang = filter_input( INPUT_POST, 'lang', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); // [1]
$lang_default = filter_input( INPUT_POST, 'lang_default', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$stats = filter_input( INPUT_POST, 'stats', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$sql = "SELECT {$default[ 'field' ]} FROM `{$table}` INNER JOIN `{$table_lists}` ON `{$table}`.`wishlist_id` = `{$table_lists}`.`ID` AND `{$table_lists}`.`type` = 'default'";
if ( $lang ) {
if ( $lang_default ) {
$languages = sprintf( "'%s'", implode( "', '", array( $lang, $lang_default ) ) );
} else {
$languages = "'" . $lang . "'";
}
$sql .= "LEFT JOIN {$table_translations} tr ON
{$table}.product_id = tr.element_id AND tr.element_type = 'post_product'
LEFT JOIN {$table_translations} tr2 ON
{$table}.variation_id != 0 AND {$table}.variation_id = tr2.element_id AND tr2.element_type = 'post_product_variation'
LEFT JOIN {$table_translations} t ON
tr.trid = t.trid AND t.element_type = 'post_product' AND t.language_code IN ({$languages})
LEFT JOIN {$table_translations} t2 ON
{$table}.variation_id != 0 AND tr2.trid = t2.trid AND t2.element_type = 'post_product_variation' AND t2.language_code IN ({$languages})
JOIN {$table_languages} l ON
(
t.language_code = l.code OR t2.language_code = l.code
) AND l.active = 1";
}
$results = $wpdb->get_results( $sql, ARRAY_A );
}
The lang and lang_default parameters [1] are being taken from the POST request and passed into the SQL query using concatenation making it vulnerable to SQLi. Tracing this back to the source, it is called in the ajax_action() function.
public function ajax_action(): void {
// TRIMMED
if ( defined( 'DOING_AJAX' ) && DOING_AJAX && isset( $post['tinvwl-security'] ) && wp_verify_nonce( $post['tinvwl-security'], 'wp_rest' ) ) {
$this->wishlist_ajax_actions( $wishlist, $post, $guest_wishlist );
} else {
$response = [
'status' => false,
'msg' => [ __( 'Something went wrong', 'ti-woocommerce-wishlist' ) ],
'icon' => 'icon_big_times',
];
$response['msg'] = array_unique( $response['msg'] );
$response['msg'] = implode( '<br>', $response['msg'] );
// TRIMMED
wp_send_json( $response );
}
}
Tracing that function back yet again, it is hooked in the wc_ajax hook.
private function define_hooks(): void {
add_action( 'wc_ajax_tinvwl', [ $this, 'ajax_action' ] );
}
In short, the whole flow of the request towards the vulnerability looks like:
wc_ajax_tinvwl -> ajax_action
()
-> get_wishlists_data()
The patch
As of writing this article, there is no patched version for the plugin. If the vulnerability gets patched in the near future, we will update the article with the patch information and patched version.
Conclusion
For the SQL query process, always do a safe escape and format for the user’s input before performing a query. The best practice is to use a prepared statement and also cast each of the used variables to its intended usage. For instance, it is always better to cast a variable to an integer if the intended value of the variable should be an integer value.
Want to learn more about finding and fixing vulnerabilities?
Explore our Academy to master the art of finding and patching vulnerabilities within the WordPress ecosystem. Dive deep into detailed guides on various vulnerability types, from discovery tactics for researchers to robust fixes for developers. Join us and contribute to our growing knowledge base.
Timeline
Help us make the Internet a safer place
Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.
- If you’re a plugin developer, join our mVDP program that makes it easier to report, manage and address vulnerabilities in your software.
- If you’re a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.